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/CHANGELOG.md b/CHANGELOG.md index f5d88052f..38aa18c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2267,7 +2267,7 @@ This is a pre-1.0 version, first major release which is going to be available so *Sep 22, 2015* -#10 Fix Carthage build +- #10 Fix Carthage build ## Nuke 0.3 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/Documentation/Nuke.docc/Extensions/ImageTask-Extension.md b/Documentation/Nuke.docc/Extensions/ImageTask-Extension.md index 492522624..28ac3eff9 100644 --- a/Documentation/Nuke.docc/Extensions/ImageTask-Extension.md +++ b/Documentation/Nuke.docc/Extensions/ImageTask-Extension.md @@ -2,21 +2,34 @@ ## Topics -### Controlling the Task State +### Loading Images with Async/Await -- ``cancel()`` -- ``state-swift.property`` -- ``State-swift.enum`` -- ``priority`` -- ``ImageRequest/Priority-swift.enum`` +- ``image`` +- ``response`` +- ``Error-swift.enum`` -### Task Progress +### Observing Task Events +- ``events`` +- ``previews-swift.property`` - ``progress-swift.property`` +- ``Event-swift.enum`` + +### Monitoring Task State + +- ``state-swift.property`` +- ``State-swift.enum`` +- ``isCancelled`` +- ``currentProgress`` - ``Progress-swift.struct`` -### General Task Information +### Controlling the Task + +- ``cancel()`` +- ``priority`` +- ``ImageRequest/Priority-swift.enum`` + +### Task Information - ``request`` - ``taskId`` -- ``description`` diff --git a/LICENSE b/LICENSE index 724d50d62..bd0918d80 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015-2024 Alexander Grebenyuk +Copyright (c) 2015-2026 Alexander Grebenyuk Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Nuke.xcodeproj/project.pbxproj b/Nuke.xcodeproj/project.pbxproj index 8d53148ba..c6256077f 100644 --- a/Nuke.xcodeproj/project.pbxproj +++ b/Nuke.xcodeproj/project.pbxproj @@ -3,267 +3,29 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ 0C0023052863E81A00B018B0 /* Nuke.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; }; - 0C063F94266524190018F2C2 /* ImageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C063F93266524190018F2C2 /* ImageResponse.swift */; }; - 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 */; }; - 0C0FD6001CA47FE1002A78FB /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0FD5D81CA47FE1002A78FB /* ImageProcessing.swift */; }; - 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 */; }; - 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, ); }; }; - 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 */; }; - 0C222DE5294E2E0300012288 /* NukeExtensions.docc in Sources */ = {isa = PBXBuildFile; fileRef = 0C222DE4294E2E0200012288 /* NukeExtensions.docc */; }; - 0C2A368B26437BF100F1D000 /* TaskLoadData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2A368A26437BF100F1D000 /* TaskLoadData.swift */; }; - 0C2A8CF720970B790013FD65 /* ImagePipelineResumableDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2A8CF620970B790013FD65 /* ImagePipelineResumableDataTests.swift */; }; - 0C2CD6EB25B67FB30017018F /* AsyncPipelineTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2CD6EA25B67FB30017018F /* AsyncPipelineTask.swift */; }; - 0C3261F41FEBC232009276AC /* MockImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068A1BCA888800089D7F /* MockImageProcessor.swift */; }; - 0C3261F51FEBC232009276AC /* MockDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068C1BCA888800089D7F /* MockDataLoader.swift */; }; - 0C3261F71FEBC232009276AC /* MockImageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE745741D4767B900123F65 /* MockImageDecoder.swift */; }; 0C38DB1C28568FE20027F9FF /* Nuke.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; }; - 0C38DB1E28568FE20027F9FF /* s-sepia-less-intense.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE29C243941A20018C8C3 /* s-sepia-less-intense.png */; }; - 0C38DB1F28568FE20027F9FF /* swift.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C64F73E243838BF001983C6 /* swift.png */; }; - 0C38DB2028568FE20027F9FF /* image-p3.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 0C6B5BDA257010B400D763F2 /* image-p3.jpg */; }; - 0C38DB2128568FE20027F9FF /* s-circle-border.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE28624391AA40018C8C3 /* s-circle-border.png */; }; - 0C38DB2228568FE20027F9FF /* img_751.heic in Resources */ = {isa = PBXBuildFile; fileRef = 0C64F73C243836B5001983C6 /* img_751.heic */; }; - 0C38DB2328568FE20027F9FF /* s-circle.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE298243940290018C8C3 /* s-circle.png */; }; - 0C38DB2428568FE20027F9FF /* fixture.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C09B1651FE9A65600E8FE3B /* fixture.jpeg */; }; - 0C38DB2528568FE20027F9FF /* fixture.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D97B2089042400A49DAC /* fixture.png */; }; - 0C38DB2628568FE20027F9FF /* grayscale.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C4B34112572E233000FDDBA /* grayscale.jpeg */; }; - 0C38DB2728568FE20027F9FF /* cat.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0C8DC722209B842600084AA6 /* cat.gif */; }; - 0C38DB2828568FE20027F9FF /* s-sepia.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE29A2439417C0018C8C3 /* s-sepia.png */; }; - 0C38DB2928568FE20027F9FF /* fixture-tiny.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C91B0F72438E84E007F9100 /* fixture-tiny.jpeg */; }; - 0C38DB2A28568FE20027F9FF /* baseline.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D9702089016800A49DAC /* baseline.jpeg */; }; - 0C38DB2B28568FE20027F9FF /* baseline.webp in Resources */ = {isa = PBXBuildFile; fileRef = 0C95FD532571B278008D4FC2 /* baseline.webp */; }; - 0C38DB2C28568FE20027F9FF /* right-orientation.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0CF5456A25B39A0E00B45F1E /* right-orientation.jpeg */; }; - 0C38DB2D28568FE20027F9FF /* s-rounded-corners.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE28A243933550018C8C3 /* s-rounded-corners.png */; }; - 0C38DB2E28568FE20027F9FF /* video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = 0CA4ECA226E67E3D00BAC8E5 /* video.mp4 */; }; - 0C38DB2F28568FE20027F9FF /* progressive.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D96F2089016700A49DAC /* progressive.jpeg */; }; - 0C38DB3028568FE20027F9FF /* s-rounded-corners-border.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE28C2439342C0018C8C3 /* s-rounded-corners-border.png */; }; - 0C38DB3A2856908E0027F9FF /* FetchImageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF52DCB26516F6B0094BC66 /* FetchImageTests.swift */; }; - 0C38DB3B285690960027F9FF /* ImagePipelineObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C14539F2657EFA7005E24B3 /* ImagePipelineObserver.swift */; }; - 0C38DB3C285690960027F9FF /* MockDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6D0A8720E574400037B68F /* MockDataCache.swift */; }; - 0C38DB3D285690960027F9FF /* MockImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068A1BCA888800089D7F /* MockImageProcessor.swift */; }; - 0C38DB3E285690960027F9FF /* MockDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068C1BCA888800089D7F /* MockDataLoader.swift */; }; - 0C38DB3F285690960027F9FF /* MockImageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE745741D4767B900123F65 /* MockImageDecoder.swift */; }; - 0C38DB40285690960027F9FF /* MockImageEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7082602640521900C62638 /* MockImageEncoder.swift */; }; - 0C38DB41285690960027F9FF /* MockImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C06891BCA888800089D7F /* MockImageCache.swift */; }; - 0C38DB42285690960027F9FF /* MockProgressiveDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCBB533217D0B980026F552 /* MockProgressiveDataLoader.swift */; }; - 0C38DB432856909B0027F9FF /* XCTestCase+Nuke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C49232820BACA81001DFCC8 /* XCTestCase+Nuke.swift */; }; - 0C38DB442856909B0027F9FF /* XCTestCaseExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068E1BCA888800089D7F /* XCTestCaseExtensions.swift */; }; - 0C38DB452856909B0027F9FF /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE6202626546FD100AAB8C3 /* CombineExtensions.swift */; }; - 0C38DB462856909B0027F9FF /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068D1BCA888800089D7F /* Helpers.swift */; }; - 0C38DB472856909B0027F9FF /* NukeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAAB00F1E45D6DA00924450 /* NukeExtensions.swift */; }; 0C38DB48285690CF0027F9FF /* NukeUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C38DAE028568FAE0027F9FF /* NukeUI.framework */; }; - 0C472F812654AA46007FC0F0 /* DeprecationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C472F802654AA46007FC0F0 /* DeprecationTests.swift */; }; - 0C472F842654AD88007FC0F0 /* ImageRequestKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C472F822654AD69007FC0F0 /* ImageRequestKeys.swift */; }; - 0C49232920BACA81001DFCC8 /* XCTestCase+Nuke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C49232820BACA81001DFCC8 /* XCTestCase+Nuke.swift */; }; - 0C4AF1EB1FE85539002F86CB /* LinkedListTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4AF1E91FE8551D002F86CB /* LinkedListTest.swift */; }; - 0C4B34122572E233000FDDBA /* grayscale.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C4B34112572E233000FDDBA /* grayscale.jpeg */; }; - 0C4B341C2572E288000FDDBA /* DecompressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4B341B2572E288000FDDBA /* DecompressionTests.swift */; }; - 0C4F8FE822E4B7390070ECFD /* ThreadSafetyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5D81871D46A385000ECCB6 /* ThreadSafetyTests.swift */; }; - 0C4F8FF122E4B8D60070ECFD /* MockImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C06891BCA888800089D7F /* MockImageCache.swift */; }; - 0C4F8FF222E4B8D60070ECFD /* MockImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068A1BCA888800089D7F /* MockImageProcessor.swift */; }; - 0C4F8FF322E4B8D60070ECFD /* MockDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068C1BCA888800089D7F /* MockDataLoader.swift */; }; - 0C4F8FF522E4B8D60070ECFD /* MockImageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE745741D4767B900123F65 /* MockImageDecoder.swift */; }; - 0C4F8FF622E4B8D60070ECFD /* MockDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6D0A8720E574400037B68F /* MockDataCache.swift */; }; - 0C4F8FF722E4B8D60070ECFD /* MockProgressiveDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCBB533217D0B980026F552 /* MockProgressiveDataLoader.swift */; }; - 0C4F8FF822E4B8DA0070ECFD /* XCTestCaseExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068E1BCA888800089D7F /* XCTestCaseExtensions.swift */; }; - 0C4F8FF922E4B8DA0070ECFD /* XCTestCase+Nuke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C49232820BACA81001DFCC8 /* XCTestCase+Nuke.swift */; }; - 0C4F8FFA22E4B8DA0070ECFD /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068D1BCA888800089D7F /* Helpers.swift */; }; - 0C4F8FFB22E4B8DA0070ECFD /* NukeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAAB00F1E45D6DA00924450 /* NukeExtensions.swift */; }; - 0C4F8FFC22E4B8F60070ECFD /* baseline.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D9702089016800A49DAC /* baseline.jpeg */; }; - 0C4F8FFD22E4B8F60070ECFD /* progressive.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D96F2089016700A49DAC /* progressive.jpeg */; }; - 0C4F8FFE22E4B8F60070ECFD /* fixture.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C09B1651FE9A65600E8FE3B /* fixture.jpeg */; }; - 0C4F8FFF22E4B8F60070ECFD /* fixture.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D97B2089042400A49DAC /* fixture.png */; }; - 0C4F900022E4B8F60070ECFD /* cat.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0C8DC722209B842600084AA6 /* cat.gif */; }; 0C4F900322E4C4FB0070ECFD /* Nuke.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; }; - 0C505B6C2286F3AD006D5399 /* AsyncTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C505B6B2286F3AD006D5399 /* AsyncTask.swift */; }; - 0C53C8AF263C7B1700E62D03 /* ImagePipelineDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C53C8AE263C7B1700E62D03 /* ImagePipelineDelegateTests.swift */; }; - 0C53C8B1263C968200E62D03 /* ImagePipeline+Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C53C8B0263C968200E62D03 /* ImagePipeline+Delegate.swift */; }; 0C55FD0528567875000FD2C9 /* NukeExtensions.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C55FCFD28567875000FD2C9 /* NukeExtensions.framework */; }; 0C55FD1028567875000FD2C9 /* NukeExtensions.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C55FCFD28567875000FD2C9 /* NukeExtensions.framework */; }; 0C55FD1128567875000FD2C9 /* NukeExtensions.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0C55FCFD28567875000FD2C9 /* NukeExtensions.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 0C55FD1E28567926000FD2C9 /* ImageLoadingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C55FD1B28567926000FD2C9 /* ImageLoadingOptions.swift */; }; - 0C55FD1F28567926000FD2C9 /* ImageViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C55FD1C28567926000FD2C9 /* ImageViewExtensions.swift */; }; 0C55FD21285679A4000FD2C9 /* Nuke.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; }; - 0C55FD2728567C12000FD2C9 /* ImageViewExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C94466D1D47EC0E006DB314 /* ImageViewExtensionsTests.swift */; }; - 0C55FD2828567C18000FD2C9 /* ImageViewIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCBB52F217D0B6A0026F552 /* ImageViewIntegrationTests.swift */; }; - 0C5D5A9D2724773A0056B95B /* ImagePipelineAsyncAwaitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5D5A9C2724773A0056B95B /* ImagePipelineAsyncAwaitTests.swift */; }; - 0C64F73B24383043001983C6 /* ImageEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C64F73A24383043001983C6 /* ImageEncoderTests.swift */; }; - 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 */; }; - 0C6D0A8820E574400037B68F /* MockDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6D0A8720E574400037B68F /* MockDataCache.swift */; }; - 0C6D0A8C20E57C810037B68F /* ImagePipelineDataCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6D0A8B20E57C810037B68F /* ImagePipelineDataCacheTests.swift */; }; - 0C7082612640521900C62638 /* MockImageEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7082602640521900C62638 /* MockImageEncoder.swift */; }; - 0C7082622640521900C62638 /* MockImageEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7082602640521900C62638 /* MockImageEncoder.swift */; }; - 0C70D9712089016800A49DAC /* progressive.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D96F2089016700A49DAC /* progressive.jpeg */; }; - 0C70D9742089016800A49DAC /* baseline.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D9702089016800A49DAC /* baseline.jpeg */; }; - 0C70D9782089017500A49DAC /* ImageDecoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70D9772089017500A49DAC /* ImageDecoderTests.swift */; }; - 0C70D97C2089042400A49DAC /* fixture.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D97B2089042400A49DAC /* fixture.png */; }; - 0C7150091FC9724C00B880AC /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7150081FC9724C00B880AC /* Extensions.swift */; }; - 0C75279E1D473AEF00EC6222 /* MockImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C06891BCA888800089D7F /* MockImageCache.swift */; }; - 0C75279F1D473AEF00EC6222 /* MockImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068A1BCA888800089D7F /* MockImageProcessor.swift */; }; - 0C7584A429A151FF00F985F8 /* NukeUI.docc in Sources */ = {isa = PBXBuildFile; fileRef = 0C222DE2294E2DEA00012288 /* NukeUI.docc */; }; 0C7584A629A151FF00F985F8 /* Nuke.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; }; - 0C7584AE29A1533700F985F8 /* ImageDecoders+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7584AD29A1533700F985F8 /* ImageDecoders+Video.swift */; }; - 0C7584B029A153B200F985F8 /* AVDataAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7584AF29A153B200F985F8 /* AVDataAsset.swift */; }; - 0C7584B229A1553200F985F8 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7584B129A1553200F985F8 /* VideoPlayerView.swift */; }; - 0C78A2A7263F4E680051E0FF /* ImagePipeline+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C78A2A6263F4E680051E0FF /* ImagePipeline+Cache.swift */; }; - 0C78A2A9263F560A0051E0FF /* ImagePipelineCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C78A2A8263F560A0051E0FF /* ImagePipelineCacheTests.swift */; }; 0C7C067C1BCA882A00089D7F /* Nuke.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; }; - 0C7C06971BCA888800089D7F /* MockDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068C1BCA888800089D7F /* MockDataLoader.swift */; }; - 0C7C06981BCA888800089D7F /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068D1BCA888800089D7F /* Helpers.swift */; }; - 0C7C06991BCA888800089D7F /* XCTestCaseExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068E1BCA888800089D7F /* XCTestCaseExtensions.swift */; }; - 0C7CE28724391AA50018C8C3 /* s-circle-border.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE28624391AA40018C8C3 /* s-circle-border.png */; }; - 0C7CE28B243933550018C8C3 /* s-rounded-corners.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE28A243933550018C8C3 /* s-rounded-corners.png */; }; - 0C7CE28D2439342C0018C8C3 /* s-rounded-corners-border.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE28C2439342C0018C8C3 /* s-rounded-corners-border.png */; }; - 0C7CE28F24393ACC0018C8C3 /* CoreImageFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7CE28E24393ACC0018C8C3 /* CoreImageFilterTests.swift */; }; - 0C7CE299243940290018C8C3 /* s-circle.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE298243940290018C8C3 /* s-circle.png */; }; - 0C7CE29B2439417C0018C8C3 /* s-sepia.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE29A2439417C0018C8C3 /* s-sepia.png */; }; - 0C7CE29D243941A20018C8C3 /* s-sepia-less-intense.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C7CE29C243941A20018C8C3 /* s-sepia-less-intense.png */; }; - 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 */; }; 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 */; }; 0C8D7BDA1D9DBF1600D12EB7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0C8D7BD91D9DBF1600D12EB7 /* Assets.xcassets */; }; 0C8D7BDD1D9DBF1600D12EB7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C8D7BDB1D9DBF1600D12EB7 /* LaunchScreen.storyboard */; }; 0C8D7BED1D9DC02B00D12EB7 /* Nuke.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; }; - 0C8D7BF51D9DC07E00D12EB7 /* DataCachePeformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8D74201D9D6EEB0036349E /* DataCachePeformanceTests.swift */; }; - 0C8DC723209B842600084AA6 /* cat.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0C8DC722209B842600084AA6 /* cat.gif */; }; - 0C91B0EC2438E287007F9100 /* ResizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C91B0EB2438E287007F9100 /* ResizeTests.swift */; }; - 0C91B0EE2438E307007F9100 /* CircleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C91B0ED2438E307007F9100 /* CircleTests.swift */; }; - 0C91B0F02438E352007F9100 /* RoundedCornersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C91B0EF2438E352007F9100 /* RoundedCornersTests.swift */; }; - 0C91B0F22438E374007F9100 /* AnonymousTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C91B0F12438E374007F9100 /* AnonymousTests.swift */; }; - 0C91B0F42438E38B007F9100 /* CompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C91B0F32438E38B007F9100 /* CompositionTests.swift */; }; - 0C91B0F62438E3CB007F9100 /* GaussianBlurTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C91B0F52438E3CB007F9100 /* GaussianBlurTests.swift */; }; - 0C91B0F82438E84E007F9100 /* fixture-tiny.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C91B0F72438E84E007F9100 /* fixture-tiny.jpeg */; }; - 0C933E642859686D00F43606 /* ImageContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C933E632859686D00F43606 /* ImageContainer.swift */; }; - 0C95FD542571B278008D4FC2 /* baseline.webp in Resources */ = {isa = PBXBuildFile; fileRef = 0C95FD532571B278008D4FC2 /* baseline.webp */; }; - 0C967EB328688B3F0050E083 /* DocumentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C967EB228688B3F0050E083 /* DocumentationTests.swift */; }; 0C973E141D9FDB9F00C00AD9 /* Nuke.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 0C9B6E7620B9F3E2001924B8 /* ImagePipelineCoalescingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9B6E7520B9F3E2001924B8 /* ImagePipelineCoalescingTests.swift */; }; - 0CA3BA63285C11EA0079A444 /* ImagePipelineTaskDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3BA62285C11EA0079A444 /* ImagePipelineTaskDelegateTests.swift */; }; - 0CA4EC9926E67CEC00BAC8E5 /* ImageDecoders+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4EC9826E67CEC00BAC8E5 /* ImageDecoders+Default.swift */; }; - 0CA4EC9B26E67D3000BAC8E5 /* ImageDecoders+Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4EC9A26E67D3000BAC8E5 /* ImageDecoders+Empty.swift */; }; - 0CA4EC9F26E67D6200BAC8E5 /* ImageDecoderRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4EC9E26E67D6200BAC8E5 /* ImageDecoderRegistry.swift */; }; - 0CA4ECA126E67D8400BAC8E5 /* AssetType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECA026E67D8400BAC8E5 /* AssetType.swift */; }; - 0CA4ECA426E67ED500BAC8E5 /* video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = 0CA4ECA226E67E3D00BAC8E5 /* video.mp4 */; }; - 0CA4ECAD26E683E300BAC8E5 /* ImageEncoders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECAC26E683E300BAC8E5 /* ImageEncoders.swift */; }; - 0CA4ECAF26E683FD00BAC8E5 /* ImageEncoders+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECAE26E683FD00BAC8E5 /* ImageEncoders+Default.swift */; }; - 0CA4ECB126E6840900BAC8E5 /* ImageEncoders+ImageIO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECB026E6840900BAC8E5 /* ImageEncoders+ImageIO.swift */; }; - 0CA4ECB426E6844B00BAC8E5 /* ImageProcessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECB326E6844B00BAC8E5 /* ImageProcessors.swift */; }; - 0CA4ECB626E6846800BAC8E5 /* ImageProcessors+Resize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECB526E6846800BAC8E5 /* ImageProcessors+Resize.swift */; }; - 0CA4ECBA26E6850B00BAC8E5 /* Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECB926E6850B00BAC8E5 /* Graphics.swift */; }; - 0CA4ECBC26E6856300BAC8E5 /* ImageDecompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECBB26E6856300BAC8E5 /* ImageDecompression.swift */; }; - 0CA4ECBE26E685A900BAC8E5 /* ImageProcessors+Circle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECBD26E685A900BAC8E5 /* ImageProcessors+Circle.swift */; }; - 0CA4ECC026E685C900BAC8E5 /* ImageProcessors+Anonymous.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECBF26E685C900BAC8E5 /* ImageProcessors+Anonymous.swift */; }; - 0CA4ECC226E685E100BAC8E5 /* ImageProcessors+Composition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECC126E685E100BAC8E5 /* ImageProcessors+Composition.swift */; }; - 0CA4ECC426E685F500BAC8E5 /* ImageProcessors+GaussianBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECC326E685F500BAC8E5 /* ImageProcessors+GaussianBlur.swift */; }; - 0CA4ECC626E6862A00BAC8E5 /* ImageProcessors+CoreImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECC526E6862A00BAC8E5 /* ImageProcessors+CoreImage.swift */; }; - 0CA4ECC826E6864D00BAC8E5 /* ImageProcessors+RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECC726E6864D00BAC8E5 /* ImageProcessors+RoundedCorners.swift */; }; - 0CA4ECCA26E6868300BAC8E5 /* ImageProcessingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECC926E6868300BAC8E5 /* ImageProcessingOptions.swift */; }; - 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 */; }; - 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 */; }; - 0CB4030125B6639200F5A241 /* TaskLoadImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4030025B6639200F5A241 /* TaskLoadImage.swift */; }; - 0CB6448928567DC300916267 /* MockImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068A1BCA888800089D7F /* MockImageProcessor.swift */; }; - 0CB6448A28567DC300916267 /* MockDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068C1BCA888800089D7F /* MockDataLoader.swift */; }; - 0CB6448B28567DC300916267 /* MockProgressiveDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCBB533217D0B980026F552 /* MockProgressiveDataLoader.swift */; }; - 0CB6448C28567DC300916267 /* MockDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6D0A8720E574400037B68F /* MockDataCache.swift */; }; - 0CB6448D28567DC300916267 /* MockImageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE745741D4767B900123F65 /* MockImageDecoder.swift */; }; - 0CB6448E28567DC300916267 /* MockImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C06891BCA888800089D7F /* MockImageCache.swift */; }; - 0CB6448F28567DC300916267 /* MockImageEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7082602640521900C62638 /* MockImageEncoder.swift */; }; - 0CB6449028567DC300916267 /* ImagePipelineObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C14539F2657EFA7005E24B3 /* ImagePipelineObserver.swift */; }; - 0CB6449428567DCA00916267 /* XCTestCase+Nuke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C49232820BACA81001DFCC8 /* XCTestCase+Nuke.swift */; }; - 0CB6449528567DCA00916267 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068D1BCA888800089D7F /* Helpers.swift */; }; - 0CB6449628567DCA00916267 /* XCTestCaseExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068E1BCA888800089D7F /* XCTestCaseExtensions.swift */; }; - 0CB6449728567DCA00916267 /* NukeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAAB00F1E45D6DA00924450 /* NukeExtensions.swift */; }; - 0CB6449828567DCA00916267 /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE6202626546FD100AAB8C3 /* CombineExtensions.swift */; }; - 0CB6449A28567DE000916267 /* NukeExtensionsTestsHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6449928567DE000916267 /* NukeExtensionsTestsHelpers.swift */; }; - 0CB6449C28567E5400916267 /* ImageViewLoadingOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6449B28567E5400916267 /* ImageViewLoadingOptionsTests.swift */; }; - 0CB644AB28567EEA00916267 /* ImageViewExtensionsProgressiveDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB644AA28567EEA00916267 /* ImageViewExtensionsProgressiveDecodingTests.swift */; }; 0CB644AC28567FC000916267 /* NukeExtensions.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C55FCFD28567875000FD2C9 /* NukeExtensions.framework */; }; - 0CB644BE2856807F00916267 /* right-orientation.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0CF5456A25B39A0E00B45F1E /* right-orientation.jpeg */; }; - 0CB644BF2856807F00916267 /* cat.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0C8DC722209B842600084AA6 /* cat.gif */; }; - 0CB644C02856807F00916267 /* progressive.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D96F2089016700A49DAC /* progressive.jpeg */; }; - 0CB644C12856807F00916267 /* baseline.webp in Resources */ = {isa = PBXBuildFile; fileRef = 0C95FD532571B278008D4FC2 /* baseline.webp */; }; - 0CB644C22856807F00916267 /* image-p3.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 0C6B5BDA257010B400D763F2 /* image-p3.jpg */; }; - 0CB644C32856807F00916267 /* fixture.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D97B2089042400A49DAC /* fixture.png */; }; - 0CB644C42856807F00916267 /* grayscale.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C4B34112572E233000FDDBA /* grayscale.jpeg */; }; - 0CB644C52856807F00916267 /* fixture-tiny.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C91B0F72438E84E007F9100 /* fixture-tiny.jpeg */; }; - 0CB644C62856807F00916267 /* video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = 0CA4ECA226E67E3D00BAC8E5 /* video.mp4 */; }; - 0CB644C72856807F00916267 /* baseline.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C70D9702089016800A49DAC /* baseline.jpeg */; }; - 0CB644C82856807F00916267 /* img_751.heic in Resources */ = {isa = PBXBuildFile; fileRef = 0C64F73C243836B5001983C6 /* img_751.heic */; }; - 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 */; }; - 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 */; }; - 0CC36A3325B8BC7900811018 /* ResumableData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC36A3225B8BC7900811018 /* ResumableData.swift */; }; - 0CC36A4125B8BCAC00811018 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC36A4025B8BCAC00811018 /* Log.swift */; }; - 0CC6271525BDF7A100466F04 /* ImagePipelineImageCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC6271425BDF7A100466F04 /* ImagePipelineImageCacheTests.swift */; }; - 0CC6278925C100AA00466F04 /* ImagePipelinePerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC6278825C100AA00466F04 /* ImagePipelinePerformanceTests.swift */; }; - 0CC6279025C100BC00466F04 /* ImageViewPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC6278F25C100BC00466F04 /* ImageViewPerformanceTests.swift */; }; - 0CC6279725C100CE00466F04 /* ImageRequestPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC6279625C100CE00466F04 /* ImageRequestPerformanceTests.swift */; }; - 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 */; }; - 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 */; }; - 0CF235E129A16006001BCA2F /* LazyImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C38DB4B285690F90027F9FF /* LazyImageView.swift */; }; - 0CF235E329A16006001BCA2F /* Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C38DB4F285690F90027F9FF /* Internal.swift */; }; - 0CF235E429A16006001BCA2F /* LazyImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C38DB4E285690F90027F9FF /* LazyImage.swift */; }; - 0CF235E529A16006001BCA2F /* FetchImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C55FD1D28567926000FD2C9 /* FetchImage.swift */; }; - 0CF235E629A16006001BCA2F /* LazyImageState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C38DB6F2856A4D30027F9FF /* LazyImageState.swift */; }; - 0CF4DE7D1D412A9E00170289 /* ImagePrefetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF4DE7C1D412A9E00170289 /* ImagePrefetcher.swift */; }; - 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 */ @@ -344,193 +106,74 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0C063F93266524190018F2C2 /* ImageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageResponse.swift; sourceTree = ""; }; - 0C09B1651FE9A65600E8FE3B /* fixture.jpeg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = fixture.jpeg; sourceTree = ""; }; 0C09B16A1FE9A65C00E8FE3B /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 0C0F7BF02287F6EE0034E656 /* TaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTests.swift; sourceTree = ""; }; - 0C0FD5D01CA47FE1002A78FB /* DataLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = ""; }; - 0C0FD5D31CA47FE1002A78FB /* ImagePipeline.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipeline.swift; sourceTree = ""; }; - 0C0FD5D71CA47FE1002A78FB /* ImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; - 0C2A368A26437BF100F1D000 /* TaskLoadData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLoadData.swift; sourceTree = ""; }; - 0C2A8CF620970B790013FD65 /* ImagePipelineResumableDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineResumableDataTests.swift; sourceTree = ""; }; - 0C2A8CFA20970D8D0013FD65 /* ImagePipelineProgressiveDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineProgressiveDecodingTests.swift; sourceTree = ""; }; - 0C2CD6EA25B67FB30017018F /* AsyncPipelineTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPipelineTask.swift; sourceTree = ""; }; 0C38DAE028568FAE0027F9FF /* NukeUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = NukeUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0C38DB3428568FE20027F9FF /* NukeUI Unit Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "NukeUI Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 0C38DB4B285690F90027F9FF /* LazyImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LazyImageView.swift; sourceTree = ""; }; - 0C38DB4E285690F90027F9FF /* LazyImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LazyImage.swift; sourceTree = ""; }; - 0C38DB4F285690F90027F9FF /* Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Internal.swift; sourceTree = ""; }; - 0C38DB6F2856A4D30027F9FF /* LazyImageState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyImageState.swift; sourceTree = ""; }; - 0C3EE97829A862CA0014F5D5 /* Nuke 12 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "Nuke 12 Migration Guide.md"; sourceTree = ""; }; 0C4326262424338200799446 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; - 0C472F802654AA46007FC0F0 /* DeprecationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecationTests.swift; sourceTree = ""; }; - 0C472F822654AD69007FC0F0 /* ImageRequestKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequestKeys.swift; sourceTree = ""; }; - 0C49232820BACA81001DFCC8 /* XCTestCase+Nuke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+Nuke.swift"; sourceTree = ""; }; - 0C4AF1E91FE8551D002F86CB /* LinkedListTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedListTest.swift; sourceTree = ""; }; - 0C4B34112572E233000FDDBA /* grayscale.jpeg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = grayscale.jpeg; sourceTree = ""; }; - 0C4B341B2572E288000FDDBA /* DecompressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecompressionTests.swift; sourceTree = ""; }; - 0C4B6A341E36630E00E86B21 /* Nuke 5 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "Nuke 5 Migration Guide.md"; sourceTree = ""; }; 0C4F8FDF22E4B6ED0070ECFD /* Nuke Thread Safety Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Nuke Thread Safety Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 0C505B6B2286F3AD006D5399 /* AsyncTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTask.swift; sourceTree = ""; }; - 0C53C8AE263C7B1700E62D03 /* ImagePipelineDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineDelegateTests.swift; sourceTree = ""; }; - 0C53C8B0263C968200E62D03 /* ImagePipeline+Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Delegate.swift"; sourceTree = ""; }; - 0C54E64126616E9D00ED1049 /* Nuke 10 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "Nuke 10 Migration Guide.md"; sourceTree = ""; }; 0C55FCFD28567875000FD2C9 /* NukeExtensions.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = NukeExtensions.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0C55FD0428567875000FD2C9 /* NukeExtensions Unit Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "NukeExtensions Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 0C55FD1B28567926000FD2C9 /* ImageLoadingOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoadingOptions.swift; sourceTree = ""; }; - 0C55FD1C28567926000FD2C9 /* ImageViewExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageViewExtensions.swift; sourceTree = ""; }; - 0C55FD1D28567926000FD2C9 /* FetchImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchImage.swift; sourceTree = ""; }; - 0C5D5A9C2724773A0056B95B /* ImagePipelineAsyncAwaitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineAsyncAwaitTests.swift; sourceTree = ""; }; - 0C5D81871D46A385000ECCB6 /* ThreadSafetyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThreadSafetyTests.swift; sourceTree = ""; }; - 0C64F73A24383043001983C6 /* ImageEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEncoderTests.swift; sourceTree = ""; }; - 0C64F73C243836B5001983C6 /* img_751.heic */ = {isa = PBXFileReference; lastKnownFileType = file; path = img_751.heic; sourceTree = ""; }; - 0C64F73E243838BF001983C6 /* swift.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = swift.png; sourceTree = ""; }; - 0C6784DC24341207005B8CF5 /* Nuke 9 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "Nuke 9 Migration Guide.md"; sourceTree = ""; }; - 0C6784DD24341212005B8CF5 /* Nuke 8 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "Nuke 8 Migration Guide.md"; sourceTree = ""; }; - 0C68F608208A1F40007DC696 /* ImageDecoderRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecoderRegistryTests.swift; sourceTree = ""; }; - 0C6B5BDA257010B400D763F2 /* image-p3.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "image-p3.jpg"; sourceTree = ""; }; - 0C6B5BE0257010D300D763F2 /* ImagePipelineFormatsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineFormatsTests.swift; sourceTree = ""; }; - 0C6D0A8720E574400037B68F /* MockDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataCache.swift; sourceTree = ""; }; - 0C6D0A8B20E57C810037B68F /* ImagePipelineDataCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineDataCacheTests.swift; sourceTree = ""; }; - 0C7082602640521900C62638 /* MockImageEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageEncoder.swift; sourceTree = ""; }; - 0C70D96F2089016700A49DAC /* progressive.jpeg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = progressive.jpeg; sourceTree = ""; }; - 0C70D9702089016800A49DAC /* baseline.jpeg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = baseline.jpeg; sourceTree = ""; }; - 0C70D9772089017500A49DAC /* ImageDecoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecoderTests.swift; sourceTree = ""; }; - 0C70D97B2089042400A49DAC /* fixture.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = fixture.png; sourceTree = ""; }; - 0C7150081FC9724C00B880AC /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; 0C7584AA29A151FF00F985F8 /* NukeVideo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = NukeVideo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 0C7584AD29A1533700F985F8 /* ImageDecoders+Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Video.swift"; sourceTree = ""; }; - 0C7584AF29A153B200F985F8 /* AVDataAsset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVDataAsset.swift; sourceTree = ""; }; - 0C7584B129A1553200F985F8 /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; - 0C78A2A6263F4E680051E0FF /* ImagePipeline+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Cache.swift"; sourceTree = ""; }; - 0C78A2A8263F560A0051E0FF /* ImagePipelineCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineCacheTests.swift; sourceTree = ""; }; 0C7C06771BCA882A00089D7F /* Nuke Unit Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Nuke Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 0C7C06871BCA888800089D7F /* ImageCacheTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCacheTests.swift; sourceTree = ""; }; - 0C7C06891BCA888800089D7F /* MockImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockImageCache.swift; sourceTree = ""; }; - 0C7C068A1BCA888800089D7F /* MockImageProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockImageProcessor.swift; sourceTree = ""; }; - 0C7C068C1BCA888800089D7F /* MockDataLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDataLoader.swift; sourceTree = ""; }; - 0C7C068D1BCA888800089D7F /* Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; - 0C7C068E1BCA888800089D7F /* XCTestCaseExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XCTestCaseExtensions.swift; sourceTree = ""; }; - 0C7CE28624391AA40018C8C3 /* s-circle-border.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "s-circle-border.png"; sourceTree = ""; }; - 0C7CE28A243933550018C8C3 /* s-rounded-corners.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "s-rounded-corners.png"; sourceTree = ""; }; - 0C7CE28C2439342C0018C8C3 /* s-rounded-corners-border.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "s-rounded-corners-border.png"; sourceTree = ""; }; - 0C7CE28E24393ACC0018C8C3 /* CoreImageFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreImageFilterTests.swift; sourceTree = ""; }; - 0C7CE298243940290018C8C3 /* s-circle.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "s-circle.png"; sourceTree = ""; }; - 0C7CE29A2439417C0018C8C3 /* s-sepia.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "s-sepia.png"; sourceTree = ""; }; - 0C7CE29C243941A20018C8C3 /* s-sepia-less-intense.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "s-sepia-less-intense.png"; sourceTree = ""; }; - 0C86AB69228B3B5100A81BA1 /* ImageTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTask.swift; sourceTree = ""; }; - 0C880531242E7B1500F8C5B3 /* ImagePipelineDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineDecodingTests.swift; sourceTree = ""; }; - 0C88C578263DAF1E0061A008 /* ImagePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePublisherTests.swift; sourceTree = ""; }; - 0C8D74201D9D6EEB0036349E /* DataCachePeformanceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataCachePeformanceTests.swift; sourceTree = ""; }; 0C8D7BD01D9DBF1600D12EB7 /* Nuke Tests Host.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Nuke Tests Host.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 0C8D7BD21D9DBF1600D12EB7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 0C8D7BD41D9DBF1600D12EB7 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 0C8D7BD71D9DBF1600D12EB7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 0C8D7BD91D9DBF1600D12EB7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0C8D7BDC1D9DBF1600D12EB7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 0C8D7BDE1D9DBF1600D12EB7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0C8D7BE81D9DC02B00D12EB7 /* Nuke Performance Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Nuke Performance Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 0C8DC722209B842600084AA6 /* cat.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = cat.gif; sourceTree = ""; }; 0C9174901BAE99EE004A7905 /* Nuke.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Nuke.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 0C91B0EB2438E287007F9100 /* ResizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResizeTests.swift; sourceTree = ""; }; - 0C91B0ED2438E307007F9100 /* CircleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleTests.swift; sourceTree = ""; }; - 0C91B0EF2438E352007F9100 /* RoundedCornersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedCornersTests.swift; sourceTree = ""; }; - 0C91B0F12438E374007F9100 /* AnonymousTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnonymousTests.swift; sourceTree = ""; }; - 0C91B0F32438E38B007F9100 /* CompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionTests.swift; sourceTree = ""; }; - 0C91B0F52438E3CB007F9100 /* GaussianBlurTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GaussianBlurTests.swift; sourceTree = ""; }; - 0C91B0F72438E84E007F9100 /* fixture-tiny.jpeg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "fixture-tiny.jpeg"; sourceTree = ""; }; - 0C933E632859686D00F43606 /* ImageContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContainer.swift; sourceTree = ""; }; - 0C94466D1D47EC0E006DB314 /* ImageViewExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageViewExtensionsTests.swift; sourceTree = ""; }; - 0C95FD532571B278008D4FC2 /* baseline.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = baseline.webp; sourceTree = ""; }; - 0C967EB228688B3F0050E083 /* DocumentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentationTests.swift; sourceTree = ""; }; - 0C97DD9E284C00EA00F55FDA /* Nuke 11 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "Nuke 11 Migration Guide.md"; sourceTree = ""; }; - 0C9B6E7520B9F3E2001924B8 /* ImagePipelineCoalescingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineCoalescingTests.swift; sourceTree = ""; }; - 0CA3BA62285C11EA0079A444 /* ImagePipelineTaskDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineTaskDelegateTests.swift; sourceTree = ""; }; - 0CA4EC9826E67CEC00BAC8E5 /* ImageDecoders+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Default.swift"; sourceTree = ""; }; - 0CA4EC9A26E67D3000BAC8E5 /* ImageDecoders+Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+Empty.swift"; sourceTree = ""; }; - 0CA4EC9E26E67D6200BAC8E5 /* ImageDecoderRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecoderRegistry.swift; sourceTree = ""; }; - 0CA4ECA026E67D8400BAC8E5 /* AssetType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetType.swift; sourceTree = ""; }; - 0CA4ECA226E67E3D00BAC8E5 /* video.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = video.mp4; sourceTree = ""; }; - 0CA4ECAC26E683E300BAC8E5 /* ImageEncoders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEncoders.swift; sourceTree = ""; }; - 0CA4ECAE26E683FD00BAC8E5 /* ImageEncoders+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageEncoders+Default.swift"; sourceTree = ""; }; - 0CA4ECB026E6840900BAC8E5 /* ImageEncoders+ImageIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageEncoders+ImageIO.swift"; sourceTree = ""; }; - 0CA4ECB326E6844B00BAC8E5 /* ImageProcessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessors.swift; sourceTree = ""; }; - 0CA4ECB526E6846800BAC8E5 /* ImageProcessors+Resize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Resize.swift"; sourceTree = ""; }; - 0CA4ECB926E6850B00BAC8E5 /* Graphics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Graphics.swift; sourceTree = ""; }; - 0CA4ECBB26E6856300BAC8E5 /* ImageDecompression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecompression.swift; sourceTree = ""; }; - 0CA4ECBD26E685A900BAC8E5 /* ImageProcessors+Circle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Circle.swift"; sourceTree = ""; }; - 0CA4ECBF26E685C900BAC8E5 /* ImageProcessors+Anonymous.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Anonymous.swift"; sourceTree = ""; }; - 0CA4ECC126E685E100BAC8E5 /* ImageProcessors+Composition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+Composition.swift"; sourceTree = ""; }; - 0CA4ECC326E685F500BAC8E5 /* ImageProcessors+GaussianBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+GaussianBlur.swift"; sourceTree = ""; }; - 0CA4ECC526E6862A00BAC8E5 /* ImageProcessors+CoreImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+CoreImage.swift"; sourceTree = ""; }; - 0CA4ECC726E6864D00BAC8E5 /* ImageProcessors+RoundedCorners.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageProcessors+RoundedCorners.swift"; sourceTree = ""; }; - 0CA4ECC926E6868300BAC8E5 /* ImageProcessingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessingOptions.swift; sourceTree = ""; }; - 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 = ""; }; - 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 = ""; }; - 0CB4030025B6639200F5A241 /* TaskLoadImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLoadImage.swift; sourceTree = ""; }; - 0CB6449928567DE000916267 /* NukeExtensionsTestsHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeExtensionsTestsHelpers.swift; sourceTree = ""; }; - 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 = ""; }; - 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 = ""; }; - 0CC36A3225B8BC7900811018 /* ResumableData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumableData.swift; sourceTree = ""; }; - 0CC36A4025B8BCAC00811018 /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; - 0CC6271425BDF7A100466F04 /* ImagePipelineImageCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineImageCacheTests.swift; sourceTree = ""; }; - 0CC6278825C100AA00466F04 /* ImagePipelinePerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelinePerformanceTests.swift; sourceTree = ""; }; - 0CC6278F25C100BC00466F04 /* ImageViewPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewPerformanceTests.swift; sourceTree = ""; }; - 0CC6279625C100CE00466F04 /* ImageRequestPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequestPerformanceTests.swift; sourceTree = ""; }; - 0CC6279D25C100E300466F04 /* ImageCachePerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCachePerformanceTests.swift; sourceTree = ""; }; - 0CC627A425C100FA00466F04 /* ImageProcessingPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessingPerformanceTests.swift; sourceTree = ""; }; - 0CCBB52F217D0B6A0026F552 /* ImageViewIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewIntegrationTests.swift; sourceTree = ""; }; - 0CCBB533217D0B980026F552 /* MockProgressiveDataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProgressiveDataLoader.swift; sourceTree = ""; }; - 0CD195291D4348AC00E011BB /* ImagePrefetcherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePrefetcherTests.swift; sourceTree = ""; }; - 0CD37C9925BA36D5006C2C36 /* ImagePipelineLoadDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineLoadDataTests.swift; sourceTree = ""; }; - 0CDB927D1DAF9BA500002905 /* Nuke 4 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "Nuke 4 Migration Guide.md"; sourceTree = ""; }; 0CDB92801DAF9BB900002905 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; 0CDB92821DAF9BC600002905 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 0CDB92831DAF9BCB00002905 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; - 0CE2D9B92084FDDD00934B28 /* ImageDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecoding.swift; sourceTree = ""; }; - 0CE334DA2724563D0017BB8D /* ImageProcessorsProtocolExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessorsProtocolExtensionsTests.swift; sourceTree = ""; }; - 0CE3992C1D4697CE00A87D47 /* ImagePipelineTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePipelineTests.swift; sourceTree = ""; }; - 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 = ""; }; - 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 = ""; }; - 0CF1754B22913F9800A8946E /* ImagePipeline+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Configuration.swift"; sourceTree = ""; }; - 0CF4DE7C1D412A9E00170289 /* ImagePrefetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePrefetcher.swift; sourceTree = ""; }; - 0CF52DCB26516F6B0094BC66 /* FetchImageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchImageTests.swift; sourceTree = ""; }; - 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 PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 0C0C85572D99ACCC006A2138 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Nuke.docc, + ); + target = 0C91748F1BAE99EE004A7905 /* Nuke */; + }; + 0C0C85592D99ACD0006A2138 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + NukeExtensions.docc, + ); + target = 0C55FCFC28567875000FD2C9 /* NukeExtensions */; + }; + 0C0C855B2D99ACD5006A2138 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + NukeUI.docc, + ); + target = 0C38DAA128568FAE0027F9FF /* NukeUI */; + }; + 0CB25DB72DAB328B000A93A3 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "Extensions/AsyncExpectation+Extensions.swift", + ); + target = 0C8D7BE71D9DC02B00D12EB7 /* Nuke Performance Tests */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 0C0C815E2D99A9A6006A2138 /* Nuke */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Nuke; sourceTree = ""; }; + 0C0C819A2D99A9AC006A2138 /* NukeExtensions */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = NukeExtensions; sourceTree = ""; }; + 0C0C81A32D99A9B1006A2138 /* NukeUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = NukeUI; sourceTree = ""; }; + 0C0C81AC2D99A9B6006A2138 /* NukeVideo */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = NukeVideo; sourceTree = ""; }; + 0C0C823F2D99AA9B006A2138 /* Helpers */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (0CB25DB72DAB328B000A93A3 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Helpers; sourceTree = ""; }; + 0C0C82A12D99ABD0006A2138 /* Resources */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Resources; sourceTree = ""; }; + 0C0C83322D99AC05006A2138 /* NukeTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = NukeTests; sourceTree = ""; }; + 0C0C83592D99AC13006A2138 /* NukeUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = NukeUITests; sourceTree = ""; }; + 0C0C83622D99AC20006A2138 /* NukeExtensionsTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = NukeExtensionsTests; sourceTree = ""; }; + 0C0C836B2D99AC2C006A2138 /* NukeThreadSafetyTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = NukeThreadSafetyTests; sourceTree = ""; }; + 0C0C83732D99AC37006A2138 /* NukePerformanceTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = NukePerformanceTests; sourceTree = ""; }; + 0C0C85552D99ACC5006A2138 /* Documentation */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (0C0C85572D99ACCC006A2138 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 0C0C855B2D99ACD5006A2138 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 0C0C85592D99ACD0006A2138 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Documentation; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 0C0023042863E81700B018B0 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -613,63 +256,14 @@ 0C096C7B1BAE9ADD007FE380 /* Sources */ = { isa = PBXGroup; children = ( - 0CC13A0F285677F800E0557D /* Nuke */, - 0C38DAE228568FC30027F9FF /* NukeUI */, - 0C7584AC29A1530000F985F8 /* NukeVideo */, - 0C55FD1A28567926000FD2C9 /* NukeExtensions */, + 0C0C81AC2D99A9B6006A2138 /* NukeVideo */, + 0C0C81A32D99A9B1006A2138 /* NukeUI */, + 0C0C819A2D99A9AC006A2138 /* NukeExtensions */, + 0C0C815E2D99A9A6006A2138 /* Nuke */, ); path = Sources; sourceTree = ""; }; - 0C09B1641FE9A62400E8FE3B /* Resources */ = { - isa = PBXGroup; - children = ( - 0C70D9702089016800A49DAC /* baseline.jpeg */, - 0CF5456A25B39A0E00B45F1E /* right-orientation.jpeg */, - 0C4B34112572E233000FDDBA /* grayscale.jpeg */, - 0C70D96F2089016700A49DAC /* progressive.jpeg */, - 0C95FD532571B278008D4FC2 /* baseline.webp */, - 0C09B1651FE9A65600E8FE3B /* fixture.jpeg */, - 0C91B0F72438E84E007F9100 /* fixture-tiny.jpeg */, - 0C70D97B2089042400A49DAC /* fixture.png */, - 0C64F73E243838BF001983C6 /* swift.png */, - 0C6B5BDA257010B400D763F2 /* image-p3.jpg */, - 0C64F73C243836B5001983C6 /* img_751.heic */, - 0C8DC722209B842600084AA6 /* cat.gif */, - 0CA4ECA226E67E3D00BAC8E5 /* video.mp4 */, - 0C91B0F92438E8AC007F9100 /* Snapshots */, - ); - path = Resources; - sourceTree = ""; - }; - 0C38DAE228568FC30027F9FF /* NukeUI */ = { - isa = PBXGroup; - children = ( - 0C38DB4E285690F90027F9FF /* LazyImage.swift */, - 0C38DB6F2856A4D30027F9FF /* LazyImageState.swift */, - 0C55FD1D28567926000FD2C9 /* FetchImage.swift */, - 0C38DB4B285690F90027F9FF /* LazyImageView.swift */, - 0C38DB4F285690F90027F9FF /* Internal.swift */, - ); - path = NukeUI; - sourceTree = ""; - }; - 0C38DB3628568FFD0027F9FF /* NukeUITests */ = { - isa = PBXGroup; - children = ( - 0CF52DCB26516F6B0094BC66 /* FetchImageTests.swift */, - ); - path = NukeUITests; - sourceTree = ""; - }; - 0C4F8FE722E4B7260070ECFD /* NukeThreadSafetyTests */ = { - isa = PBXGroup; - children = ( - 0C5D81871D46A385000ECCB6 /* ThreadSafetyTests.swift */, - ); - path = NukeThreadSafetyTests; - sourceTree = ""; - }; 0C4F900222E4C4FB0070ECFD /* Frameworks */ = { isa = PBXGroup; children = ( @@ -677,113 +271,29 @@ name = Frameworks; sourceTree = ""; }; - 0C55FD1A28567926000FD2C9 /* NukeExtensions */ = { - isa = PBXGroup; - children = ( - 0C55FD1B28567926000FD2C9 /* ImageLoadingOptions.swift */, - 0C55FD1C28567926000FD2C9 /* ImageViewExtensions.swift */, - ); - path = NukeExtensions; - sourceTree = ""; - }; - 0C55FD22285679DC000FD2C9 /* NukeTests */ = { - isa = PBXGroup; - children = ( - 0C1E620A1D6F817700AD5CF5 /* ImageRequestTests.swift */, - 4480674B2A448C9F00DE7CF8 /* DataPublisherTests.swift */, - 0C7C06871BCA888800089D7F /* ImageCacheTests.swift */, - 0C70D9772089017500A49DAC /* ImageDecoderTests.swift */, - 0C68F608208A1F40007DC696 /* ImageDecoderRegistryTests.swift */, - 0C64F73A24383043001983C6 /* ImageEncoderTests.swift */, - 0CD195291D4348AC00E011BB /* ImagePrefetcherTests.swift */, - 0CB26806208F25C2004C83F4 /* DataCacheTests.swift */, - 0CE5F681215638300046609F /* ResumableDataTests.swift */, - 0C4AF1E91FE8551D002F86CB /* LinkedListTest.swift */, - 0CB2EFD52110F52C00F7C63F /* RateLimiterTests.swift */, - 0C0F7BF02287F6EE0034E656 /* TaskTests.swift */, - 0C88C578263DAF1E0061A008 /* ImagePublisherTests.swift */, - 0C472F802654AA46007FC0F0 /* DeprecationTests.swift */, - 0C91B0E82438E245007F9100 /* ImagePipelineTests */, - 0C91B0EA2438E269007F9100 /* ImageProcessorsTests */, - ); - path = NukeTests; - sourceTree = ""; - }; - 0C55FD2428567C10000FD2C9 /* NukeExtensionsTests */ = { - isa = PBXGroup; - children = ( - 0CCBB52F217D0B6A0026F552 /* ImageViewIntegrationTests.swift */, - 0C94466D1D47EC0E006DB314 /* ImageViewExtensionsTests.swift */, - 0CB6449B28567E5400916267 /* ImageViewLoadingOptionsTests.swift */, - 0CB6449928567DE000916267 /* NukeExtensionsTestsHelpers.swift */, - 0CB644AA28567EEA00916267 /* ImageViewExtensionsProgressiveDecodingTests.swift */, - ); - path = NukeExtensionsTests; - sourceTree = ""; - }; - 0C7584AC29A1530000F985F8 /* NukeVideo */ = { - isa = PBXGroup; - children = ( - 0C7584AD29A1533700F985F8 /* ImageDecoders+Video.swift */, - 0C7584AF29A153B200F985F8 /* AVDataAsset.swift */, - 0C7584B129A1553200F985F8 /* VideoPlayerView.swift */, - ); - path = NukeVideo; - sourceTree = ""; - }; 0C7C06551BCA87EC00089D7F /* Tests */ = { isa = PBXGroup; children = ( + 0C0C82A12D99ABD0006A2138 /* Resources */, + 0C0C823F2D99AA9B006A2138 /* Helpers */, 0C09B16A1FE9A65C00E8FE3B /* Info.plist */, - 0C55FD22285679DC000FD2C9 /* NukeTests */, - 0C38DB3628568FFD0027F9FF /* NukeUITests */, - 0C55FD2428567C10000FD2C9 /* NukeExtensionsTests */, - 0C4F8FE722E4B7260070ECFD /* NukeThreadSafetyTests */, - 0CC6276525C0A16300466F04 /* NukePerformanceTests */, - 0C7C069B1BCA889000089D7F /* Extensions */, + 0C0C83322D99AC05006A2138 /* NukeTests */, + 0C0C83592D99AC13006A2138 /* NukeUITests */, + 0C0C83622D99AC20006A2138 /* NukeExtensionsTests */, + 0C0C836B2D99AC2C006A2138 /* NukeThreadSafetyTests */, + 0C0C83732D99AC37006A2138 /* NukePerformanceTests */, 0C8D7BD11D9DBF1600D12EB7 /* Host */, - 0C7C069A1BCA888C00089D7F /* Mocks */, - 0C09B1641FE9A62400E8FE3B /* Resources */, ); path = Tests; sourceTree = ""; }; - 0C7C069A1BCA888C00089D7F /* Mocks */ = { - isa = PBXGroup; - children = ( - 0C7C06891BCA888800089D7F /* MockImageCache.swift */, - 0C7C068A1BCA888800089D7F /* MockImageProcessor.swift */, - 0C7C068C1BCA888800089D7F /* MockDataLoader.swift */, - 0CE745741D4767B900123F65 /* MockImageDecoder.swift */, - 0C6D0A8720E574400037B68F /* MockDataCache.swift */, - 0CCBB533217D0B980026F552 /* MockProgressiveDataLoader.swift */, - 0C7082602640521900C62638 /* MockImageEncoder.swift */, - 0C14539F2657EFA7005E24B3 /* ImagePipelineObserver.swift */, - ); - name = Mocks; - sourceTree = ""; - }; - 0C7C069B1BCA889000089D7F /* Extensions */ = { - isa = PBXGroup; - children = ( - 0C7C068E1BCA888800089D7F /* XCTestCaseExtensions.swift */, - 0C49232820BACA81001DFCC8 /* XCTestCase+Nuke.swift */, - 0C7C068D1BCA888800089D7F /* Helpers.swift */, - 0CAAB00F1E45D6DA00924450 /* NukeExtensions.swift */, - 0CE6202626546FD100AAB8C3 /* CombineExtensions.swift */, - ); - name = Extensions; - sourceTree = ""; - }; 0C8D7BD11D9DBF1600D12EB7 /* Host */ = { isa = PBXGroup; children = ( 0C8D7BD21D9DBF1600D12EB7 /* AppDelegate.swift */, - 0C8D7BD41D9DBF1600D12EB7 /* ViewController.swift */, 0C8D7BD61D9DBF1600D12EB7 /* Main.storyboard */, 0C8D7BD91D9DBF1600D12EB7 /* Assets.xcassets */, 0C8D7BDB1D9DBF1600D12EB7 /* LaunchScreen.storyboard */, - 0C8D7BDE1D9DBF1600D12EB7 /* Info.plist */, ); path = Host; sourceTree = ""; @@ -793,8 +303,8 @@ children = ( 0C096C7B1BAE9ADD007FE380 /* Sources */, 0C7C06551BCA87EC00089D7F /* Tests */, + 0C0C85552D99ACC5006A2138 /* Documentation */, 0C9174911BAE99EE004A7905 /* Products */, - 0CDB92761DAF9BA500002905 /* Documentation */, 0CDB927F1DAF9BA900002905 /* Metadata */, 0C4F900222E4C4FB0070ECFD /* Frameworks */, ); @@ -817,234 +327,6 @@ name = Products; sourceTree = ""; }; - 0C91B0E82438E245007F9100 /* ImagePipelineTests */ = { - isa = PBXGroup; - children = ( - 0CE3992C1D4697CE00A87D47 /* ImagePipelineTests.swift */, - 0CC6271425BDF7A100466F04 /* ImagePipelineImageCacheTests.swift */, - 0C6D0A8B20E57C810037B68F /* ImagePipelineDataCacheTests.swift */, - 0C9B6E7520B9F3E2001924B8 /* ImagePipelineCoalescingTests.swift */, - 0CB2EFD12110F38600F7C63F /* ImagePipelineConfigurationTests.swift */, - 0C2A8CF620970B790013FD65 /* ImagePipelineResumableDataTests.swift */, - 0C2A8CFA20970D8D0013FD65 /* ImagePipelineProgressiveDecodingTests.swift */, - 2DFD93AF233A6AB300D84DB9 /* ImagePipelineProcessorTests.swift */, - 0C880531242E7B1500F8C5B3 /* ImagePipelineDecodingTests.swift */, - 0C6B5BE0257010D300D763F2 /* ImagePipelineFormatsTests.swift */, - 0CD37C9925BA36D5006C2C36 /* ImagePipelineLoadDataTests.swift */, - 0C53C8AE263C7B1700E62D03 /* ImagePipelineDelegateTests.swift */, - 0CA3BA62285C11EA0079A444 /* ImagePipelineTaskDelegateTests.swift */, - 0CE6202426543EC700AAB8C3 /* ImagePipelinePublisherTests.swift */, - 0C5D5A9C2724773A0056B95B /* ImagePipelineAsyncAwaitTests.swift */, - 0C78A2A8263F560A0051E0FF /* ImagePipelineCacheTests.swift */, - 0C967EB228688B3F0050E083 /* DocumentationTests.swift */, - ); - path = ImagePipelineTests; - sourceTree = ""; - }; - 0C91B0EA2438E269007F9100 /* ImageProcessorsTests */ = { - isa = PBXGroup; - children = ( - 0C4B341B2572E288000FDDBA /* DecompressionTests.swift */, - 0C91B0EB2438E287007F9100 /* ResizeTests.swift */, - 0C91B0ED2438E307007F9100 /* CircleTests.swift */, - 0C91B0EF2438E352007F9100 /* RoundedCornersTests.swift */, - 0C7CE28E24393ACC0018C8C3 /* CoreImageFilterTests.swift */, - 0C91B0F52438E3CB007F9100 /* GaussianBlurTests.swift */, - 0C91B0F12438E374007F9100 /* AnonymousTests.swift */, - 0C91B0F32438E38B007F9100 /* CompositionTests.swift */, - 0CF58FF626DAAC3800D2650D /* ImageDownsampleTests.swift */, - 0CE334DA2724563D0017BB8D /* ImageProcessorsProtocolExtensionsTests.swift */, - ); - path = ImageProcessorsTests; - sourceTree = ""; - }; - 0C91B0F92438E8AC007F9100 /* Snapshots */ = { - isa = PBXGroup; - children = ( - 0C7CE298243940290018C8C3 /* s-circle.png */, - 0C7CE28624391AA40018C8C3 /* s-circle-border.png */, - 0C7CE28A243933550018C8C3 /* s-rounded-corners.png */, - 0C7CE28C2439342C0018C8C3 /* s-rounded-corners-border.png */, - 0C7CE29A2439417C0018C8C3 /* s-sepia.png */, - 0C7CE29C243941A20018C8C3 /* s-sepia-less-intense.png */, - ); - path = Snapshots; - sourceTree = ""; - }; - 0CA4EC9726E67CCB00BAC8E5 /* Decoding */ = { - isa = PBXGroup; - children = ( - 0CE2D9B92084FDDD00934B28 /* ImageDecoding.swift */, - 0CA4EC9826E67CEC00BAC8E5 /* ImageDecoders+Default.swift */, - 0CA4EC9A26E67D3000BAC8E5 /* ImageDecoders+Empty.swift */, - 0CA4EC9E26E67D6200BAC8E5 /* ImageDecoderRegistry.swift */, - 0CA4ECA026E67D8400BAC8E5 /* AssetType.swift */, - ); - path = Decoding; - sourceTree = ""; - }; - 0CA4ECAB26E683D200BAC8E5 /* Encoding */ = { - isa = PBXGroup; - children = ( - 0C179C7A2283597F008AB488 /* ImageEncoding.swift */, - 0CA4ECAC26E683E300BAC8E5 /* ImageEncoders.swift */, - 0CA4ECAE26E683FD00BAC8E5 /* ImageEncoders+Default.swift */, - 0CA4ECB026E6840900BAC8E5 /* ImageEncoders+ImageIO.swift */, - ); - path = Encoding; - sourceTree = ""; - }; - 0CA4ECB226E6843800BAC8E5 /* Processing */ = { - isa = PBXGroup; - children = ( - 0C0FD5D81CA47FE1002A78FB /* ImageProcessing.swift */, - 0CA4ECB326E6844B00BAC8E5 /* ImageProcessors.swift */, - 0CA4ECB526E6846800BAC8E5 /* ImageProcessors+Resize.swift */, - 0CA4ECBD26E685A900BAC8E5 /* ImageProcessors+Circle.swift */, - 0CA4ECC726E6864D00BAC8E5 /* ImageProcessors+RoundedCorners.swift */, - 0CA4ECC526E6862A00BAC8E5 /* ImageProcessors+CoreImage.swift */, - 0CA4ECC326E685F500BAC8E5 /* ImageProcessors+GaussianBlur.swift */, - 0CA4ECC126E685E100BAC8E5 /* ImageProcessors+Composition.swift */, - 0CA4ECBF26E685C900BAC8E5 /* ImageProcessors+Anonymous.swift */, - 0CA4ECC926E6868300BAC8E5 /* ImageProcessingOptions.swift */, - 0CA4ECBB26E6856300BAC8E5 /* ImageDecompression.swift */, - ); - path = Processing; - sourceTree = ""; - }; - 0CA4ECCB26E68F9900BAC8E5 /* Loading */ = { - isa = PBXGroup; - children = ( - 0CA4ECCC26E68FA100BAC8E5 /* DataLoading.swift */, - 0C0FD5D01CA47FE1002A78FB /* DataLoader.swift */, - ); - path = Loading; - sourceTree = ""; - }; - 0CA4ECD126E68FD300BAC8E5 /* Caching */ = { - isa = PBXGroup; - children = ( - 0CA4ECD226E68FDC00BAC8E5 /* ImageCaching.swift */, - 0C0FD5D71CA47FE1002A78FB /* ImageCache.swift */, - 0CA4ECCF26E68FC000BAC8E5 /* DataCaching.swift */, - 0CB26801208F2565004C83F4 /* DataCache.swift */, - 0CB047992856D9AC00DF9B6D /* Cache.swift */, - ); - path = Caching; - sourceTree = ""; - }; - 0CA4ECD526E6903F00BAC8E5 /* Prefetching */ = { - isa = PBXGroup; - children = ( - 0CF4DE7C1D412A9E00170289 /* ImagePrefetcher.swift */, - ); - path = Prefetching; - sourceTree = ""; - }; - 0CB402D325B6568800F5A241 /* Tasks */ = { - isa = PBXGroup; - children = ( - 0C505B6B2286F3AD006D5399 /* AsyncTask.swift */, - 0C2CD6EA25B67FB30017018F /* AsyncPipelineTask.swift */, - 0CB4030025B6639200F5A241 /* TaskLoadImage.swift */, - 0C2A368A26437BF100F1D000 /* TaskLoadData.swift */, - 0CB402DA25B656D200F5A241 /* TaskFetchOriginalImage.swift */, - 0CB402D425B6569700F5A241 /* TaskFetchOriginalData.swift */, - 0CE6202226543B6A00AAB8C3 /* TaskFetchWithPublisher.swift */, - ); - path = Tasks; - sourceTree = ""; - }; - 0CBA07842852DA7A00CE29F4 /* Pipeline */ = { - isa = PBXGroup; - children = ( - 0C0FD5D31CA47FE1002A78FB /* ImagePipeline.swift */, - 0CF1754B22913F9800A8946E /* ImagePipeline+Configuration.swift */, - 0C53C8B0263C968200E62D03 /* ImagePipeline+Delegate.swift */, - 0C78A2A6263F4E680051E0FF /* ImagePipeline+Cache.swift */, - 0CBA07852852DA8B00CE29F4 /* ImagePipeline+Error.swift */, - ); - path = Pipeline; - sourceTree = ""; - }; - 0CC13A0F285677F800E0557D /* Nuke */ = { - isa = PBXGroup; - children = ( - 0C0FD5D91CA47FE1002A78FB /* ImageRequest.swift */, - 0C063F93266524190018F2C2 /* ImageResponse.swift */, - 0C933E632859686D00F43606 /* ImageContainer.swift */, - 0C86AB69228B3B5100A81BA1 /* ImageTask.swift */, - 0CBA07842852DA7A00CE29F4 /* Pipeline */, - 0CA4ECCB26E68F9900BAC8E5 /* Loading */, - 0CA4ECD126E68FD300BAC8E5 /* Caching */, - 0CA4ECB226E6843800BAC8E5 /* Processing */, - 0CA4EC9726E67CCB00BAC8E5 /* Decoding */, - 0CA4ECAB26E683D200BAC8E5 /* Encoding */, - 0CA4ECD526E6903F00BAC8E5 /* Prefetching */, - 0CB402D325B6568800F5A241 /* Tasks */, - 0CC36A0B25B8BBF800811018 /* Internal */, - ); - path = Nuke; - sourceTree = ""; - }; - 0CC36A0B25B8BBF800811018 /* Internal */ = { - isa = PBXGroup; - children = ( - 0CA4ECB926E6850B00BAC8E5 /* Graphics.swift */, - 0CC36A1825B8BC2500811018 /* RateLimiter.swift */, - 0CC36A2425B8BC4900811018 /* Operation.swift */, - 0CC36A2B25B8BC6300811018 /* LinkedList.swift */, - 0CC36A3225B8BC7900811018 /* ResumableData.swift */, - 0CC36A4025B8BCAC00811018 /* Log.swift */, - 0C7150081FC9724C00B880AC /* Extensions.swift */, - 0CA8D8EC2958DA3700EDAA2C /* Atomic.swift */, - 0CE6202026542F7200AAB8C3 /* DataPublisher.swift */, - 0CA5D953263CCEA500E08E17 /* ImagePublisher.swift */, - 0C472F822654AD69007FC0F0 /* ImageRequestKeys.swift */, - ); - path = Internal; - sourceTree = ""; - }; - 0CC6276525C0A16300466F04 /* NukePerformanceTests */ = { - isa = PBXGroup; - children = ( - 0CC6278825C100AA00466F04 /* ImagePipelinePerformanceTests.swift */, - 0CC6279D25C100E300466F04 /* ImageCachePerformanceTests.swift */, - 0CC6278F25C100BC00466F04 /* ImageViewPerformanceTests.swift */, - 0CC6279625C100CE00466F04 /* ImageRequestPerformanceTests.swift */, - 0CC627A425C100FA00466F04 /* ImageProcessingPerformanceTests.swift */, - 0C8D74201D9D6EEB0036349E /* DataCachePeformanceTests.swift */, - ); - path = NukePerformanceTests; - sourceTree = ""; - }; - 0CDB92761DAF9BA500002905 /* Documentation */ = { - isa = PBXGroup; - children = ( - 0C1B987F294E28D800C09310 /* Nuke.docc */, - 0C222DE2294E2DEA00012288 /* NukeUI.docc */, - 0C222DE4294E2E0200012288 /* NukeExtensions.docc */, - 0CDB927C1DAF9BA500002905 /* Migrations */, - ); - path = Documentation; - sourceTree = ""; - }; - 0CDB927C1DAF9BA500002905 /* Migrations */ = { - isa = PBXGroup; - children = ( - 0C3EE97829A862CA0014F5D5 /* Nuke 12 Migration Guide.md */, - 0C97DD9E284C00EA00F55FDA /* Nuke 11 Migration Guide.md */, - 0C54E64126616E9D00ED1049 /* Nuke 10 Migration Guide.md */, - 0C6784DC24341207005B8CF5 /* Nuke 9 Migration Guide.md */, - 0C6784DD24341212005B8CF5 /* Nuke 8 Migration Guide.md */, - 0CE5F78820A22ABF00BC3283 /* Nuke 7 Migration Guide.md */, - 0CE5F78720A22ABF00BC3283 /* Nuke 6 Migration Guide.md */, - 0C4B6A341E36630E00E86B21 /* Nuke 5 Migration Guide.md */, - 0CDB927D1DAF9BA500002905 /* Nuke 4 Migration Guide.md */, - ); - path = Migrations; - sourceTree = ""; - }; 0CDB927F1DAF9BA900002905 /* Metadata */ = { isa = PBXGroup; children = ( @@ -1082,6 +364,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 0C0C81A32D99A9B1006A2138 /* NukeUI */, + ); name = NukeUI; productName = Nuke; productReference = 0C38DAE028568FAE0027F9FF /* NukeUI.framework */; @@ -1100,6 +385,11 @@ dependencies = ( 0C38DB4A285690D20027F9FF /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 0C0C823F2D99AA9B006A2138 /* Helpers */, + 0C0C82A12D99ABD0006A2138 /* Resources */, + 0C0C83592D99AC13006A2138 /* NukeUITests */, + ); name = "NukeUI Unit Tests"; productName = "Nuke Tests"; productReference = 0C38DB3428568FE20027F9FF /* NukeUI Unit Tests.xctest */; @@ -1117,6 +407,11 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 0C0C823F2D99AA9B006A2138 /* Helpers */, + 0C0C82A12D99ABD0006A2138 /* Resources */, + 0C0C836B2D99AC2C006A2138 /* NukeThreadSafetyTests */, + ); name = "Nuke Thread Safety Tests"; productName = "Nuke Thread Safety Tests"; productReference = 0C4F8FDF22E4B6ED0070ECFD /* Nuke Thread Safety Tests.xctest */; @@ -1136,6 +431,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 0C0C819A2D99A9AC006A2138 /* NukeExtensions */, + ); name = NukeExtensions; productName = NukeExtensions; productReference = 0C55FCFD28567875000FD2C9 /* NukeExtensions.framework */; @@ -1154,6 +452,11 @@ dependencies = ( 0C55FD0728567875000FD2C9 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 0C0C823F2D99AA9B006A2138 /* Helpers */, + 0C0C82A12D99ABD0006A2138 /* Resources */, + 0C0C83622D99AC20006A2138 /* NukeExtensionsTests */, + ); name = "NukeExtensions Unit Tests"; productName = NukeExtensionsTests; productReference = 0C55FD0428567875000FD2C9 /* NukeExtensions Unit Tests.xctest */; @@ -1171,6 +474,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 0C0C81AC2D99A9B6006A2138 /* NukeVideo */, + ); name = NukeVideo; productName = Nuke; productReference = 0C7584AA29A151FF00F985F8 /* NukeVideo.framework */; @@ -1188,6 +494,11 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 0C0C823F2D99AA9B006A2138 /* Helpers */, + 0C0C82A12D99ABD0006A2138 /* Resources */, + 0C0C83322D99AC05006A2138 /* NukeTests */, + ); name = "Nuke Unit Tests"; productName = "Nuke Tests"; productReference = 0C7C06771BCA882A00089D7F /* Nuke Unit Tests.xctest */; @@ -1229,6 +540,11 @@ 0CB644AE28567FC500916267 /* PBXTargetDependency */, 0C8D7BF41D9DC03A00D12EB7 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 0C0C823F2D99AA9B006A2138 /* Helpers */, + 0C0C82A12D99ABD0006A2138 /* Resources */, + 0C0C83732D99AC37006A2138 /* NukePerformanceTests */, + ); name = "Nuke Performance Tests"; productName = "Nuke iOS Performance Tests"; productReference = 0C8D7BE81D9DC02B00D12EB7 /* Nuke Performance Tests.xctest */; @@ -1245,6 +561,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 0C0C815E2D99A9A6006A2138 /* Nuke */, + ); name = Nuke; productName = Nuke; productReference = 0C9174901BAE99EE004A7905 /* Nuke.framework */; @@ -1325,25 +644,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C38DB1E28568FE20027F9FF /* s-sepia-less-intense.png in Resources */, - 0C38DB1F28568FE20027F9FF /* swift.png in Resources */, - 0C38DB2028568FE20027F9FF /* image-p3.jpg in Resources */, - 0C38DB2128568FE20027F9FF /* s-circle-border.png in Resources */, - 0C38DB2228568FE20027F9FF /* img_751.heic in Resources */, - 0C38DB2328568FE20027F9FF /* s-circle.png in Resources */, - 0C38DB2428568FE20027F9FF /* fixture.jpeg in Resources */, - 0C38DB2528568FE20027F9FF /* fixture.png in Resources */, - 0C38DB2628568FE20027F9FF /* grayscale.jpeg in Resources */, - 0C38DB2728568FE20027F9FF /* cat.gif in Resources */, - 0C38DB2828568FE20027F9FF /* s-sepia.png in Resources */, - 0C38DB2928568FE20027F9FF /* fixture-tiny.jpeg in Resources */, - 0C38DB2A28568FE20027F9FF /* baseline.jpeg in Resources */, - 0C38DB2B28568FE20027F9FF /* baseline.webp in Resources */, - 0C38DB2C28568FE20027F9FF /* right-orientation.jpeg in Resources */, - 0C38DB2D28568FE20027F9FF /* s-rounded-corners.png in Resources */, - 0C38DB2E28568FE20027F9FF /* video.mp4 in Resources */, - 0C38DB2F28568FE20027F9FF /* progressive.jpeg in Resources */, - 0C38DB3028568FE20027F9FF /* s-rounded-corners-border.png in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1351,11 +651,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C4F8FFC22E4B8F60070ECFD /* baseline.jpeg in Resources */, - 0C4F8FFF22E4B8F60070ECFD /* fixture.png in Resources */, - 0C4F900022E4B8F60070ECFD /* cat.gif in Resources */, - 0C4F8FFE22E4B8F60070ECFD /* fixture.jpeg in Resources */, - 0C4F8FFD22E4B8F60070ECFD /* progressive.jpeg in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1370,19 +665,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0CB644C12856807F00916267 /* baseline.webp in Resources */, - 0CB644C32856807F00916267 /* fixture.png in Resources */, - 0CB644CA2856807F00916267 /* swift.png in Resources */, - 0CB644C02856807F00916267 /* progressive.jpeg in Resources */, - 0CB644C82856807F00916267 /* img_751.heic in Resources */, - 0CB644C42856807F00916267 /* grayscale.jpeg in Resources */, - 0CB644C62856807F00916267 /* video.mp4 in Resources */, - 0CB644C22856807F00916267 /* image-p3.jpg in Resources */, - 0CB644C92856807F00916267 /* fixture.jpeg in Resources */, - 0CB644C52856807F00916267 /* fixture-tiny.jpeg in Resources */, - 0CB644BE2856807F00916267 /* right-orientation.jpeg in Resources */, - 0CB644C72856807F00916267 /* baseline.jpeg in Resources */, - 0CB644BF2856807F00916267 /* cat.gif in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1390,25 +672,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C7CE29D243941A20018C8C3 /* s-sepia-less-intense.png in Resources */, - 0C64F73F243838BF001983C6 /* swift.png in Resources */, - 0C6B5BDB257010B400D763F2 /* image-p3.jpg in Resources */, - 0C7CE28724391AA50018C8C3 /* s-circle-border.png in Resources */, - 0C64F73D2438371A001983C6 /* img_751.heic in Resources */, - 0C7CE299243940290018C8C3 /* s-circle.png in Resources */, - 0C09B1661FE9A65700E8FE3B /* fixture.jpeg in Resources */, - 0C70D97C2089042400A49DAC /* fixture.png in Resources */, - 0C4B34122572E233000FDDBA /* grayscale.jpeg in Resources */, - 0C8DC723209B842600084AA6 /* cat.gif in Resources */, - 0C7CE29B2439417C0018C8C3 /* s-sepia.png in Resources */, - 0C91B0F82438E84E007F9100 /* fixture-tiny.jpeg in Resources */, - 0C70D9742089016800A49DAC /* baseline.jpeg in Resources */, - 0C95FD542571B278008D4FC2 /* baseline.webp in Resources */, - 0CF5456B25B39A0E00B45F1E /* right-orientation.jpeg in Resources */, - 0C7CE28B243933550018C8C3 /* s-rounded-corners.png in Resources */, - 0CA4ECA426E67ED500BAC8E5 /* video.mp4 in Resources */, - 0C70D9712089016800A49DAC /* progressive.jpeg in Resources */, - 0C7CE28D2439342C0018C8C3 /* s-rounded-corners-border.png in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1426,7 +689,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C09B1691FE9A65700E8FE3B /* fixture.jpeg in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1516,12 +778,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0CF235E429A16006001BCA2F /* LazyImage.swift in Sources */, - 0CF235E529A16006001BCA2F /* FetchImage.swift in Sources */, - 0CF235E129A16006001BCA2F /* LazyImageView.swift in Sources */, - 0CF235E329A16006001BCA2F /* Internal.swift in Sources */, - 0C222DE3294E2DEA00012288 /* NukeUI.docc in Sources */, - 0CF235E629A16006001BCA2F /* LazyImageState.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1529,20 +785,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C38DB472856909B0027F9FF /* NukeExtensions.swift in Sources */, - 0C38DB41285690960027F9FF /* MockImageCache.swift in Sources */, - 0C38DB3D285690960027F9FF /* MockImageProcessor.swift in Sources */, - 0C38DB3A2856908E0027F9FF /* FetchImageTests.swift in Sources */, - 0C38DB452856909B0027F9FF /* CombineExtensions.swift in Sources */, - 0C38DB42285690960027F9FF /* MockProgressiveDataLoader.swift in Sources */, - 0C38DB40285690960027F9FF /* MockImageEncoder.swift in Sources */, - 0C38DB3E285690960027F9FF /* MockDataLoader.swift in Sources */, - 0C38DB3B285690960027F9FF /* ImagePipelineObserver.swift in Sources */, - 0C38DB442856909B0027F9FF /* XCTestCaseExtensions.swift in Sources */, - 0C38DB3F285690960027F9FF /* MockImageDecoder.swift in Sources */, - 0C38DB462856909B0027F9FF /* Helpers.swift in Sources */, - 0C38DB3C285690960027F9FF /* MockDataCache.swift in Sources */, - 0C38DB432856909B0027F9FF /* XCTestCase+Nuke.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1550,19 +792,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C4F8FF122E4B8D60070ECFD /* MockImageCache.swift in Sources */, - 0C4F8FE822E4B7390070ECFD /* ThreadSafetyTests.swift in Sources */, - 0C4F8FFB22E4B8DA0070ECFD /* NukeExtensions.swift in Sources */, - 0C1453A12657EFA7005E24B3 /* ImagePipelineObserver.swift in Sources */, - 0C4F8FF822E4B8DA0070ECFD /* XCTestCaseExtensions.swift in Sources */, - 0C4F8FFA22E4B8DA0070ECFD /* Helpers.swift in Sources */, - 0C4F8FF322E4B8D60070ECFD /* MockDataLoader.swift in Sources */, - 0C4F8FF622E4B8D60070ECFD /* MockDataCache.swift in Sources */, - 0C4F8FF522E4B8D60070ECFD /* MockImageDecoder.swift in Sources */, - 0C7082622640521900C62638 /* MockImageEncoder.swift in Sources */, - 0C4F8FF722E4B8D60070ECFD /* MockProgressiveDataLoader.swift in Sources */, - 0C4F8FF222E4B8D60070ECFD /* MockImageProcessor.swift in Sources */, - 0C4F8FF922E4B8DA0070ECFD /* XCTestCase+Nuke.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1570,9 +799,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C55FD1F28567926000FD2C9 /* ImageViewExtensions.swift in Sources */, - 0C222DE5294E2E0300012288 /* NukeExtensions.docc in Sources */, - 0C55FD1E28567926000FD2C9 /* ImageLoadingOptions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1580,24 +806,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0CB6449828567DCA00916267 /* CombineExtensions.swift in Sources */, - 0CB6449728567DCA00916267 /* NukeExtensions.swift in Sources */, - 0C55FD2728567C12000FD2C9 /* ImageViewExtensionsTests.swift in Sources */, - 0CB644AB28567EEA00916267 /* ImageViewExtensionsProgressiveDecodingTests.swift in Sources */, - 0CB6448D28567DC300916267 /* MockImageDecoder.swift in Sources */, - 0CB6448E28567DC300916267 /* MockImageCache.swift in Sources */, - 0C55FD2828567C18000FD2C9 /* ImageViewIntegrationTests.swift in Sources */, - 0CB6448B28567DC300916267 /* MockProgressiveDataLoader.swift in Sources */, - 0CB6449C28567E5400916267 /* ImageViewLoadingOptionsTests.swift in Sources */, - 0CB6449428567DCA00916267 /* XCTestCase+Nuke.swift in Sources */, - 0CB6448A28567DC300916267 /* MockDataLoader.swift in Sources */, - 0CB6449528567DCA00916267 /* Helpers.swift in Sources */, - 0CB6448928567DC300916267 /* MockImageProcessor.swift in Sources */, - 0CB6449A28567DE000916267 /* NukeExtensionsTestsHelpers.swift in Sources */, - 0CB6449028567DC300916267 /* ImagePipelineObserver.swift in Sources */, - 0CB6448C28567DC300916267 /* MockDataCache.swift in Sources */, - 0CB6449628567DCA00916267 /* XCTestCaseExtensions.swift in Sources */, - 0CB6448F28567DC300916267 /* MockImageEncoder.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1605,10 +813,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C7584AE29A1533700F985F8 /* ImageDecoders+Video.swift in Sources */, - 0C7584B029A153B200F985F8 /* AVDataAsset.swift in Sources */, - 0C7584A429A151FF00F985F8 /* NukeUI.docc in Sources */, - 0C7584B229A1553200F985F8 /* VideoPlayerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1616,60 +820,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4480674C2A448C9F00DE7CF8 /* DataPublisherTests.swift in Sources */, - 0CD37C9A25BA36D5006C2C36 /* 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 */, - 0C7C06991BCA888800089D7F /* XCTestCaseExtensions.swift in Sources */, - 0CE5F6832156386B0046609F /* ResumableDataTests.swift in Sources */, - 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 */, - 0C4B341C2572E288000FDDBA /* DecompressionTests.swift in Sources */, - 0C78A2A9263F560A0051E0FF /* ImagePipelineCacheTests.swift in Sources */, - 0C49232920BACA81001DFCC8 /* XCTestCase+Nuke.swift in Sources */, - 0C75279E1D473AEF00EC6222 /* MockImageCache.swift in Sources */, - 0CCBB534217D0B980026F552 /* MockProgressiveDataLoader.swift in Sources */, - 0C7CE28F24393ACC0018C8C3 /* CoreImageFilterTests.swift in Sources */, - 0C53C8AF263C7B1700E62D03 /* ImagePipelineDelegateTests.swift in Sources */, - 0C1453A02657EFA7005E24B3 /* ImagePipelineObserver.swift in Sources */, - 0C1E620B1D6F817700AD5CF5 /* ImageRequestTests.swift in Sources */, - 0C8684FF20BDD578009FF7CC /* ImagePipelineProgressiveDecodingTests.swift in Sources */, - 0C1ECA421D526461009063A9 /* ImageCacheTests.swift in Sources */, - 0C2A8CF720970B790013FD65 /* ImagePipelineResumableDataTests.swift in Sources */, - 0CE334DB2724563D0017BB8D /* ImageProcessorsProtocolExtensionsTests.swift in Sources */, - 0C7C06981BCA888800089D7F /* Helpers.swift in Sources */, - 0C4AF1EB1FE85539002F86CB /* LinkedListTest.swift in Sources */, - 0C5D5A9D2724773A0056B95B /* ImagePipelineAsyncAwaitTests.swift in Sources */, - 0CB2EFD62110F52C00F7C63F /* RateLimiterTests.swift in Sources */, - 0C7C06971BCA888800089D7F /* MockDataLoader.swift in Sources */, - 2DFD93B0233A6AB300D84DB9 /* ImagePipelineProcessorTests.swift in Sources */, - 0C91B0F42438E38B007F9100 /* CompositionTests.swift in Sources */, - 0C91B0F62438E3CB007F9100 /* GaussianBlurTests.swift in Sources */, - 0C6D0A8820E574400037B68F /* MockDataCache.swift in Sources */, - 0C472F812654AA46007FC0F0 /* DeprecationTests.swift in Sources */, - 0C9B6E7620B9F3E2001924B8 /* ImagePipelineCoalescingTests.swift in Sources */, - 0C91B0F22438E374007F9100 /* AnonymousTests.swift in Sources */, - 0CE3992D1D4697CE00A87D47 /* ImagePipelineTests.swift in Sources */, - 0C64F73B24383043001983C6 /* ImageEncoderTests.swift in Sources */, - 0CF58FF726DAAC3800D2650D /* ImageDownsampleTests.swift in Sources */, - 0C967EB328688B3F0050E083 /* DocumentationTests.swift in Sources */, - 0C91B0EC2438E287007F9100 /* ResizeTests.swift in Sources */, - 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; }; @@ -1677,7 +827,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C8D7BD51D9DBF1600D12EB7 /* ViewController.swift in Sources */, 0C8D7BD31D9DBF1600D12EB7 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1686,18 +835,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C6CF0CD1DAF789C007B8C0E /* XCTestCaseExtensions.swift in Sources */, - 0C3261F71FEBC232009276AC /* MockImageDecoder.swift in Sources */, - 0CC6278925C100AA00466F04 /* ImagePipelinePerformanceTests.swift in Sources */, - 0C09B16F1FE9A6D800E8FE3B /* Helpers.swift in Sources */, - 0C3261F41FEBC232009276AC /* MockImageProcessor.swift in Sources */, - 0CAAB0131E45D6DA00924450 /* NukeExtensions.swift in Sources */, - 0CC6279725C100CE00466F04 /* ImageRequestPerformanceTests.swift in Sources */, - 0CC6279E25C100E300466F04 /* ImageCachePerformanceTests.swift in Sources */, - 0C3261F51FEBC232009276AC /* MockDataLoader.swift in Sources */, - 0CC627A525C100FA00466F04 /* ImageProcessingPerformanceTests.swift in Sources */, - 0C8D7BF51D9DC07E00D12EB7 /* DataCachePeformanceTests.swift in Sources */, - 0CC6279025C100BC00466F04 /* ImageViewPerformanceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1705,62 +842,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0C0FD6001CA47FE1002A78FB /* ImageProcessing.swift in Sources */, - 0CC36A4125B8BCAC00811018 /* Log.swift in Sources */, - 0CA4ECB126E6840900BAC8E5 /* ImageEncoders+ImageIO.swift in Sources */, - 0C063F94266524190018F2C2 /* ImageResponse.swift in Sources */, - 0C0FD5FC1CA47FE1002A78FB /* ImageCache.swift in Sources */, - 0C0FD5EC1CA47FE1002A78FB /* ImagePipeline.swift in Sources */, - 0C0FD5E01CA47FE1002A78FB /* DataLoader.swift in Sources */, - 0C505B6C2286F3AD006D5399 /* AsyncTask.swift in Sources */, - 0CBA07862852DA8B00CE29F4 /* ImagePipeline+Error.swift in Sources */, - 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 */, - 0C933E642859686D00F43606 /* ImageContainer.swift in Sources */, - 0C78A2A7263F4E680051E0FF /* ImagePipeline+Cache.swift in Sources */, - 0CA4ECD026E68FC000BAC8E5 /* DataCaching.swift in Sources */, - 0CA4ECCA26E6868300BAC8E5 /* ImageProcessingOptions.swift in Sources */, - 0C53C8B1263C968200E62D03 /* ImagePipeline+Delegate.swift in Sources */, - 0CA4ECBC26E6856300BAC8E5 /* ImageDecompression.swift in Sources */, - 0CA4ECD326E68FDC00BAC8E5 /* ImageCaching.swift in Sources */, - 0CA4ECC026E685C900BAC8E5 /* ImageProcessors+Anonymous.swift in Sources */, - 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 */, - 0CA4ECC426E685F500BAC8E5 /* ImageProcessors+GaussianBlur.swift in Sources */, - 0CA4EC9B26E67D3000BAC8E5 /* ImageDecoders+Empty.swift in Sources */, - 0CB26802208F2565004C83F4 /* DataCache.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 */, - 0C0FD6041CA47FE1002A78FB /* ImageRequest.swift in Sources */, - 0CA4EC9926E67CEC00BAC8E5 /* ImageDecoders+Default.swift in Sources */, - 0CA4ECC226E685E100BAC8E5 /* ImageProcessors+Composition.swift in Sources */, - 0CA4ECA126E67D8400BAC8E5 /* AssetType.swift in Sources */, - 0CF1754C22913F9800A8946E /* ImagePipeline+Configuration.swift in Sources */, - 0C86AB6A228B3B5100A81BA1 /* ImageTask.swift in Sources */, - 0C7150091FC9724C00B880AC /* Extensions.swift in Sources */, - 0CA4ECBE26E685A900BAC8E5 /* ImageProcessors+Circle.swift in Sources */, - 0CE2D9BA2084FDDD00934B28 /* ImageDecoding.swift in Sources */, - 0CC36A1925B8BC2500811018 /* RateLimiter.swift in Sources */, - 0CB402D525B6569700F5A241 /* TaskFetchOriginalData.swift in Sources */, - 0CA4ECAD26E683E300BAC8E5 /* ImageEncoders.swift in Sources */, - 0CA4ECC626E6862A00BAC8E5 /* ImageProcessors+CoreImage.swift in Sources */, - 0C2CD6EB25B67FB30017018F /* AsyncPipelineTask.swift in Sources */, - 0CF4DE7D1D412A9E00170289 /* ImagePrefetcher.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1841,7 +922,6 @@ OTHER_SWIFT_FLAGS = "-D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.nukeui; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -1861,14 +941,12 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.nukeui; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Release; }; 0C38DB3228568FE20027F9FF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_OBJC_WEAK = YES; DEAD_CODE_STRIPPING = YES; LD_RUNPATH_SEARCH_PATHS = ( @@ -1879,13 +957,13 @@ OTHER_SWIFT_FLAGS = "-D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.nukeui-unit-tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Debug; }; 0C38DB3328568FE20027F9FF /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_OBJC_WEAK = YES; DEAD_CODE_STRIPPING = YES; LD_RUNPATH_SEARCH_PATHS = ( @@ -1895,6 +973,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.nukeui-unit-tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Release; }; @@ -1911,6 +990,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 +1006,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.Nuke-Thread-Safety-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Release; }; @@ -1946,7 +1027,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 +1046,6 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.nuke-extensions"; PRODUCT_NAME = NukeExtensions; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Release; }; @@ -1988,6 +1067,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 +1088,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.NukeExtensionsTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Release; }; @@ -2028,7 +1109,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 +1128,6 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.nukevideo; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Release; }; @@ -2065,6 +1144,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 +1161,8 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.Nuke-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 5.0; }; name = Release; }; @@ -2092,7 +1175,6 @@ CODE_SIGN_STYLE = Automatic; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = NR8DLKJ7E6; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2114,7 +1196,6 @@ CODE_SIGN_STYLE = Automatic; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = NR8DLKJ7E6; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2144,6 +1225,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 +1247,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; @@ -2218,19 +1301,19 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MACOSX_DEPLOYMENT_TARGET = 10.15; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + MACOSX_DEPLOYMENT_TARGET = 12.4; MARKETING_VERSION = 12.8.0; ONLY_ACTIVE_ARCH = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; SUPPORTS_MACCATALYST = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TVOS_DEPLOYMENT_TARGET = 13.0; + SWIFT_VERSION = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.6; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + WATCHOS_DEPLOYMENT_TARGET = 8.7; }; name = Debug; }; @@ -2277,18 +1360,18 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MACOSX_DEPLOYMENT_TARGET = 10.15; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + MACOSX_DEPLOYMENT_TARGET = 12.4; MARKETING_VERSION = 12.8.0; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; SUPPORTS_MACCATALYST = YES; - SWIFT_VERSION = 5.0; - TVOS_DEPLOYMENT_TARGET = 13.0; + SWIFT_VERSION = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.6; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + WATCHOS_DEPLOYMENT_TARGET = 8.7; }; name = Release; }; @@ -2309,7 +1392,6 @@ OTHER_SWIFT_FLAGS = "-D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.nuke; PRODUCT_NAME = Nuke; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -2329,7 +1411,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..41af88b4c 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(.v15), + .tvOS(.v15), + .macOS(.v12), + .watchOS(.v8), .visionOS(.v1), ], products: [ diff --git a/Sources/Nuke/Caching/Cache.swift b/Sources/Nuke/Caching/Cache.swift index f38f9e86e..56c343d5e 100644 --- a/Sources/Nuke/Caching/Cache.swift +++ b/Sources/Nuke/Caching/Cache.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -9,7 +9,7 @@ import UIKit.UIApplication #endif // Internal memory-cache implementation. -final class Cache: @unchecked Sendable { +final class Cache: @unchecked Sendable where Key: Sendable, Value: Sendable { // Can't use `NSCache` because it is not LRU struct Configuration { @@ -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() } @@ -198,7 +198,7 @@ final class Cache: @unchecked Sendable { return closure() } - private struct Entry { + private struct Entry: Sendable { let value: Value let key: Key let cost: Int diff --git a/Sources/Nuke/Caching/DataCache.swift b/Sources/Nuke/Caching/DataCache.swift index 8a63cf1c3..a1d7d3a43 100644 --- a/Sources/Nuke/Caching/DataCache.swift +++ b/Sources/Nuke/Caching/DataCache.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -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/Caching/DataCaching.swift b/Sources/Nuke/Caching/DataCaching.swift index ad1c884c9..c1a3e24a9 100644 --- a/Sources/Nuke/Caching/DataCaching.swift +++ b/Sources/Nuke/Caching/DataCaching.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/Nuke/Caching/ImageCache.swift b/Sources/Nuke/Caching/ImageCache.swift index 153014077..c9b2006ce 100644 --- a/Sources/Nuke/Caching/ImageCache.swift +++ b/Sources/Nuke/Caching/ImageCache.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation #if !os(macOS) diff --git a/Sources/Nuke/Caching/ImageCaching.swift b/Sources/Nuke/Caching/ImageCaching.swift index 37f408f29..74ac5ca4b 100644 --- a/Sources/Nuke/Caching/ImageCaching.swift +++ b/Sources/Nuke/Caching/ImageCaching.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/Nuke/Decoding/AssetType.swift b/Sources/Nuke/Decoding/AssetType.swift index 2df440618..2a28b443a 100644 --- a/Sources/Nuke/Decoding/AssetType.swift +++ b/Sources/Nuke/Decoding/AssetType.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -54,8 +54,10 @@ extension AssetType { } return zip(numbers.indices, numbers).allSatisfy { index, number in guard let number else { return true } - guard (index + offset) < data.count else { return false } - return data[index + offset] == number + guard let index = data.index(data.startIndex, offsetBy: index + offset, limitedBy: data.endIndex) else { + return false + } + return data[index] == number } } @@ -71,6 +73,8 @@ extension AssetType { // WebP magic numbers https://en.wikipedia.org/wiki/List_of_file_signatures if _match([0x52, 0x49, 0x46, 0x46, nil, nil, nil, nil, 0x57, 0x45, 0x42, 0x50]) { return .webp } + if _match([0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63], offset: 4) { return .heic } + // see https://stackoverflow.com/questions/21879981/avfoundation-avplayer-supported-formats-no-vob-or-mpg-containers // https://en.wikipedia.org/wiki/List_of_file_signatures if _match([0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D], offset: 4) { return .mp4 } diff --git a/Sources/Nuke/Decoding/ImageDecoderRegistry.swift b/Sources/Nuke/Decoding/ImageDecoderRegistry.swift index 3c5b54650..5b0e9fe97 100644 --- a/Sources/Nuke/Decoding/ImageDecoderRegistry.swift +++ b/Sources/Nuke/Decoding/ImageDecoderRegistry.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -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/Decoding/ImageDecoders+Default.swift b/Sources/Nuke/Decoding/ImageDecoders+Default.swift index 5d6d8ace3..7cbedbec2 100644 --- a/Sources/Nuke/Decoding/ImageDecoders+Default.swift +++ b/Sources/Nuke/Decoding/ImageDecoders+Default.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). #if !os(macOS) import UIKit diff --git a/Sources/Nuke/Decoding/ImageDecoders+Empty.swift b/Sources/Nuke/Decoding/ImageDecoders+Empty.swift index 4097299ab..a85a001d8 100644 --- a/Sources/Nuke/Decoding/ImageDecoders+Empty.swift +++ b/Sources/Nuke/Decoding/ImageDecoders+Empty.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/Nuke/Decoding/ImageDecoding.swift b/Sources/Nuke/Decoding/ImageDecoding.swift index a33fa9b22..05af85478 100644 --- a/Sources/Nuke/Decoding/ImageDecoding.swift +++ b/Sources/Nuke/Decoding/ImageDecoding.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -43,22 +43,26 @@ public enum ImageDecodingError: Error, CustomStringConvertible, Sendable { } extension ImageDecoding { - func decode(_ context: ImageDecodingContext) throws -> ImageResponse { - let container: ImageContainer = try autoreleasepool { + func decode(_ context: ImageDecodingContext) -> Result { + let container: ImageContainer + do { if context.isCompleted { - return try decode(context.data) + container = try decode(context.data) } else { if let preview = decodePartiallyDownloadedData(context.data) { - return preview + container = preview + } else { + throw ImageDecodingError.unknown } - throw ImageDecodingError.unknown } + } catch { + return .failure(.decodingFailed(decoder: self, context: context, error: error)) } #if !os(macOS) if container.userInfo[.isThumbnailKey] == nil { ImageDecompression.setDecompressionNeeded(true, for: container.image) } #endif - return ImageResponse(container: container, request: context.request, urlResponse: context.urlResponse, cacheType: context.cacheType) + return .success(ImageResponse(container: container, request: context.request, urlResponse: context.urlResponse, cacheType: context.cacheType)) } } diff --git a/Sources/Nuke/Encoding/ImageEncoders+Default.swift b/Sources/Nuke/Encoding/ImageEncoders+Default.swift index bfeba48cb..c18192760 100644 --- a/Sources/Nuke/Encoding/ImageEncoders+Default.swift +++ b/Sources/Nuke/Encoding/ImageEncoders+Default.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift b/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift index 415a0f1bb..41d7b164a 100644 --- a/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift +++ b/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import CoreGraphics @@ -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/ImageEncoders.swift b/Sources/Nuke/Encoding/ImageEncoders.swift index 51b30c1ef..48044c762 100644 --- a/Sources/Nuke/Encoding/ImageEncoders.swift +++ b/Sources/Nuke/Encoding/ImageEncoders.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/Nuke/Encoding/ImageEncoding.swift b/Sources/Nuke/Encoding/ImageEncoding.swift index 1385ea2e6..055109a7e 100644 --- a/Sources/Nuke/Encoding/ImageEncoding.swift +++ b/Sources/Nuke/Encoding/ImageEncoding.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). #if canImport(UIKit) import UIKit @@ -32,8 +32,10 @@ extension ImageEncoding { } } +// note: @unchecked was added to suppress build errors with NSImage on macOS + /// Image encoding context used when selecting which encoder to use. -public struct ImageEncodingContext: @unchecked Sendable { +public struct ImageEncodingContext: Sendable { public let request: ImageRequest public let image: PlatformImage public let urlResponse: URLResponse? diff --git a/Sources/Nuke/ImageContainer.swift b/Sources/Nuke/ImageContainer.swift index 346a50622..b0b538c4b 100644 --- a/Sources/Nuke/ImageContainer.swift +++ b/Sources/Nuke/ImageContainer.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). #if !os(watchOS) import AVKit @@ -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..326743eae 100644 --- a/Sources/Nuke/ImageRequest.swift +++ b/Sources/Nuke/ImageRequest.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Combine @@ -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, @@ -257,7 +213,7 @@ public struct ImageRequest: CustomStringConvertible, Sendable, ExpressibleByStri // MARK: Nested Types /// The priority affecting the order in which the requests are performed. - public enum Priority: Int, Comparable, Sendable { + public enum Priority: Int, Comparable, Sendable, CaseIterable { case veryLow = 0, low, normal, high, veryHigh public static func < (lhs: Priority, rhs: Priority) -> Bool { @@ -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,21 +475,21 @@ 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 .url(let url): "\(url?.absoluteString ?? "nil")" + case .urlRequest(let urlRequest): "\(urlRequest)" + case .closure(_, let id): id } } var imageId: String? { 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 .url(let url): url?.absoluteString + case .urlRequest(let urlRequest): urlRequest.url?.absoluteString + case .closure(_, let id): id } } } diff --git a/Sources/Nuke/ImageResponse.swift b/Sources/Nuke/ImageResponse.swift index 0999a8b68..93438bed6 100644 --- a/Sources/Nuke/ImageResponse.swift +++ b/Sources/Nuke/ImageResponse.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -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..5cdb7382b 100644 --- a/Sources/Nuke/ImageTask.swift +++ b/Sources/Nuke/ImageTask.swift @@ -1,9 +1,8 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation -@preconcurrency import Combine #if canImport(UIKit) import UIKit @@ -15,87 +14,89 @@ import AppKit /// A task performed by the ``ImagePipeline``. /// -/// 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 { +/// The pipeline maintains a strong reference to the task until it completes. +/// You do not need to maintain your own reference unless it is useful for your app. +/// +/// ## Thread Safety +/// +/// All public properties can be safely accessed from any thread. The task's +/// state, priority, and cancellation status provide immediate, thread-safe snapshots. +public final class ImageTask: Hashable, JobSubscriber, Sendable { /// An identifier that uniquely identifies the task within a given pipeline. - /// Unique only within that pipeline. public let taskId: Int64 - /// The original request that the task was created with. + /// The original request used to create this task. public let request: ImageRequest - /// The priority of the task. The priority can be updated dynamically even - /// for a task that is already running. + // TODO: fix this being on ImagePipelineActor right now + /// The task priority. Can be updated dynamically, even while the task is running. public var priority: ImageRequest.Priority { - get { withLock { $0.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. + /// A snapshot of the current download progress. + /// + /// - note: The `total` value is zero when the resource size is unknown or + /// the server doesn't provide a `Content-Length` header. The `fraction` + /// property handles this by returning 0. + /// + /// - seealso: ``ImageTask/progress`` for receiving progress updates. public var currentProgress: Progress { - withLock { $0.progress } - } - - /// The download progress. - public struct Progress: Hashable, Sendable { - /// The number of bytes that the task has received. - public let completed: Int64 - /// A best-guess upper bound on the number of bytes of the resource. - public let total: Int64 - - /// Returns the fraction of the completion. - public var fraction: Float { - guard total > 0 else { return 0 } - return min(1, Float(completed) / Float(total)) - } - - /// Initializes progress with the given status. - public init(completed: Int64, total: Int64) { - (self.completed, self.total) = (completed, total) - } + nonisolatedState.withLock(\.progress) } /// The current state of the task. + /// + /// - seealso: + /// - ``isCancelled`` to check if cancellation was initiated + /// - ``events`` to observe state transitions in real-time public var state: State { - withLock { $0.state } + nonisolatedState.withLock(\.state) } - /// The state of the image task. - public enum State { - /// The task is currently running. - case running - /// The task has received a cancel message. - case cancelled - /// The task has completed (without being canceled). - case completed + /// Returns `true` if the task was cancelled. + /// + /// - seealso: + /// - ``cancel()`` to cancel the task + /// - ``state`` for the final outcome + /// - ``ImageTask/Error/cancelled`` error case + public var isCancelled: Bool { + nonisolatedState.withLock(\.isCancelled) + } + + var isTerminated: Bool { + guard case .running = state else { return true } + return false } // MARK: - Async/Await /// Returns the response image. public var image: PlatformImage { - get async throws { + get async throws(ImageTask.Error) { try await response.image } } /// Returns the image response. public var response: ImageResponse { - get async throws { - try await withTaskCancellationHandler { - try await _task.value - } onCancel: { - cancel() + get async throws(ImageTask.Error) { + do { + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + cancel() + } + } catch { + throw error as! ImageTask.Error } } } - /// The stream of progress updates. - public var progress: AsyncStream { - makeStream { + /// A stream of progress updates during the download. + public var progress: AsyncCompactMapSequence, Progress> { + events.compactMap { if case .progress(let value) = $0 { return value } return nil } @@ -104,160 +105,216 @@ public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Send /// The stream of image previews generated for images that support /// progressive decoding. /// + /// Progressive decoding allows you to display low-resolution previews + /// while the full image is still downloading, improving perceived performance. + /// /// - seealso: ``ImagePipeline/Configuration-swift.struct/isProgressiveDecodingEnabled`` - public var previews: AsyncStream { - makeStream { + public var previews: AsyncCompactMapSequence, ImageResponse> { + events.compactMap { if case .preview(let value) = $0 { return value } return nil } } - // MARK: - Events + /// A stream of events during task execution. + /// + /// Events are yielded in the following order: + /// 1. Zero or more `.progress` events as data downloads + /// 2. Zero or more `.preview` events for progressive images (if enabled) + /// 3. Exactly one `.finished` event with the final result + /// + /// The stream completes after the `.finished` event is sent. + /// + /// ## Example + /// ```swift + /// for await event in task.events { + /// switch event { + /// case .progress(let progress): + /// progressBar.progress = progress.fraction + /// case .preview(let response): + /// imageView.image = response.image // Show progressive scan + /// case .finished(.success(let response)): + /// imageView.image = response.image // Show final image + /// case .finished(.failure(let error)): + /// handleError(error) + /// } + /// } + /// ``` + public var events: AsyncStream { + AsyncStream { continuation in + Task { @ImagePipelineActor [weak self] in + guard let self, !self.isTerminated else { + return continuation.finish() + } + self._state.streamContinuations.append(continuation) + } + } + } - /// The events sent by the pipeline during the task execution. - public var events: AsyncStream { makeStream { $0 } } + private let nonisolatedState: Mutex - /// An event produced during the runetime of the task. - public enum Event: Sendable { - /// The download progress was updated. - case progress(Progress) - /// The pipeline generated a progressive scan of the image. - case preview(ImageResponse) - /// The task was cancelled. - /// - /// - note: You are guaranteed to receive either `.cancelled` or - /// `.finished`, but never both. - case cancelled - /// The task finish with the given response. - case finished(Result) + /// The state that can be accessed synchronously by the users of the task. + private struct NonisolatedState { + var isCancelled = false + var state: ImageTask.State = .running + var priority: ImageRequest.Priority + var progress = ImageTask.Progress(completed: 0, total: 0) } - private var publicState: PublicState - private let isDataTask: Bool - private let onEvent: ((Event, ImageTask) -> Void)? - private let lock: os_unfair_lock_t - private let queue: DispatchQueue - private weak var pipeline: ImagePipeline? - - // State synchronized on `pipeline.queue`. - var _task: Task! - var _continuation: UnsafeContinuation? - var _state: State = .running - private var _events: PassthroughSubject? - - deinit { - lock.deinitialize(count: 1) - lock.deallocate() + @ImagePipelineActor + private var _state = IsolatedState() + + /// The part of the task that manages background jobs. + @ImagePipelineActor + private struct IsolatedState { + var pipeline: ImagePipeline? + var subscription: JobSubscription? + var continuation: UnsafeContinuation? + var streamContinuations = ContiguousArray.Continuation>() } - init(taskId: Int64, request: ImageRequest, isDataTask: Bool, pipeline: ImagePipeline, onEvent: ((Event, ImageTask) -> Void)?) { + let isDataTask: Bool + private nonisolated(unsafe) var task: Task! + private let onEvent: (@ImagePipelineActor @Sendable (Event, ImageTask) -> Void)? + + init(taskId: Int64, request: ImageRequest, isDataTask: Bool, pipeline: ImagePipeline, onEvent: (@ImagePipelineActor @Sendable (Event, ImageTask) -> Void)?) { self.taskId = taskId self.request = request - self.publicState = PublicState(priority: request.priority) + self.nonisolatedState = Mutex(NonisolatedState(priority: request.priority)) self.isDataTask = isDataTask - self.pipeline = pipeline - self.queue = pipeline.queue + self._state.pipeline = pipeline self.onEvent = onEvent + self.task = Task { @ImagePipelineActor in + try await perform() + } + } - lock = .allocate(capacity: 1) - lock.initialize(to: os_unfair_lock()) + @ImagePipelineActor + private func perform() async throws -> ImageResponse { + // In case the task gets cancelled immediately after creation. + guard !isCancelled, !isTerminated, let pipeline = _state.pipeline else { + throw ImageTask.Error.cancelled + } + return try await withUnsafeThrowingContinuation { continuation in + _state.continuation = continuation + if let subscription = pipeline.perform(self) { + _state.subscription = subscription + } else { + process(.finished(.failure(.pipelineInvalidated))) + } + } } - /// Marks task as being cancelled. + /// Cancels the task. + /// + /// The ``isCancelled`` property is set to `true` immediately. The pipeline + /// then asynchronously attempts to stop the underlying work unless an equivalent + /// task is running or the request has already completed. /// - /// The pipeline will immediately cancel any work associated with a task - /// unless there is an equivalent outstanding task running. + /// Calling this method multiple times has no effect. + /// + /// - note: Cancellation does not guarantee the task will stop. The task may + /// complete successfully or fail if the work finishes before cancellation is processed. public func cancel() { - let didChange: Bool = withLock { - guard $0.state == .running else { return false } - $0.state = .cancelled + let shouldCancel = nonisolatedState.withLock { + guard !$0.isCancelled else { return false } + $0.isCancelled = true return true } - guard didChange else { return } // Make sure it gets called once (expensive) - pipeline?.imageTaskCancelCalled(self) + guard shouldCancel else { return } + + Task { @ImagePipelineActor in + _cancel() + } } private func setPriority(_ newValue: ImageRequest.Priority) { - let didChange: Bool = withLock { + let shouldChangePriority = nonisolatedState.withLock { guard $0.priority != newValue else { return false } $0.priority = newValue - return $0.state == .running + return !$0.isCancelled + } + guard shouldChangePriority else { return } + + Task { @ImagePipelineActor in + _state.subscription?.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`. + @ImagePipelineActor func _cancel() { - guard _setState(.cancelled) else { return } - _dispatch(.cancelled) + process(.finished(.failure(.cancelled))) } - /// Gets called when the associated task sends a new event. - /// - /// synchronized on `pipeline.queue`. - func _process(_ event: AsyncTask.Event) { + /// - warning: The task needs to be fully wired (`_continuation` present) + /// before it can start sending the events. + @ImagePipelineActor + private func process(_ event: Event) { + guard !isTerminated else { return } + + let state = _state // Important to avoid cleanup from affecting it + + for continuation in state.streamContinuations { + continuation.yield(event) + } + switch event { - case let .value(response, isCompleted): - if isCompleted { - _finish(.success(response)) - } else { - _dispatch(.preview(response)) + case .finished(let result): + if case .failure(.cancelled) = result { + state.subscription?.unsubscribe() } - case let .progress(value): - withLock { $0.progress = value } - _dispatch(.progress(value)) - case let .error(error): - _finish(.failure(error)) + complete(with: result) + case .progress(let progress): + nonisolatedState.withLock { $0.progress = progress } + default: + break } - } - /// Synchronized on `pipeline.queue`. - private func _finish(_ result: Result) { - guard _setState(.completed) else { return } - _dispatch(.finished(result)) + onEvent?(event, self) + state.pipeline?.imageTask(self, didProcessEvent: event) } - /// Synchronized on `pipeline.queue`. - func _setState(_ state: State) -> Bool { - guard _state == .running else { return false } - _state = state - if onEvent == nil { - withLock { $0.state = state } + @ImagePipelineActor + private func complete(with result: Result) { + nonisolatedState.withLock { $0.state = .finished(result) } + + _state.pipeline = nil + _state.subscription = nil + + for continuation in _state.streamContinuations { + continuation.finish() } - return true + _state.streamContinuations.removeAll() + + _state.continuation?.resume(with: result) + _state.continuation = nil } - /// 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 { - return // Task isn't fully wired yet - } - _events?.send(event) + // MARK: JobSubscriber + + @ImagePipelineActor + func receive(_ event: Job.Event) { switch event { - case .cancelled: - _events?.send(completion: .finished) - _continuation?.resume(throwing: CancellationError()) - case .finished(let result): - let result = result.mapError { $0 as Error } - _events?.send(completion: .finished) - _continuation?.resume(with: result) - default: - break + case let .value(response, isCompleted): + if isCompleted { + process(.finished(.success(response))) + } else { + process(.preview(response)) + } + case let .progress(value): + process(.progress(value)) + case let .error(error): + process(.finished(.failure(error))) } + } - onEvent?(event, self) - pipeline?.imageTask(self, didProcessEvent: event, isDataTask: isDataTask) + @ImagePipelineActor + func addSubscribedTasks(to output: inout [ImageTask]) { + output.append(self) } // MARK: Hashable @@ -269,69 +326,119 @@ public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Send public static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { ObjectIdentifier(lhs) == ObjectIdentifier(rhs) } +} + +extension ImageTask { + /// The download progress. + public struct Progress: Hashable, Sendable { + /// The number of bytes that the task has received. + public let completed: Int64 + /// A best-guess upper bound on the number of bytes of the resource. + public let total: Int64 - // MARK: CustomStringConvertible + /// Returns the fraction of the completion. + public var fraction: Float { + guard total > 0 else { return 0 } + return min(1, Float(completed) / Float(total)) + } - public var description: String { - "ImageTask(id: \(taskId), priority: \(priority), progress: \(currentProgress.completed) / \(currentProgress.total), state: \(state))" + /// Initializes progress with the given status. + public init(completed: Int64, total: Int64) { + (self.completed, self.total) = (completed, total) + } } -} -@available(*, deprecated, renamed: "ImageTask", message: "Async/Await support was added directly to the existing `ImageTask` type") -public typealias AsyncImageTask = ImageTask + /// The state of the image task. + /// + /// Tasks always begin in the `.running` state and transition to `.finished` + /// exactly once when the request completes, fails, or is cancelled. + /// + /// - note: Cancellation (``ImageTask/cancel()``) doesn't immediately change + /// the state. The task transitions to `.finished` with a ``.cancelled`` error + /// when cancellation is processed by the pipeline. + public enum State: Sendable { + /// The task is currently running. + case running + /// The task has completed. + case finished(Result) + } -// MARK: - ImageTask (Private) + /// An event produced during the runtime of the task. + public enum Event: Sendable { + /// The download progress was updated. + case progress(Progress) + /// The pipeline generated a progressive scan of the image. + case preview(ImageResponse) + /// The task finished with the given result. + /// + /// - note: If the task was cancelled, the result will contain the + /// respective error: ``ImageTask/Error/cancelled``. + case finished(Result) + } -extension ImageTask { - private func makeStream(of closure: @Sendable @escaping (Event) -> T?) -> AsyncStream { - AsyncStream { continuation in - self.queue.async { - guard let events = self._makeEventsSubject() else { - return continuation.finish() - } - let cancellable = events.sink { _ in - continuation.finish() - } receiveValue: { event in - if let value = closure(event) { - continuation.yield(value) - } - switch event { - case .cancelled, .finished: - continuation.finish() - default: - break - } - } - continuation.onTermination = { _ in - cancellable.cancel() - } - } - } + /// Represents all possible image task errors. + public enum Error: Swift.Error, CustomStringConvertible, Sendable { + /// The task got cancelled. + /// + /// - warning: This error case is used only for Async/Await APIs. The + /// completion-based APIs don't report cancellation error for backward + /// compatibility. + case cancelled + /// 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. + case dataLoadingFailed(error: Swift.Error) + /// Data loader returned empty data. + case dataIsEmpty + /// No decoder registered for the given data. + /// + /// This error can only be thrown if the pipeline has custom decoders. + /// By default, the pipeline uses ``ImageDecoders/Default`` as a catch-all. + case decoderNotRegistered(context: ImageDecodingContext) + /// Decoder failed to produce a final image. + case decodingFailed(decoder: any ImageDecoding, context: ImageDecodingContext, error: Swift.Error) + /// Processor failed to produce a final image. + case processingFailed(processor: any ImageProcessing, context: ImageProcessingContext, error: Swift.Error) + /// Load image method was called with no image request. + case imageRequestMissing + /// Image pipeline is invalidated and no requests can be made. + case pipelineInvalidated } +} - // Synchronized on `pipeline.queue` - private func _makeEventsSubject() -> PassthroughSubject? { - guard _state == .running else { +extension ImageTask.Error { + /// Returns underlying data loading error. + public var dataLoadingError: Swift.Error? { + switch self { + case .dataLoadingFailed(let error): + return error + default: return nil } - 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) + public var description: String { + switch self { + case .dataMissingInCache: + return "Failed to load data from cache and download is disabled." + case let .dataLoadingFailed(error): + return "Failed to load image data. Underlying error: \(error)." + case .dataIsEmpty: + return "Data loader returned empty data." + case .decoderNotRegistered: + return "No decoders registered for the downloaded data." + case let .decodingFailed(decoder, _, error): + let underlying = error is ImageDecodingError ? "" : " Underlying error: \(error)." + return "Failed to decode image data using decoder \(decoder).\(underlying)" + case let .processingFailed(processor, _, error): + let underlying = error is ImageProcessingError ? "" : " Underlying error: \(error)." + return "Failed to process the image using processor \(processor).\(underlying)" + case .imageRequestMissing: + return "Load image method was called with no image request or no URL." + case .pipelineInvalidated: + return "Image pipeline is invalidated and no requests can be made." + case .cancelled: + return "Image task was cancelled" + } } } 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..c0b684561 100644 --- a/Sources/Nuke/Internal/Extensions.swift +++ b/Sources/Nuke/Internal/Extensions.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import CryptoKit @@ -27,37 +27,7 @@ extension String { extension URL { var isLocalResource: Bool { - scheme == "file" || scheme == "data" - } -} - -extension OperationQueue { - convenience init(maxConcurrentCount: Int) { - self.init() - self.maxConcurrentOperationCount = maxConcurrentCount - } -} - -extension ImageRequest.Priority { - var taskPriority: TaskPriority { - switch self { - case .veryLow: return .veryLow - case .low: return .low - case .normal: return .normal - case .high: return .high - case .veryHigh: return .veryHigh - } - } -} - -final class AnonymousCancellable: Cancellable { - let onCancel: @Sendable () -> Void - - init(_ onCancel: @Sendable @escaping () -> Void) { - self.onCancel = onCancel - } - - func cancel() { - onCancel() + let scheme = self.scheme + return scheme == "file" || scheme == "data" } } diff --git a/Sources/Nuke/Internal/Graphics.swift b/Sources/Nuke/Internal/Graphics.swift index adb40b85f..515cd9f4d 100644 --- a/Sources/Nuke/Internal/Graphics.swift +++ b/Sources/Nuke/Internal/Graphics.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -147,7 +147,7 @@ extension PlatformImage { /// Decompresses the input image by drawing in the the `CGContext`. func decompressed(isUsingPrepareForDisplay: Bool) -> PlatformImage? { #if os(iOS) || os(tvOS) || os(visionOS) - if isUsingPrepareForDisplay, #available(iOS 15.0, tvOS 15.0, *) { + if isUsingPrepareForDisplay { return preparingForDisplay() } #endif diff --git a/Sources/Nuke/Internal/ImageRequestKeys.swift b/Sources/Nuke/Internal/ImageRequestKeys.swift index 90046776b..0b933feea 100644 --- a/Sources/Nuke/Internal/ImageRequestKeys.swift +++ b/Sources/Nuke/Internal/ImageRequestKeys.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -35,42 +35,35 @@ final class MemoryCacheKey: Hashable, Sendable { /// Uniquely identifies a task of retrieving the processed image. final class TaskLoadImageKey: Hashable, Sendable { - private let loadKey: TaskFetchOriginalImageKey + private let loadKey: TaskFetchOriginalDataKey + private let scale: Float + private let thumbnail: ImageRequest.ThumbnailOptions? private let options: ImageRequest.Options private let processors: [any ImageProcessing] init(_ request: ImageRequest) { - self.loadKey = TaskFetchOriginalImageKey(request) + self.loadKey = TaskFetchOriginalDataKey(request) + self.scale = request.scale ?? 1 + self.thumbnail = request.thumbnail self.options = request.options self.processors = request.processors } func hash(into hasher: inout Hasher) { hasher.combine(loadKey.hashValue) + hasher.combine(scale.hashValue) + hasher.combine(thumbnail.hashValue) hasher.combine(options.hashValue) hasher.combine(processors.count) } static func == (lhs: TaskLoadImageKey, rhs: TaskLoadImageKey) -> Bool { - lhs.loadKey == rhs.loadKey && lhs.options == rhs.options && lhs.processors == rhs.processors - } -} - -/// Uniquely identifies a task of retrieving the original image. -struct TaskFetchOriginalImageKey: Hashable { - private let dataLoadKey: TaskFetchOriginalDataKey - private let scale: Float - private let thumbnail: ImageRequest.ThumbnailOptions? - - init(_ request: ImageRequest) { - self.dataLoadKey = TaskFetchOriginalDataKey(request) - self.scale = request.scale ?? 1 - self.thumbnail = request.thumbnail + lhs.loadKey == rhs.loadKey && lhs.scale == rhs.scale && lhs.thumbnail == rhs.thumbnail && lhs.options == rhs.options && lhs.processors == rhs.processors } } /// Uniquely identifies a task of retrieving the original image data. -struct TaskFetchOriginalDataKey: Hashable { +struct TaskFetchOriginalDataKey: Hashable, Sendable { private let imageId: String? private let cachePolicy: URLRequest.CachePolicy private let allowsCellularAccess: Bool @@ -78,7 +71,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/LinkedList.swift b/Sources/Nuke/Internal/LinkedList.swift index 2f33ab63d..9bd410c13 100644 --- a/Sources/Nuke/Internal/LinkedList.swift +++ b/Sources/Nuke/Internal/LinkedList.swift @@ -1,14 +1,17 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation /// A doubly linked list. -final class LinkedList { +final class LinkedList { // first <-> node <-> ... <-> last private(set) var first: Node? private(set) var last: Node? + private(set) var count = 0 + + var isEmpty: Bool { last == nil } deinit { // This way we make sure that the deallocations do no happen recursively @@ -16,14 +19,9 @@ final class LinkedList { removeAllElements() } - var isEmpty: Bool { - last == nil - } - /// Adds an element to the end of the list. - @discardableResult - func append(_ element: Element) -> Node { - let node = Node(value: element) + @discardableResult func append(_ element: Element) -> Node { + let node = Node(element) append(node) return node } @@ -38,6 +36,28 @@ final class LinkedList { last = node first = node } + count += 1 + } + + /// Adds a node to the beginning of the list. + func prepend(_ node: Node) { + if let first { + node.next = first + first.previous = node + self.first = node + } else { + first = node + last = node + } + count += 1 + } + + func popLast() -> Node? { + guard let last else { + return nil + } + remove(last) + return last } func remove(_ node: Node) { @@ -51,6 +71,7 @@ final class LinkedList { } node.next = nil node.previous = nil + count -= 1 } func removeAllElements() { @@ -63,15 +84,14 @@ final class LinkedList { } last = nil first = nil + count = 0 } final class Node { - let value: Element - fileprivate var next: Node? - fileprivate var previous: Node? + var value: Element + fileprivate(set) var next: Node? + fileprivate(set) var previous: Node? - init(value: Element) { - self.value = value - } + init(_ value: Element) { self.value = value } } } diff --git a/Sources/Nuke/Internal/Log.swift b/Sources/Nuke/Internal/Log.swift index cc725ae4f..977d5fcb0 100644 --- a/Sources/Nuke/Internal/Log.swift +++ b/Sources/Nuke/Internal/Log.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import os @@ -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 56% rename from Sources/Nuke/Internal/Atomic.swift rename to Sources/Nuke/Internal/Mutex.swift index 43055ce35..7f7425cfe 100644 --- a/Sources/Nuke/Internal/Atomic.swift +++ b/Sources/Nuke/Internal/Mutex.swift @@ -1,14 +1,14 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). 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,25 @@ final class Atomic: @unchecked Sendable { return closure(&_value) } } + +extension Mutex where T: Equatable { + /// Sets the value to the given value and `returns` true` if + /// the value changed. + func setValue(_ newValue: T) -> Bool { + withLock { + guard $0 != newValue else { return false } + $0 = newValue + return true + } + } +} + +extension Mutex where T: BinaryInteger { + func incremented() -> T { + withLock { + let value = $0 + $0 += 1 + return value + } + } +} diff --git a/Sources/Nuke/Internal/Operation.swift b/Sources/Nuke/Internal/Operation.swift deleted file mode 100644 index a112d1436..000000000 --- a/Sources/Nuke/Internal/Operation.swift +++ /dev/null @@ -1,98 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import Foundation - -final class Operation: Foundation.Operation, @unchecked Sendable { - override var isExecuting: Bool { - get { - os_unfair_lock_lock(lock) - defer { os_unfair_lock_unlock(lock) } - return _isExecuting - } - set { - os_unfair_lock_lock(lock) - _isExecuting = newValue - os_unfair_lock_unlock(lock) - - willChangeValue(forKey: "isExecuting") - didChangeValue(forKey: "isExecuting") - } - } - - override var isFinished: Bool { - get { - os_unfair_lock_lock(lock) - defer { os_unfair_lock_unlock(lock) } - return _isFinished - } - set { - os_unfair_lock_lock(lock) - _isFinished = newValue - os_unfair_lock_unlock(lock) - - willChangeValue(forKey: "isFinished") - didChangeValue(forKey: "isFinished") - } - } - - typealias Starter = @Sendable (_ finish: @Sendable @escaping () -> Void) -> Void - private let starter: Starter - - private var _isExecuting = false - private var _isFinished = false - private var isFinishCalled = false - private let lock: os_unfair_lock_t - - deinit { - lock.deinitialize(count: 1) - lock.deallocate() - } - - init(starter: @escaping Starter) { - self.starter = starter - - self.lock = .allocate(capacity: 1) - self.lock.initialize(to: os_unfair_lock()) - } - - override func start() { - guard !isCancelled else { - isFinished = true - return - } - isExecuting = true - starter { [weak self] in - self?._finish() - } - } - - private func _finish() { - os_unfair_lock_lock(lock) - guard !isFinishCalled else { - return os_unfair_lock_unlock(lock) - } - isFinishCalled = true - os_unfair_lock_unlock(lock) - - isExecuting = false - isFinished = true - } -} - -extension OperationQueue { - /// Adds simple `BlockOperation`. - func add(_ closure: @Sendable @escaping () -> Void) -> BlockOperation { - let operation = BlockOperation(block: closure) - addOperation(operation) - return operation - } - - /// Adds asynchronous operation (`Nuke.Operation`) with the given starter. - func add(_ starter: @escaping Operation.Starter) -> Operation { - let operation = Operation(starter: starter) - addOperation(operation) - return operation - } -} diff --git a/Sources/Nuke/Internal/RateLimiter.swift b/Sources/Nuke/Internal/RateLimiter.swift index d85c34a41..58955204a 100644 --- a/Sources/Nuke/Internal/RateLimiter.swift +++ b/Sources/Nuke/Internal/RateLimiter.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -13,16 +13,16 @@ 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 - typealias Work = () -> Bool + typealias Work = @ImagePipelineActor () -> Bool /// Initializes the `RateLimiter` with the given configuration. /// - parameters: @@ -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..a41f0258a 100644 --- a/Sources/Nuke/Internal/ResumableData.swift +++ b/Sources/Nuke/Internal/ResumableData.swift @@ -1,12 +1,12 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). 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 @@ -63,71 +63,55 @@ struct ResumableData: @unchecked Sendable { } /// Shared cache, uses the same memory pool across multiple pipelines. -final class ResumableDataStorage: @unchecked Sendable { +@ImagePipelineActor +final class ResumableDataStorage { 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) { - lock.lock() - defer { lock.unlock() } - - if registeredPipelines.isEmpty { + func register(_ namespace: UUID) { + if namespaces.isEmpty { // 32 MB cache = Cache(costLimit: 32000000, countLimit: 100) } - registeredPipelines.insert(pipeline.id) + namespaces.insert(namespace) } - func unregister(_ pipeline: ImagePipeline) { - lock.lock() - defer { lock.unlock() } - - registeredPipelines.remove(pipeline.id) - if registeredPipelines.isEmpty { + func unregister(_ namespace: UUID) { + namespaces.remove(namespace) + if namespaces.isEmpty { cache = nil // Deallocate storage } } func removeAllResponses() { - lock.lock() - defer { lock.unlock() } - cache?.removeAllCachedValues() } // MARK: Storage - func removeResumableData(for request: ImageRequest, pipeline: ImagePipeline) -> ResumableData? { - lock.lock() - defer { lock.unlock() } - - guard let key = Key(request: request, pipeline: pipeline) else { return nil } + func removeResumableData(for request: ImageRequest, namespace: UUID) -> ResumableData? { + 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) { - lock.lock() - defer { lock.unlock() } - - guard let key = Key(request: request, pipeline: pipeline) else { return } + func storeResumableData(_ data: ResumableData, for request: ImageRequest, namespace: UUID) { + 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/Jobs/AsyncPipelineTask.swift b/Sources/Nuke/Jobs/AsyncPipelineTask.swift new file mode 100644 index 000000000..9319afad4 --- /dev/null +++ b/Sources/Nuke/Jobs/AsyncPipelineTask.swift @@ -0,0 +1,115 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation + +// TODO: rename/remove +// 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: Job { + let pipeline: ImagePipeline + // A canonical request representing the unit work performed by the task. + let request: ImageRequest + + init(_ pipeline: ImagePipeline, _ request: ImageRequest) { + self.pipeline = pipeline + self.request = request + super.init() + } +} + +// MARK: - AsyncPipelineTask (Helpers) + +extension AsyncPipelineTask { + func decode(_ context: ImageDecodingContext, decoder: any ImageDecoding, _ completion: @ImagePipelineActor @Sendable @escaping (Result) -> Void) { + let operation = Operation(name: "DecodeImage") { + decoder.decode(context) + } + if decoder.isAsynchronous { + operation.queue = pipeline.configuration.imageDecodingQueue + } + self.operation = operation.receive(self, completion) + } + + func decompress(_ response: ImageResponse, _ completion: @ImagePipelineActor @Sendable @escaping (ImageResponse) -> Void) { + let operation = Operation(name: "DecompressImage") { + let value = self.pipeline.delegate.decompress(response: response, request: self.request, pipeline: self.pipeline) + return .success(value) + } + operation.queue = pipeline.configuration.imageDecompressingQueue + self.operation = operation.receive(self) { + switch $0 { + case let .success(value): completion(value) + case .failure: completion(response) + } + } + } + + func process(_ context: ImageProcessingContext, response: ImageResponse, processors: [any ImageProcessing], _ completion: @ImagePipelineActor @Sendable @escaping (Result) -> Void) { + let operation = Operation(name: "ProcessImage") { + var response = response + for processor in processors { + do { + response.container = try processor.process(response.container, context: context) + } catch { + return .failure(.processingFailed(processor: processor, context: context, error: error)) + } + } + return .success(response) + } + operation.queue = pipeline.configuration.imageProcessingQueue + self.operation = operation.receive(self, completion) + } + + func storeImageInCaches(_ response: ImageResponse) { + pipeline.cache[request] = response.container + if shouldStoreResponseInDataCache(response) { + storeImageInDataCache(response) + } + } + + private func storeImageInDataCache(_ response: ImageResponse) { + guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline) else { + return + } + let context = ImageEncodingContext(request: request, image: response.image, urlResponse: response.urlResponse) + let encoder = pipeline.delegate.imageEncoder(for: context, pipeline: pipeline) + let key = pipeline.cache.makeDataCacheKey(for: request) + + let operation = Operation(name: "EncodeImage") { + let encodedData = encoder.encode(response.container, context: context) + if let data = encodedData, !data.isEmpty { + self.pipeline.delegate.willCache(data: data, image: response.container, for: self.request, pipeline: self.pipeline) { + guard let data = $0, !data.isEmpty else { return } + // Important! Storing directly ignoring `ImageRequest.Options`. + dataCache.storeData(data, for: key) // This is instant, writes are async + } + } + return .success(()) + } + if !pipeline.configuration.debugIsSyncImageEncoding { + operation.queue = pipeline.configuration.imageEncodingQueue + } + operation.receive { _ in } // Adding a subscriber starts a job + } + + private func shouldStoreResponseInDataCache(_ response: ImageResponse) -> Bool { + guard !response.container.isPreview, + !(response.cacheType == .disk), + !(request.url?.isLocalResource ?? false) else { + return false + } + let isProcessed = !request.processors.isEmpty || request.thumbnail != nil + switch pipeline.configuration.dataCachePolicy { + case .automatic: + return isProcessed + case .storeOriginalData: + return false + case .storeEncodedImages: + return true + case .storeAll: + return isProcessed + } + } +} diff --git a/Sources/Nuke/Jobs/Job.swift b/Sources/Nuke/Jobs/Job.swift new file mode 100644 index 000000000..cb8fc7a2f --- /dev/null +++ b/Sources/Nuke/Jobs/Job.swift @@ -0,0 +1,305 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation + +// TODO: separate priority from addSubscribedTasks and (probably) remove priority entirely +/// A subscriber determines the priority of the job (together with other subscribers). +protocol JobOwner { + @ImagePipelineActor + var priority: JobPriority { get } + + @ImagePipelineActor + func addSubscribedTasks(to output: inout [ImageTask]) +} + +/// A subscriber that also receives events emitted by the job. +protocol JobSubscriber: JobOwner { + associatedtype Value: Sendable + + @ImagePipelineActor + func receive(_ event: Job.Event) +} + +// TODO: can we remove some of these? +@ImagePipelineActor +protocol JobDelegate: AnyObject { + func jobDisposed(_ job: any JobProtocol) + func job(_ job: any JobProtocol, didUpdatePriority newPriority: JobPriority, from oldPriority: JobPriority) +} + +/// Represents a unit of work performed by the image pipeline. +/// +/// A single job can have many subscribers. The priority of the job is automatically +/// set to the highest priority of its subscribers. +@ImagePipelineActor +class Job: JobProtocol, JobOwner { + enum Event { + case value(Value, isCompleted: Bool) + case progress(JobProgress) + case error(ImageTask.Error) + } + + private struct Subscriber { + var subscriber: any JobSubscriber + } + + private var subscriptions = JobSubscriberSet() + + /// Returns `true` if the job was either cancelled, or was completed. + private(set) var isDisposed = false + private(set) var isStarted = false + private var isEnqueued = false + + /// Gets called when the job is either cancelled, or was completed. + var onDisposed: (@ImagePipelineActor @Sendable () -> Void)? + + private(set) var priority: JobPriority = .normal { + didSet { + guard oldValue != priority else { return } + operation?.didChangePriority(priority) + dependency?.didChangePriority(priority) + delegate?.job(self, didUpdatePriority: priority, from: oldValue) + } + } + + /// A queue on which to schedule the job. + /// + /// The job is scheduled automatically as soon as the first subscriber is added. + /// It ensures that if it completes synchronously, the events are still + /// delivered, and the job gets scheduled with the initial priority + /// based on the subscriber. + weak var queue: JobQueue? + + weak var delegate: JobDelegate? + + /// A job might have a dependency. The job automatically unsubscribes + /// from the dependency when it gets cancelled, and also updates the + /// priority of the subscription to the dependency when its own + /// priority is updated. + var dependency: JobSubscription? { + didSet { + dependency?.didChangePriority(priority) + } + } + + var operation: JobSubscription? + + /// Returns all tasks registered for the current job, directly or indirectly. + var tasks: [ImageTask] { + var tasks: [ImageTask] = [] + addSubscribedTasks(to: &tasks) + return tasks + } + + private var starter: (@ImagePipelineActor @Sendable (Job) -> Void)? + + init(_ starter: @ImagePipelineActor @Sendable @escaping (Job) -> Void) { + self.starter = starter + } + + init() {} + + // MARK: - Hooks + + /// Override this to start the job. Only gets called once. + func start() {} + + // TODO: overriding is bad + func onCancel() { + terminate(reason: .cancelled) + } + + // MARK: - Stat + + /// - warning: Do not call this directly. + func startIfNeeded() { + guard !isStarted else { return } + isStarted = true + start() + } + + // MARK: - Subscribers + + /// - notes: Returns `nil` if the job was disposed. + func subscribe(_ subscriber: any JobSubscriber) -> JobSubscription? { + guard !isDisposed else { return nil } + + let index = subscriptions.add(Subscriber(subscriber: subscriber)) + + if subscriptions.count == 1 { + priority = subscriber.priority + } else { + updatePriority(suggestedPriority: subscriber.priority) + } + + if !isEnqueued { + isEnqueued = true + if let queue { + queue.enqueue(self) + } else { + startIfNeeded() + } + } + + // The job may have been completed synchronously by `starter`. + guard !isDisposed else { return nil } + return JobSubscription(job: self, key: index) + } + + // MARK: - JobSubscriptionDelegate + + func didChangePriority(_ priority: JobPriority, for key: JobSubscriptionKey) { + guard !isDisposed else { return } + updatePriority(suggestedPriority: priority) + } + + func unsubscribe(key: JobSubscriptionKey) { + subscriptions.remove(at: key) + guard !isDisposed else { return } + // swiftlint:disable:next empty_count + if subscriptions.count == 0 { + onCancel() + } else { + updatePriority(suggestedPriority: nil) + } + } + + // MARK: - Sending Events + + func send(value: Value, isCompleted: Bool = false) { + send(event: .value(value, isCompleted: isCompleted)) + } + + func send(error: ImageTask.Error) { + send(event: .error(error)) + } + + func send(progress: JobProgress) { + send(event: .progress(progress)) + } + + /// A convenience method that send a terminal event depending on the result. + func finish(with result: Result) { + switch result { + case .success(let value): + send(value: value, isCompleted: true) + case .failure(let error): + send(error: error) + } + } + + private func send(event: Event) { + guard !isDisposed else { return } + + subscriptions.forEach { + $0.subscriber.receive(event) + } + + switch event { + case let .value(_, isCompleted): + if isCompleted { + terminate(reason: .finished) + } + case .progress: + break // Simply send the event + case .error: + terminate(reason: .finished) + } + } + + // MARK: - Termination + + private enum TerminationReason { + case finished, cancelled + } + + private func terminate(reason: TerminationReason) { + guard !isDisposed else { return } + isDisposed = true + + if reason == .cancelled { + operation?.unsubscribe() + dependency?.unsubscribe() + } + subscriptions = .init() + onDisposed?() + delegate?.jobDisposed(self) + } + + // MARK: - Priority + + private func updatePriority(suggestedPriority: JobPriority?) { + if let suggestedPriority, suggestedPriority >= priority { + // No need to recompute, won't go higher than that + priority = suggestedPriority + return + } + var newPriority: JobPriority = .veryLow + subscriptions.forEach { + newPriority = max(newPriority, $0.subscriber.priority) + } + self.priority = newPriority + } + + // MARK: - Subscribers + + func addSubscribedTasks(to output: inout [ImageTask]) { + subscriptions.forEach { + $0.subscriber.addSubscribedTasks(to: &output) + } + } +} + +typealias JobProgress = ImageTask.Progress +typealias JobPriority = ImageRequest.Priority + +// MARK: - JobSubscription + +/// Represents a subscription to a job. The observer must retain a strong +/// reference to a subscription. +@ImagePipelineActor +struct JobSubscription { + private let job: any JobProtocol + private let key: JobSubscriptionKey + + fileprivate init(job: any JobProtocol, key: JobSubscriptionKey) { + self.job = job + self.key = key + } + + /// Removes the subscription from the job. The observer won't receive any + /// more events from the job. + /// + /// If there are no more subscriptions attached to the job, the job gets + /// cancelled along with its dependencies. The cancelled job is + /// marked as disposed. + func unsubscribe() { + job.unsubscribe(key: key) + } + + /// Updates the priority of the subscription. The priority of the job is + /// calculated as the maximum priority out of all of its subscription. When + /// the priority of the job is updated, the priority of a dependency also is. + /// + /// - note: The priority also automatically gets updated when the subscription + /// is removed from the job. + func didChangePriority(_ priority: JobPriority) { + job.didChangePriority(priority, for: key) + } +} + +// TODO: add separate JobSubscription.Delegate +@ImagePipelineActor +protocol JobProtocol: AnyObject, Sendable { + var priority: JobPriority { get } + var isStarted: Bool { get } + var queue: JobQueue? { get set } + + func startIfNeeded() + func unsubscribe(key: JobSubscriptionKey) + func didChangePriority(_ priority: JobPriority, for key: JobSubscriptionKey) +} + +typealias JobSubscriptionKey = Int diff --git a/Sources/Nuke/Tasks/TaskFetchOriginalData.swift b/Sources/Nuke/Jobs/JobFetchData.swift similarity index 64% rename from Sources/Nuke/Tasks/TaskFetchOriginalData.swift rename to Sources/Nuke/Jobs/JobFetchData.swift index 519330f22..7640a5a30 100644 --- a/Sources/Nuke/Tasks/TaskFetchOriginalData.swift +++ b/Sources/Nuke/Jobs/JobFetchData.swift @@ -1,34 +1,19 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). 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 JobFetchData: AsyncPipelineTask<(Data, URLResponse?)> { private var urlResponse: URLResponse? private var resumableData: ResumableData? private var resumedDataCount: Int64 = 0 private var data = Data() + private var task: Task? override func start() { - guard let urlRequest = request.urlRequest, let url = urlRequest.url else { - // A malformed URL prevented a URL request from being initiated. - send(error: .dataLoadingFailed(error: URLError(.badURL))) - return - } - - if url.isLocalResource && pipeline.configuration.isLocalResourcesSupportEnabled { - do { - let data = try Data(contentsOf: url) - send(value: (data, nil), isCompleted: true) - } catch { - send(error: .dataLoadingFailed(error: error)) - } - return - } - if let rateLimiter = pipeline.rateLimiter { // Rate limiter is synchronized on pipeline's queue. Delayed work is // executed asynchronously also on the same queue. @@ -36,41 +21,61 @@ final class TaskFetchOriginalData: AsyncPipelineTask<(Data, URLResponse?)>, @unc guard let self, !self.isDisposed else { return false } - self.loadData(urlRequest: urlRequest) + self.actuallyStart() return true } } else { // Start loading immediately. - loadData(urlRequest: urlRequest) + actuallyStart() } } - private func loadData(urlRequest: URLRequest) { - if request.options.contains(.skipDataLoadingQueue) { - loadData(urlRequest: urlRequest, finish: { /* do nothing */ }) - } else { - // Wrap data request in an operation to limit the maximum number of - // concurrent data tasks. - operation = pipeline.configuration.dataLoadingQueue.add { [weak self] finish in - guard let self else { - return finish() - } - self.pipeline.queue.async { - self.loadData(urlRequest: urlRequest, finish: finish) - } + override func onCancel() { + task?.cancel() + super.onCancel() // Important + } + + private func actuallyStart() { + switch request.resource { + case .url(let url): + guard let url else { + return send(error: .dataLoadingFailed(error: URLError(.badURL))) + } + start(with: URLRequest(url: url)) + case .urlRequest(let urlRequest): + start(with: urlRequest) + case .closure(let closure, _): + task = Task { @ImagePipelineActor in + await self.loadData(with: closure) } } } - // This methods gets called inside data loading operation (Operation). - private func loadData(urlRequest: URLRequest, finish: @escaping () -> Void) { - guard !isDisposed else { - return finish() + // MARK: URLRequest + + private func start(with urlRequest: URLRequest) { + if pipeline.configuration.isLocalResourcesSupportEnabled, let url = urlRequest.url, url.isLocalResource { + do { + let data = try Data(contentsOf: url) + send(value: (data, nil), isCompleted: true) + } catch { + send(error: .dataLoadingFailed(error: error)) + } + return + } + task = Task { @ImagePipelineActor in + await self.actuallyLoadData(urlRequest: urlRequest) } + } + + // This methods gets called inside data loading operation (Operation). + private func actuallyLoadData(urlRequest: URLRequest) async { + guard !isDisposed else { return } + // Read and remove resumable data from cache (we're going to insert it // 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,32 +83,19 @@ 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) - } - }, 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) - } - }) - onCancelled = { [weak self] in - guard let self else { return } - - signpost(self, "LoadImageData", .end, "Cancelled") - dataTask.cancel() - finish() // Finish the operation! - - self.tryToSaveResumableData() + do { + for try await (data, response) in dataLoader.loadData(for: urlRequest) { + dataTask(didReceiveData: data, response: response) + } + dataTaskDidFinish(error: nil) + } catch { + dataTaskDidFinish(error: error) } + signpost(self, "LoadImageData", .end, "Finished with size \(Formatter.bytes(self.data.count))") } private func dataTask(didReceiveData chunk: Data, response: URLResponse) { @@ -126,7 +118,7 @@ final class TaskFetchOriginalData: AsyncPipelineTask<(Data, URLResponse?)>, @unc } urlResponse = response - let progress = TaskProgress(completed: Int64(data.count), total: response.expectedContentLength + resumedDataCount) + let progress = JobProgress(completed: Int64(data.count), total: response.expectedContentLength + resumedDataCount) send(progress: progress) // If the image hasn't been fully loaded yet, give decoder a change @@ -150,7 +142,6 @@ final class TaskFetchOriginalData: AsyncPipelineTask<(Data, URLResponse?)>, @unc return } - // Store in data cache storeDataInCacheIfNeeded(data) send(value: (data, urlResponse), isCompleted: true) @@ -162,13 +153,34 @@ 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) } } -} -extension AsyncPipelineTask where Value == (Data, URLResponse?) { - func storeDataInCacheIfNeeded(_ data: Data) { + // MARK: Closure + + private func loadData(with closure: (@Sendable @escaping () async throws -> Data)) async { + guard !isDisposed else { + return + } + 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") + } + do { + let data = try await closure() + guard !data.isEmpty else { + throw ImageTask.Error.dataIsEmpty + } + send(value: (data, nil), isCompleted: true) + } catch { + send(error: .dataLoadingFailed(error: error)) + } + } + + // MARK: - DataCaching + + private func storeDataInCacheIfNeeded(_ data: Data) { let request = makeSanitizedRequest() guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), shouldStoreDataInDiskCache() else { return @@ -191,8 +203,8 @@ extension AsyncPipelineTask where Value == (Data, URLResponse?) { } private func shouldStoreDataInDiskCache() -> Bool { - let imageTasks = imageTasks - guard imageTasks.contains(where: { !$0.request.options.contains(.disableDiskCacheWrites) }) else { + let tasks = tasks + guard tasks.contains(where: { !$0.request.options.contains(.disableDiskCacheWrites) }) else { return false } guard !(request.url?.isLocalResource ?? false) else { @@ -200,7 +212,7 @@ extension AsyncPipelineTask where Value == (Data, URLResponse?) { } switch pipeline.configuration.dataCachePolicy { case .automatic: - return imageTasks.contains { $0.request.processors.isEmpty } + return tasks.contains { $0.request.processors.isEmpty } case .storeOriginalData: return true case .storeEncodedImages: diff --git a/Sources/Nuke/Jobs/JobFetchImage.swift b/Sources/Nuke/Jobs/JobFetchImage.swift new file mode 100644 index 000000000..41865f910 --- /dev/null +++ b/Sources/Nuke/Jobs/JobFetchImage.swift @@ -0,0 +1,142 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Wrapper for tasks created by `loadImage` calls. +/// +/// 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). +class JobFetchImage: AsyncPipelineTask, JobSubscriber { + private var decoder: (any ImageDecoding)? + + // MARK: Memory Cache + + override func start() { + dependency = pipeline.makeJobFetchData(for: request).subscribe(self) + } + + func receive(_ event: Job<(Data, URLResponse?)>.Event) { + switch event { + case let .value(value, isCompleted): + didReceiveData(value.0, urlResponse: value.1, isCompleted: isCompleted) + case .progress(let progress): + send(progress: progress) + case .error(let error): + send(error: error) + } + } + + /// Receiving data from `TaskFetchOriginalData`. + private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { + guard isCompleted || pipeline.configuration.isProgressiveDecodingEnabled else { + return + } + + if !isCompleted && operation != nil { + return // Back pressure - already decoding another progressive data chunk + } + + if isCompleted { + operation?.unsubscribe() // Cancel any potential pending progressive decoding tasks + } + + let context = ImageDecodingContext(request: request, data: data, isCompleted: isCompleted, urlResponse: urlResponse) + guard let decoder = getDecoder(for: context) else { + if isCompleted { + send(error: .decoderNotRegistered(context: context)) + } else { + // Try again when more data is downloaded. + } + return + } + decode(context, decoder: decoder) { [weak self] result in + self?.didFinishDecoding(context: context, result: result) + } + } + + private func didFinishDecoding(context: ImageDecodingContext, result: Result) { + operation = nil // TODO: cleanup + + switch result { + case .success(let response): + didReceiveDecodedImage(response, isCompleted: context.isCompleted) + case .failure(let error): + if context.isCompleted { + send(error: error) + } + } + } + + // Lazily creates decoding for task + private func getDecoder(for context: ImageDecodingContext) -> (any ImageDecoding)? { + // Return the existing processor in case it has already been created. + if let decoder { + return decoder + } + let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) + self.decoder = decoder + return decoder + } + + // MARK: Processing + + private func didReceiveDecodedImage(_ response: ImageResponse, isCompleted: Bool) { + guard !isDisposed else { return } + if isCompleted { + operation?.unsubscribe() // Cancel any potential pending progressive + } else if operation != nil { + return // Back pressure - already processing another progressive image + } + guard !request.processors.isEmpty else { + return didReceiveProcessedImage(response, isCompleted: isCompleted) + } + let context = ImageProcessingContext(request: request, response: response, isCompleted: isCompleted) + process(context, response: response, processors: request.processors) { [weak self] in + self?.operation = nil + self?.didFinishProcessing(result: $0, isCompleted: isCompleted) + } + } + + private func didFinishProcessing(result: Result, isCompleted: Bool) { + switch result { + case .success(let response): + didReceiveProcessedImage(response, isCompleted: isCompleted) + case .failure(let error): + if isCompleted { + send(error: error) + } + } + } + + // MARK: Decompression + + private func didReceiveProcessedImage(_ response: ImageResponse, isCompleted: Bool) { + guard isDecompressionNeeded(for: response) else { + return didReceiveDecompressedImage(response, isCompleted: isCompleted) + } + guard !isDisposed else { return } + if isCompleted { + operation?.unsubscribe() // Cancel any potential pending progressive decompression tasks + } else if operation != nil { + return // Back-pressure: receiving progressive scans too fast + } + decompress(response) { response in + self.operation = nil + self.didReceiveDecompressedImage(response, isCompleted: isCompleted) + } + } + + // TODO: do it based on the subscribed tasks + private func isDecompressionNeeded(for response: ImageResponse) -> Bool { + ImageDecompression.isDecompressionNeeded(for: response) && + !request.options.contains(.skipDecompression) && + pipeline.delegate.shouldDecompress(response: response, for: request, pipeline: pipeline) + } + + private func didReceiveDecompressedImage(_ response: ImageResponse, isCompleted: Bool) { + send(value: response, isCompleted: isCompleted) + } +} diff --git a/Sources/Nuke/Jobs/JobPool.swift b/Sources/Nuke/Jobs/JobPool.swift new file mode 100644 index 000000000..5c40656cb --- /dev/null +++ b/Sources/Nuke/Jobs/JobPool.swift @@ -0,0 +1,30 @@ +import Foundation + +@ImagePipelineActor +final class JobPool { + private let isCoalescingEnabled: Bool + private var map = [Key: Job]() + + nonisolated init(_ isCoalescingEnabled: Bool) { + self.isCoalescingEnabled = isCoalescingEnabled + } + + /// Creates a task with the given key. If there is an outstanding task with + /// the given key in the pool, the existing task is returned. Tasks are + /// automatically removed from the pool when they are disposed. + func task(for key: @autoclosure () -> Key, _ make: () -> Job) -> Job { + guard isCoalescingEnabled else { + return make() + } + let key = key() + if let task = map[key] { + return task + } + let task = make() + map[key] = task + task.onDisposed = { [weak self] in + self?.map[key] = nil + } + return task + } +} diff --git a/Sources/Nuke/Jobs/JobPrefixFetchData.swift b/Sources/Nuke/Jobs/JobPrefixFetchData.swift new file mode 100644 index 000000000..427baeca5 --- /dev/null +++ b/Sources/Nuke/Jobs/JobPrefixFetchData.swift @@ -0,0 +1,41 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A non-coalesced task that represents the initial (quick) part of the work. +final class JobPrefixFetchData: AsyncPipelineTask, JobSubscriber { + override func start() { + if let data = pipeline.cache.cachedData(for: request) { + let container = ImageContainer(image: .init(), data: data) + let response = ImageResponse(container: container, request: request) + self.send(value: response, isCompleted: true) + } else { + self.loadData() + } + } + + private func loadData() { + guard !request.options.contains(.returnCacheDataDontLoad) else { + return send(error: .dataMissingInCache) + } + let request = request.withProcessors([]) + dependency = pipeline.makeJobFetchData(for: request).subscribe(self) + } + + func receive(_ event: Job<(Data, URLResponse?)>.Event) { + switch event { + case let .value((data, urlResponse), isCompleted): + let container = ImageContainer(image: .init(), data: data) + let response = ImageResponse(container: container, request: request, urlResponse: urlResponse) + if isCompleted { + send(value: response, isCompleted: isCompleted) + } + case .progress(let progress): + send(progress: progress) + case .error(let error): + send(error: error) + } + } +} diff --git a/Sources/Nuke/Jobs/JobPrefixFetchImage.swift b/Sources/Nuke/Jobs/JobPrefixFetchImage.swift new file mode 100644 index 000000000..761ec1018 --- /dev/null +++ b/Sources/Nuke/Jobs/JobPrefixFetchImage.swift @@ -0,0 +1,79 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation + +// TODO: can we run the preflight task without creating a Job (expensive?) +// TODO: can we create a chain of tasks without `start()`? + +/// A non-coalesced task that represents the initial (quick) part of the work. +final class JobPrefixFetchImage: AsyncPipelineTask, JobSubscriber { + override func start() { + if let container = pipeline.cache[request] { + let response = ImageResponse(container: container, request: request, cacheType: .memory) + send(value: response, isCompleted: !container.isPreview) + if !container.isPreview { + return // The final image is loaded + } + } + // TODO: check original image cache also! + if let data = pipeline.cache.cachedData(for: request) { + decodeCachedData(data) + } else { + fetchImage() + } + } + + // MARK: Disk Cache + + private func decodeCachedData(_ data: Data) { + let context = ImageDecodingContext(request: request, data: data, cacheType: .disk) + guard let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) else { + return didFinishDecoding(with: nil) + } + // TODO: this doesn't check if decompression needed or not + decode(context, decoder: decoder) { [weak self] result in + self?.didFinishDecoding(with: try? result.get()) + } + } + + private func didFinishDecoding(with response: ImageResponse?) { + if let response { + decompress(response) { + self.didReceiveResponse($0) + } + } else { + fetchImage() + } + } + + // MARK: Fetch Image + + private func fetchImage() { + guard !request.options.contains(.returnCacheDataDontLoad) else { + return send(error: .dataMissingInCache) + } + // TODO: can we do the preflight task here with no processors? or should the initial task do this? + dependency = pipeline.makeJobFetchImage(for: request).subscribe(self) + } + + func receive(_ event: Job.Event) { + switch event { + case let .value(value, isCompleted): + didReceiveResponse(value) + case .progress(let progress): + send(progress: progress) + case .error(let error): + send(error: error) + } + } + + // MARK: Finish + + // TODO: cleanup how this is used + private func didReceiveResponse(_ response: ImageResponse) { + storeImageInCaches(response) + send(value: response, isCompleted: !response.isPreview) + } +} diff --git a/Sources/Nuke/Jobs/JobQueue.swift b/Sources/Nuke/Jobs/JobQueue.swift new file mode 100644 index 000000000..1c6dd5a59 --- /dev/null +++ b/Sources/Nuke/Jobs/JobQueue.swift @@ -0,0 +1,129 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// Limits the number of concurrenly executed jobs. +@ImagePipelineActor +public final class JobQueue { + /// Sets the maximum number of concurrently executed operations. + public nonisolated var maxConcurrentJobCount: Int { + get { _maxConcurrentJobCount.value } + set { _maxConcurrentJobCount.value = newValue } + } + private let _maxConcurrentJobCount: Mutex + + /// Setting this property to true prevents the queue from starting any queued + /// tasks, but already executing tasks continue to execute. + var isSuspended = false { + didSet { + guard oldValue != isSuspended, !isSuspended else { return } + performScheduledJobs() + } + } + + let scheduledJobs = (0..() + } + + let executingJobs = LinkedList() + + typealias JobHandle = LinkedList.Node + + enum Event { + case added(JobHandle) + case priorityUpdated(JobHandle, JobPriority) + case cancelled(JobHandle) + case disposed(JobHandle) + } + + var onEvent: (@ImagePipelineActor (Event) -> Void)? + + nonisolated init(maxConcurrentJobCount: Int = 1) { + self._maxConcurrentJobCount = Mutex(maxConcurrentJobCount) + } + + /// - warning: Do not call this directly. + @discardableResult + func enqueue(_ job: Job) -> JobHandle { + let handle = JobHandle(job) + job.delegate = handle + if !isSuspended && executingJobs.count < maxConcurrentJobCount { + perform(handle) + } else { + scheduledJobs(for: handle.job.priority).prepend(handle) + } + onEvent?(.added(handle)) + return handle + } + + private func perform(_ handle: JobHandle) { + executingJobs.append(handle) + handle.job.startIfNeeded() + } + + private func scheduledJobs(for priority: JobPriority) -> LinkedList { + scheduledJobs[priority.rawValue] + } + + // MARK: - JobHandleDelegate + + func disposed(_ handle: JobHandle) { + if handle.job.isStarted { + executingJobs.remove(handle) + performScheduledJobs() + } else { + scheduledJobs(for: handle.job.priority).remove(handle) + onEvent?(.cancelled(handle)) + } + handle.job.queue = nil + onEvent?(.disposed(handle)) + } + + func job(_ handle: JobHandle, didUpdatePriority newPriority: JobPriority, from oldPriority: JobPriority) { + guard !handle.job.isStarted else { return } + scheduledJobs(for: oldPriority).remove(handle) + + // When raising priority, prepend to execute sooner. + // When lowering priority, append to avoid unfair queue jumping. + if newPriority > oldPriority { + scheduledJobs(for: newPriority).prepend(handle) + } else { + scheduledJobs(for: newPriority).append(handle) + } + + onEvent?(.priorityUpdated(handle, newPriority)) + } + + // MARK: - Performing Scheduled Work + + /// Returns a scheduled job with the highest priority. + private func dequeueNextJob() -> JobHandle? { + for list in scheduledJobs.reversed() { + if let handle = list.popLast() { + return handle + } + } + return nil + } + + private func performScheduledJobs() { + while !isSuspended, executingJobs.count < maxConcurrentJobCount, let job = dequeueNextJob() { + perform(job) + } + } +} + +@ImagePipelineActor +extension JobQueue.JobHandle: JobDelegate { + var job: JobProtocol { value } + + func jobDisposed(_ job: any JobProtocol) { + value.queue?.disposed(self) + } + + func job(_ job: any JobProtocol, didUpdatePriority newPriority: JobPriority, from oldPriority: JobPriority) { + value.queue?.job(self, didUpdatePriority: newPriority, from: oldPriority) + } +} diff --git a/Sources/Nuke/Jobs/JobSubscriberSet.swift b/Sources/Nuke/Jobs/JobSubscriberSet.swift new file mode 100644 index 000000000..76cce705b --- /dev/null +++ b/Sources/Nuke/Jobs/JobSubscriberSet.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Optimized for a scenario when there is only one subscriber (or two in the +/// case of prefetching). +struct JobSubscriberSet { + private var inline: (Element?, Element?) + private var more: [Int: Element]? + private var nextIndex = 2 + + private(set) var count: Int = 0 + + mutating func add(_ element: Element) -> Int { + count += 1 + if inline.0 == nil { + inline.0 = element + return 0 + } else if inline.1 == nil { + inline.1 = element + return 1 + } else { + if more == nil { more = [:] } + let index = nextIndex + nextIndex += 1 + more![index] = element + return index + } + } + + mutating func remove(at index: Int) { + count -= 1 + if index == 0 { + inline.0 = nil + } else if index == 1 { + inline.1 = nil + } else { + more?[index] = nil + } + } + + func forEach(_ closure: (Element) -> Void) { + if let sub = inline.0 { closure(sub) } + if let sub = inline.1 { closure(sub) } + if let more { + for (_, value) in more { + closure(value) + } + } + } +} diff --git a/Sources/Nuke/Jobs/Operation.swift b/Sources/Nuke/Jobs/Operation.swift new file mode 100644 index 000000000..d6ca07110 --- /dev/null +++ b/Sources/Nuke/Jobs/Operation.swift @@ -0,0 +1,64 @@ +import Foundation + +/// A simple job that represents an operation that executes the given closure +/// on a thread managed by Swift Concurrency. +final class Operation: Job { + // This needs to be replaced with typed throws as soon as Swift supports infrence for closures + private let closure: @Sendable () -> Result + private let name: StaticString + private var task: Task? + + /// Initialize the task with the given closure to be executed in the background. + init(name: StaticString = "Operation", closure: @Sendable @escaping () -> Result) { + self.name = name + self.closure = closure + super.init() + } + + override func start() { + task = Task.detached { + let result = self.closure() + await self.finish(with: result) + } + } + + // TODO: cleanup + override func onCancel() { + if let task { + task.cancel() + } else { + super.onCancel() + } + } + + struct ProxySubscriber: JobSubscriber { + let owner: (any JobOwner)? + let completion: @ImagePipelineActor @Sendable (Result) -> Void + + var priority: JobPriority { owner?.priority ?? .normal } + + func addSubscribedTasks(to output: inout [ImageTask]) { + owner?.addSubscribedTasks(to: &output) + } + + func receive(_ event: Job.Event) { + switch event { + case let .value(value, isCompleted): + if isCompleted { + completion(.success(value)) + } + case .progress: + break + case let .error(error): + completion(.failure(error)) + } + } + } + + @discardableResult func receive( + _ owner: (any JobOwner)? = nil, + _ completion: @ImagePipelineActor @Sendable @escaping (Result) -> Void + ) -> JobSubscription? { + subscribe(ProxySubscriber(owner: owner, completion: completion)) + } +} diff --git a/Sources/Nuke/Loading/DataLoader.swift b/Sources/Nuke/Loading/DataLoader.swift index a3076d237..cebcf0ce5 100644 --- a/Sources/Nuke/Loading/DataLoader.swift +++ b/Sources/Nuke/Loading/DataLoader.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -90,13 +90,25 @@ 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 { - let task = session.dataTask(with: request) - if #available(iOS 14.5, tvOS 14.5, watchOS 7.4, macOS 11.3, *) { - task.prefersIncrementalDelivery = prefersIncrementalDelivery + 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) + task.prefersIncrementalDelivery = prefersIncrementalDelivery return impl.loadData(with: task, session: session, didReceiveData: didReceiveData, completion: completion) } @@ -130,13 +142,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 +235,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..84658f1dc 100644 --- a/Sources/Nuke/Loading/DataLoading.swift +++ b/Sources/Nuke/Loading/DataLoading.swift @@ -1,21 +1,14 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). 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+Cache.swift b/Sources/Nuke/Pipeline/ImagePipeline+Cache.swift index 5637d3550..ab66318c1 100644 --- a/Sources/Nuke/Pipeline/ImagePipeline+Cache.swift +++ b/Sources/Nuke/Pipeline/ImagePipeline+Cache.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -226,7 +226,7 @@ extension ImagePipeline.Cache { guard let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) else { return nil } - return (try? decoder.decode(context))?.container + return (try? decoder.decode(context).get())?.container } private func encodeImage(_ image: ImageContainer, for request: ImageRequest) -> Data? { diff --git a/Sources/Nuke/Pipeline/ImagePipeline+Closures.swift b/Sources/Nuke/Pipeline/ImagePipeline+Closures.swift new file mode 100644 index 000000000..534c255c4 --- /dev/null +++ b/Sources/Nuke/Pipeline/ImagePipeline+Closures.swift @@ -0,0 +1,106 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 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?), ImageTask.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 + // events are called on the callback queue. + guard !task.isCancelled else { return } + switch event { + case .progress(let value): progress?(nil, value) + case .preview(let response): progress?(response, task.currentProgress) + case .finished(let result): completion(result) + } + } + } + } +} diff --git a/Sources/Nuke/Pipeline/ImagePipeline+Configuration.swift b/Sources/Nuke/Pipeline/ImagePipeline+Configuration.swift index 0ce00f09f..10fab491d 100644 --- a/Sources/Nuke/Pipeline/ImagePipeline+Configuration.swift +++ b/Sources/Nuke/Pipeline/ImagePipeline+Configuration.swift @@ -1,12 +1,12 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation extension ImagePipeline { /// The pipeline configuration. - public struct Configuration: @unchecked Sendable { + public struct Configuration: Sendable { // MARK: - Dependencies /// Data loader used by the pipeline. @@ -65,29 +65,10 @@ extension ImagePipeline { /// If you use an aggressive disk cache ``DataCaching``, you can specify /// a cache policy with multiple available options and /// ``ImagePipeline/DataCachePolicy/storeOriginalData`` used by default. - public var dataCachePolicy = ImagePipeline.DataCachePolicy.storeOriginalData + public var dataCachePolicy: ImagePipeline.DataCachePolicy = .storeOriginalData /// `true` by default. If `true` the pipeline avoids duplicated work when - /// loading images. The work only gets cancelled when all the registered - /// requests are. The pipeline also automatically manages the priority of the - /// deduplicated work. - /// - /// Let's take these two requests for example: - /// - /// ```swift - /// let url = URL(string: "http://example.com/image") - /// pipeline.loadImage(with: ImageRequest(url: url, processors: [ - /// .resize(size: CGSize(width: 44, height: 44)), - /// .gaussianBlur(radius: 8) - /// ])) - /// pipeline.loadImage(with: ImageRequest(url: url, processors: [ - /// .resize(size: CGSize(width: 44, height: 44)) - /// ])) - /// ``` - /// - /// Nuke will load the image data only once, resize the image once and - /// apply the blur also only once. There is no duplicated work done at - /// any stage. + /// loading images. It coalesces the identical network and image requests. public var isTaskCoalescingEnabled = true /// `true` by default. If `true` the pipeline will rate limit requests @@ -118,16 +99,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,32 +111,28 @@ extension ImagePipeline { set { _isSignpostLoggingEnabled.value = newValue } } - private static let _isSignpostLoggingEnabled = Atomic(value: false) + private static let _isSignpostLoggingEnabled = Mutex(false) private var isCustomImageCacheProvided = false var debugIsSyncImageEncoding = false - // MARK: - Operation Queues + // MARK: - Work Queues /// 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) + public var dataLoadingQueue = JobQueue(maxConcurrentJobCount: 6) /// Image decoding queue. Default maximum concurrent task count is 1. - public var imageDecodingQueue = OperationQueue(maxConcurrentCount: 1) + public var imageDecodingQueue = JobQueue(maxConcurrentJobCount: 1) /// Image encoding queue. Default maximum concurrent task count is 1. - public var imageEncodingQueue = OperationQueue(maxConcurrentCount: 1) + public var imageEncodingQueue = JobQueue(maxConcurrentJobCount: 1) /// Image processing queue. Default maximum concurrent task count is 2. - public var imageProcessingQueue = OperationQueue(maxConcurrentCount: 2) + public var imageProcessingQueue = JobQueue(maxConcurrentJobCount: 2) /// Image decompressing queue. Default maximum concurrent task count is 2. - public var imageDecompressingQueue = OperationQueue(maxConcurrentCount: 2) + public var imageDecompressingQueue = JobQueue(maxConcurrentJobCount: 2) // MARK: - Initializer diff --git a/Sources/Nuke/Pipeline/ImagePipeline+Delegate.swift b/Sources/Nuke/Pipeline/ImagePipeline+Delegate.swift index 3e6f4d99d..17151349b 100644 --- a/Sources/Nuke/Pipeline/ImagePipeline+Delegate.swift +++ b/Sources/Nuke/Pipeline/ImagePipeline+Delegate.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -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 deleted file mode 100644 index 0e0152a3d..000000000 --- a/Sources/Nuke/Pipeline/ImagePipeline+Error.swift +++ /dev/null @@ -1,65 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import Foundation - -extension ImagePipeline { - /// Represents all possible image pipeline errors. - public enum Error: Swift.Error, CustomStringConvertible, @unchecked 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. - case dataLoadingFailed(error: Swift.Error) - /// Data loader returned empty data. - case dataIsEmpty - /// No decoder registered for the given data. - /// - /// This error can only be thrown if the pipeline has custom decoders. - /// By default, the pipeline uses ``ImageDecoders/Default`` as a catch-all. - case decoderNotRegistered(context: ImageDecodingContext) - /// Decoder failed to produce a final image. - case decodingFailed(decoder: any ImageDecoding, context: ImageDecodingContext, error: Swift.Error) - /// Processor failed to produce a final image. - case processingFailed(processor: any ImageProcessing, context: ImageProcessingContext, error: Swift.Error) - /// Load image method was called with no image request. - case imageRequestMissing - /// Image pipeline is invalidated and no requests can be made. - case pipelineInvalidated - } -} - -extension ImagePipeline.Error { - /// Returns underlying data loading error. - public var dataLoadingError: Swift.Error? { - switch self { - case .dataLoadingFailed(let error): - return error - default: - return nil - } - } - - public var description: String { - switch self { - case .dataMissingInCache: - return "Failed to load data from cache and download is disabled." - case let .dataLoadingFailed(error): - return "Failed to load image data. Underlying error: \(error)." - case .dataIsEmpty: - return "Data loader returned empty data." - case .decoderNotRegistered: - return "No decoders registered for the downloaded data." - case let .decodingFailed(decoder, _, error): - let underlying = error is ImageDecodingError ? "" : " Underlying error: \(error)." - return "Failed to decode image data using decoder \(decoder).\(underlying)" - case let .processingFailed(processor, _, error): - let underlying = error is ImageProcessingError ? "" : " Underlying error: \(error)." - return "Failed to process the image using processor \(processor).\(underlying)" - case .imageRequestMissing: - return "Load image method was called with no image request or no URL." - case .pipelineInvalidated: - return "Image pipeline is invalidated and no requests can be made." - } - } -} diff --git a/Sources/Nuke/Pipeline/ImagePipeline.swift b/Sources/Nuke/Pipeline/ImagePipeline.swift index f25cd07da..7eeb39816 100644 --- a/Sources/Nuke/Pipeline/ImagePipeline.swift +++ b/Sources/Nuke/Pipeline/ImagePipeline.swift @@ -1,9 +1,8 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation -import Combine #if canImport(UIKit) import UIKit @@ -13,53 +12,44 @@ 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]() + private var tasks = Set() - private let tasksLoadData: TaskPool - private let tasksLoadImage: TaskPool - private let tasksFetchOriginalImage: TaskPool - private let tasksFetchOriginalData: TaskPool + private let jobsFetchImage: JobPool + private let jobsFetchData: JobPool - // 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() + @available(*, deprecated, message: "Please use ImageTask.Error") + public typealias Error = ImageTask.Error - ResumableDataStorage.shared.unregister(self) + deinit { + Task { @ImagePipelineActor [id] in + ResumableDataStorage.shared.unregister(id) + } } /// Initializes the instance with the given configuration. @@ -67,22 +57,19 @@ 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 let isCoalescingEnabled = configuration.isTaskCoalescingEnabled - self.tasksLoadData = TaskPool(isCoalescingEnabled) - self.tasksLoadImage = TaskPool(isCoalescingEnabled) - self.tasksFetchOriginalImage = TaskPool(isCoalescingEnabled) - self.tasksFetchOriginalData = TaskPool(isCoalescingEnabled) - - self.lock = .allocate(capacity: 1) - self.lock.initialize(to: os_unfair_lock()) + self.jobsFetchImage = JobPool(isCoalescingEnabled) + self.jobsFetchData = JobPool(isCoalescingEnabled) - ResumableDataStorage.shared.register(self) + Task { @ImagePipelineActor [id] in + 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) @@ -108,332 +95,109 @@ 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 { - guard !self.isInvalidated else { return } - self.isInvalidated = true - self.tasks.keys.forEach(self.cancelImageTask) - } + guard !isInvalidated else { return } + isInvalidated = true + tasks.forEach { $0._cancel() } } - // 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 { - imageTask(with: ImageRequest(url: url)) + /// ## Example + /// ```swift + /// let task = pipeline.imageTask(with: url) + /// do { + /// let image = try await task.image + /// ... + /// } catch ImageTask.Error.cancelled { + /// print("Task was cancelled") + /// } catch { + /// print("Failed to load image: \(error)") + /// } + /// ``` + /// + /// - note: The task starts executing the moment it is created. + public nonisolated func imageTask(with url: URL) -> ImageTask { + makeImageTask(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. - public func image(for url: URL) async throws -> PlatformImage { + @inlinable + public func image(for url: URL) async throws(ImageTask.Error) -> PlatformImage { try await image(for: ImageRequest(url: url)) } /// Returns an image for the given request. - public func image(for request: ImageRequest) async throws -> PlatformImage { + @inlinable + public func image(for request: ImageRequest) async throws(ImageTask.Error) -> PlatformImage { 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) + public func data(for request: ImageRequest) async throws(ImageTask.Error) -> (data: Data, response: URLResponse?) { + 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 perform(_ imageTask: ImageTask) -> JobSubscription? { guard !isInvalidated else { - 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) + return nil } - delegate.imageTaskDidStart(task, pipeline: self) - onTaskStarted?(task) + let job = imageTask.isDataTask ? JobPrefixFetchData(self, imageTask.request) : JobPrefixFetchImage(self, imageTask.request) + tasks.insert(imageTask) + return job.subscribe(imageTask) } - private 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, didProcessEvent event: ImageTask.Event, isDataTask: Bool) { + func imageTask(_ task: ImageTask, didProcessEvent event: ImageTask.Event) { switch event { - case .cancelled, .finished: - tasks[task] = nil - default: break + case .finished: + tasks.remove(task) + default: + break } - - if !isDataTask { + if !task.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) - } - } - } - - // MARK: - Task Factory (Private) - - // When you request an image or image data, the pipeline creates a graph of tasks - // (some tasks are added to the graph on demand). - // - // `loadImage()` call is represented by TaskLoadImage: - // - // TaskLoadImage -> TaskFetchOriginalImage -> TaskFetchOriginalData - // - // `loadData()` call is represented by TaskLoadData: - // - // TaskLoadData -> TaskFetchOriginalData - // - // - // Each task represents a resource or a piece of work required to produce the - // final result. The pipeline reduces the amount of duplicated work by coalescing - // the tasks that represent the same work. For example, if you all `loadImage()` - // and `loadData()` with the same request, only on `TaskFetchOriginalImageData` - // is created. The work is split between tasks to minimize any duplicated work. - - func makeTaskLoadImage(for request: ImageRequest) -> AsyncTask.Publisher { - tasksLoadImage.publisherForKey(TaskLoadImageKey(request)) { - TaskLoadImage(self, request) } } - func makeTaskLoadData(for request: ImageRequest) -> AsyncTask.Publisher { - tasksLoadData.publisherForKey(TaskLoadImageKey(request)) { - TaskLoadData(self, request) + func makeJobFetchImage(for request: ImageRequest) -> Job { + jobsFetchImage.task(for: TaskLoadImageKey(request)) { + JobFetchImage(self, request) } } - func makeTaskFetchOriginalImage(for request: ImageRequest) -> AsyncTask.Publisher { - tasksFetchOriginalImage.publisherForKey(TaskFetchOriginalImageKey(request)) { - TaskFetchOriginalImage(self, request) - } - } - - func makeTaskFetchOriginalData(for request: ImageRequest) -> AsyncTask<(Data, URLResponse?), Error>.Publisher { - tasksFetchOriginalData.publisherForKey(TaskFetchOriginalDataKey(request)) { - request.publisher == nil ? TaskFetchOriginalData(self, request) : TaskFetchWithPublisher(self, request) + func makeJobFetchData(for request: ImageRequest) -> Job<(Data, URLResponse?)> { + jobsFetchData.task(for: TaskFetchOriginalDataKey(request)) { + let job = JobFetchData(self, request) + // TODO: implement skipDataLoadingQueue + // TODO: add separate operation for disk lookup (or no operation at all?) + if request.options.contains(.skipDataLoadingQueue) { + job.startIfNeeded() + } else { + job.queue = configuration.dataLoadingQueue + } + return job } } - - // 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..214f4568c --- /dev/null +++ b/Sources/Nuke/Pipeline/ImagePipelineActor.swift @@ -0,0 +1,13 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 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..37a062e68 100644 --- a/Sources/Nuke/Prefetching/ImagePrefetcher.swift +++ b/Sources/Nuke/Prefetching/ImagePrefetcher.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -11,26 +11,39 @@ 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 { +public final class ImagePrefetcher: Sendable { /// 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 var isPaused: Bool { + get { _isPaused.value } + set { + guard _isPaused.setValue(newValue) else { return } + Task { @ImagePipelineActor in + impl.queue.isSuspended = newValue + } + } } + private let _isPaused = Mutex(false) + /// 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 var priority: ImageRequest.Priority { + get { _priority.value } + set { + guard _priority.setValue(newValue) else { return } + Task { @ImagePipelineActor in + impl.priority = newValue + } } } + private let _priority = Mutex(ImageRequest.Priority.low) + /// Prefetching destination. public enum Destination: Sendable { /// Prefetches the image and stores it in both the memory and the disk @@ -45,18 +58,8 @@ public final class ImagePrefetcher: @unchecked Sendable { case diskCache } - /// 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 + /// An actor-isolated implementation. + let impl: _ImagePrefetcher /// Initializes the ``ImagePrefetcher`` instance. /// @@ -64,23 +67,17 @@ 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) { - self.pipeline = pipeline - self.destination = destination - self.queue.maxConcurrentOperationCount = maxConcurrentRequestCount - self.queue.underlyingQueue = pipeline.queue + public init( + pipeline: ImagePipeline = ImagePipeline.shared, + destination: Destination = .memoryCache, + maxConcurrentRequestCount: Int = 2 + ) { + self.impl = _ImagePrefetcher(pipeline: pipeline, destination: destination, maxConcurrentRequestCount: maxConcurrentRequestCount) } deinit { - let tasks = self.tasks.values // Make sure we don't retain self - self.tasks.removeAll() - - pipeline.queue.async { - for task in tasks { - task.cancel() - } + Task { @ImagePipelineActor [impl] in + impl.stopPrefetching() } } @@ -88,7 +85,9 @@ public final class ImagePrefetcher: @unchecked Sendable { /// /// See also ``startPrefetching(with:)-718dg`` that works with ``ImageRequest``. public func startPrefetching(with urls: [URL]) { - startPrefetching(with: urls.map { ImageRequest(url: $0) }) + Task { @ImagePipelineActor in + for url in urls { impl.startPrefetching(with: url) } + } } /// Starts prefetching images for the given requests. @@ -102,66 +101,19 @@ public final class ImagePrefetcher: @unchecked Sendable { /// /// See also ``startPrefetching(with:)-1jef2`` that works with `URL`. public func startPrefetching(with requests: [ImageRequest]) { - pipeline.queue.async { - self._startPrefetching(with: requests) + Task { @ImagePipelineActor in + for request in requests { impl.startPrefetching(with: request) } } } - public func _startPrefetching(with requests: [ImageRequest]) { - for request in requests { - var request = request - if _priority != request.priority { - request.priority = _priority - } - _startPrefetching(with: request) - } - sendCompletionIfNeeded() - } - - private func _startPrefetching(with request: ImageRequest) { - guard pipeline.cache[request] == nil else { - return - } - let key = TaskLoadImageKey(request) - guard tasks[key] == nil else { - return - } - let task = Task(request: request, key: key) - task.operation = queue.add { [weak self] finish in - guard let self else { return finish() } - 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 - self?._remove(task) - finish() - } - task.onCancelled = finish - } - - private func _remove(_ task: Task) { - 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) - } - /// 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]) { - stopPrefetching(with: urls.map { ImageRequest(url: $0) }) + Task { @ImagePipelineActor in + for url in urls { impl.stopPrefetching(with: url) } + } } /// Stops prefetching images for the given requests and cancels outstanding @@ -173,53 +125,127 @@ public final class ImagePrefetcher: @unchecked Sendable { /// /// See also ``stopPrefetching(with:)-2tcyq`` that works with `URL`. public func stopPrefetching(with requests: [ImageRequest]) { - pipeline.queue.async { - for request in requests { - self._stopPrefetching(with: request) - } + Task { @ImagePipelineActor in + for request in requests { impl.stopPrefetching(with: request) } } } - private func _stopPrefetching(with request: ImageRequest) { - if let task = tasks.removeValue(forKey: TaskLoadImageKey(request)) { - task.cancel() + /// Stops all prefetching tasks. + public func stopPrefetching() { + Task { @ImagePipelineActor in + impl.stopPrefetching() } } +} - /// Stops all prefetching tasks. - public func stopPrefetching() { - pipeline.queue.async { - self.tasks.values.forEach { $0.cancel() } - self.tasks.removeAll() +@ImagePipelineActor +final class _ImagePrefetcher: JobSubscriber { + /// 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. + + let pipeline: ImagePipeline + let destination: ImagePrefetcher.Destination + private var subscriptions = [TaskLoadImageKey: JobSubscription]() + + nonisolated let queue: JobQueue + + var priority: ImageRequest.Priority = .low + + nonisolated init(pipeline: ImagePipeline, destination: ImagePrefetcher.Destination, maxConcurrentRequestCount: Int) { + self.pipeline = pipeline + self.destination = destination + self.queue = JobQueue(maxConcurrentJobCount: maxConcurrentRequestCount) + } + + func startPrefetching(with url: URL) { + startPrefetching(with: ImageRequest(url: url)) + } + + func startPrefetching(with request: ImageRequest) { + var request = request + if priority != request.priority { + request.priority = priority + } + guard pipeline.cache[request] == nil else { return } + + let key = TaskLoadImageKey(request) + guard subscriptions[key] == nil else { return } + + let job = ImagePrefetcherJob(prefetcher: self, request: request) + job.queue = queue + job.onDisposed = { [weak self] in + self?.remove(key) } + subscriptions[key] = job.subscribe(self) + return + } + + private func remove(_ key: TaskLoadImageKey) { + subscriptions[key] = nil + } + + func stopPrefetching(with url: URL) { + stopPrefetching(with: ImageRequest(url: url)) } - private func didUpdatePriority(to priority: ImageRequest.Priority) { - guard _priority != priority else { return } - _priority = priority - for task in tasks.values { - task.imageTask?.priority = priority + func stopPrefetching(with request: ImageRequest) { + if let subscription = subscriptions.removeValue(forKey: TaskLoadImageKey(request)) { + subscription.unsubscribe() } } - private final class Task: @unchecked Sendable { + func stopPrefetching() { + subscriptions.values.forEach { $0.unsubscribe() } + subscriptions.removeAll() + } + + @ImagePipelineActor + private final class PrefetchTask { let key: TaskLoadImageKey - let request: ImageRequest weak var imageTask: ImageTask? - weak var operation: Operation? - var onCancelled: (() -> Void)? + var subscription: JobSubscription? - init(request: ImageRequest, key: TaskLoadImageKey) { - self.request = request + init(key: TaskLoadImageKey) { self.key = key } // When task is cancelled, it is removed from the prefetcher and can // never get cancelled twice. func cancel() { - operation?.cancel() - imageTask?.cancel() - onCancelled?() + subscription?.unsubscribe() } } + + // MARK: JobSubscriber + + // TODO: remove these + func receive(_ event: Job.Event) {} + func addSubscribedTasks(to output: inout [ImageTask]) {} +} + +private final class ImagePrefetcherJob: Job { + private weak let prefetcher: _ImagePrefetcher? + private let request: ImageRequest + private var task: Task? + + init(prefetcher: _ImagePrefetcher, request: ImageRequest) { + self.prefetcher = prefetcher + self.request = request + super.init() + } + + override func start() { + task = Task { @ImagePipelineActor in + if let prefetcher { + let imageTask = prefetcher.pipeline.makeImageTask(with: request, isDataTask: prefetcher.destination == .diskCache) + _ = try? await imageTask.response + } + finish(with: .success(())) + } + } + + override func onCancel() { + task?.cancel() + } } diff --git a/Sources/Nuke/Processing/ImageDecompression.swift b/Sources/Nuke/Processing/ImageDecompression.swift index 2dfade350..0db974b6b 100644 --- a/Sources/Nuke/Processing/ImageDecompression.swift +++ b/Sources/Nuke/Processing/ImageDecompression.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -15,12 +15,8 @@ enum ImageDecompression { // MARK: Managing Decompression State -#if swift(>=5.10) // Safe because it's never mutated. nonisolated(unsafe) static let isDecompressionNeededAK = malloc(1)! -#else - static let isDecompressionNeededAK = malloc(1)! -#endif static func setDecompressionNeeded(_ isDecompressionNeeded: Bool, for image: PlatformImage) { objc_setAssociatedObject(image, isDecompressionNeededAK, isDecompressionNeeded, .OBJC_ASSOCIATION_RETAIN) diff --git a/Sources/Nuke/Processing/ImageProcessing.swift b/Sources/Nuke/Processing/ImageProcessing.swift index 21d3d7caa..d743c8a2c 100644 --- a/Sources/Nuke/Processing/ImageProcessing.swift +++ b/Sources/Nuke/Processing/ImageProcessing.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -68,7 +68,7 @@ extension ImageProcessing { return container } - /// The default impleemntation simply returns `var identifier: String`. + /// The default implementation simply returns `var identifier: String`. public var hashableIdentifier: AnyHashable { identifier } } diff --git a/Sources/Nuke/Processing/ImageProcessingOptions.swift b/Sources/Nuke/Processing/ImageProcessingOptions.swift index 2ace3a688..0c4734af2 100644 --- a/Sources/Nuke/Processing/ImageProcessingOptions.swift +++ b/Sources/Nuke/Processing/ImageProcessingOptions.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation @@ -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+Anonymous.swift b/Sources/Nuke/Processing/ImageProcessors+Anonymous.swift index 105096d83..61607b0a5 100644 --- a/Sources/Nuke/Processing/ImageProcessors+Anonymous.swift +++ b/Sources/Nuke/Processing/ImageProcessors+Anonymous.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/Nuke/Processing/ImageProcessors+Circle.swift b/Sources/Nuke/Processing/ImageProcessors+Circle.swift index cd7905134..9d84036cf 100644 --- a/Sources/Nuke/Processing/ImageProcessors+Circle.swift +++ b/Sources/Nuke/Processing/ImageProcessors+Circle.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/Nuke/Processing/ImageProcessors+Composition.swift b/Sources/Nuke/Processing/ImageProcessors+Composition.swift index 56d7e27a3..6f6453868 100644 --- a/Sources/Nuke/Processing/ImageProcessors+Composition.swift +++ b/Sources/Nuke/Processing/ImageProcessors+Composition.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift b/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift index a199e9120..a1f0861fa 100644 --- a/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift +++ b/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) @@ -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+GaussianBlur.swift b/Sources/Nuke/Processing/ImageProcessors+GaussianBlur.swift index 24c6a559d..82938cc72 100644 --- a/Sources/Nuke/Processing/ImageProcessors+GaussianBlur.swift +++ b/Sources/Nuke/Processing/ImageProcessors+GaussianBlur.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) diff --git a/Sources/Nuke/Processing/ImageProcessors+Resize.swift b/Sources/Nuke/Processing/ImageProcessors+Resize.swift index 984136c2e..7e7fa641e 100644 --- a/Sources/Nuke/Processing/ImageProcessors+Resize.swift +++ b/Sources/Nuke/Processing/ImageProcessors+Resize.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import CoreGraphics @@ -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/Processing/ImageProcessors+RoundedCorners.swift b/Sources/Nuke/Processing/ImageProcessors+RoundedCorners.swift index 1ec97f593..27fdf5be6 100644 --- a/Sources/Nuke/Processing/ImageProcessors+RoundedCorners.swift +++ b/Sources/Nuke/Processing/ImageProcessors+RoundedCorners.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import CoreGraphics diff --git a/Sources/Nuke/Processing/ImageProcessors.swift b/Sources/Nuke/Processing/ImageProcessors.swift index 796a505ac..5b2a2a04c 100644 --- a/Sources/Nuke/Processing/ImageProcessors.swift +++ b/Sources/Nuke/Processing/ImageProcessors.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation diff --git a/Sources/Nuke/Tasks/AsyncPipelineTask.swift b/Sources/Nuke/Tasks/AsyncPipelineTask.swift deleted file mode 100644 index 2865e3bae..000000000 --- a/Sources/Nuke/Tasks/AsyncPipelineTask.swift +++ /dev/null @@ -1,61 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -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 { - let pipeline: ImagePipeline - // A canonical request representing the unit work performed by the task. - let request: ImageRequest - - init(_ pipeline: ImagePipeline, _ request: ImageRequest) { - self.pipeline = pipeline - self.request = request - } -} - -// Returns all image tasks subscribed to the current pipeline task. -// A suboptimal approach just to make the new DiskCachPolicy.automatic work. -protocol ImageTaskSubscribers { - var imageTasks: [ImageTask] { get } -} - -extension ImageTask: ImageTaskSubscribers { - var imageTasks: [ImageTask] { - [self] - } -} - -extension AsyncPipelineTask: ImageTaskSubscribers { - var imageTasks: [ImageTask] { - subscribers.flatMap { subscribers -> [ImageTask] in - (subscribers as? ImageTaskSubscribers)?.imageTasks ?? [] - } - } -} - -extension AsyncPipelineTask { - /// Decodes the data on the dedicated queue and calls the completion - /// on the pipeline's internal queue. - func decode(_ context: ImageDecodingContext, decoder: any ImageDecoding, _ completion: @Sendable @escaping (Result) -> Void) { - @Sendable func decode() -> Result { - signpost(context.isCompleted ? "DecodeImageData" : "DecodeProgressiveImageData") { - Result { try decoder.decode(context) } - .mapError { .decodingFailed(decoder: decoder, context: context, error: $0) } - } - } - guard decoder.isAsynchronous else { - return completion(decode()) - } - operation = pipeline.configuration.imageDecodingQueue.add { [weak self] in - guard let self else { return } - let response = decode() - self.pipeline.queue.async { - completion(response) - } - } - } -} diff --git a/Sources/Nuke/Tasks/AsyncTask.swift b/Sources/Nuke/Tasks/AsyncTask.swift deleted file mode 100644 index e381f51fe..000000000 --- a/Sources/Nuke/Tasks/AsyncTask.swift +++ /dev/null @@ -1,350 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// Represents a task with support for multiple observers, cancellation, -/// progress reporting, dependencies – everything that `ImagePipeline` needs. -/// -/// A `AsyncTask` can have zero or more subscriptions (`TaskSubscription`) which can -/// be used to later unsubscribe or change the priority of the subscription. -/// -/// The task has built-in support for operations (`Foundation.Operation`) – it -/// 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 { - - private struct Subscription { - let closure: (Event) -> Void - weak var subscriber: AnyObject? - var priority: TaskPriority - } - - // In most situations, especially for intermediate tasks, the almost almost - // only one subscription. - private var inlineSubscription: Subscription? - private var subscriptions: [TaskSubscriptionKey: Subscription]? // Create lazily - private var nextSubscriptionKey = 0 - - var subscribers: [AnyObject] { - var output = [AnyObject?]() - output.append(inlineSubscription?.subscriber) - subscriptions?.values.forEach { output.append($0.subscriber) } - return output.compactMap { $0 } - } - - /// Returns `true` if the task was either cancelled, or was completed. - private(set) var isDisposed = false - private var isStarted = false - - /// Gets called when the task is either cancelled, or was completed. - var onDisposed: (() -> Void)? - - var onCancelled: (() -> Void)? - - var priority: TaskPriority = .normal { - didSet { - guard oldValue != priority else { return } - operation?.queuePriority = priority.queuePriority - dependency?.setPriority(priority) - } - } - - /// A task might have a dependency. The task automatically unsubscribes - /// from the dependency when it gets cancelled, and also updates the - /// priority of the subscription to the dependency when its own - /// priority is updated. - var dependency: TaskSubscription? { - didSet { - dependency?.setPriority(priority) - } - } - - weak var operation: Foundation.Operation? { - didSet { - guard priority != .normal else { return } - operation?.queuePriority = priority.queuePriority - } - } - - /// Publishes the results of the task. - var publisher: Publisher { Publisher(task: self) } - - /// Override this to start image task. Only gets called once. - func start() {} - - // MARK: - Managing Observers - - /// - notes: Returns `nil` if the task was disposed. - private func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { - guard !isDisposed else { return nil } - - let subscriptionKey = nextSubscriptionKey - nextSubscriptionKey += 1 - let subscription = TaskSubscription(task: self, key: subscriptionKey) - - if subscriptionKey == 0 { - inlineSubscription = Subscription(closure: closure, subscriber: subscriber, priority: priority) - } else { - if subscriptions == nil { subscriptions = [:] } - subscriptions![subscriptionKey] = Subscription(closure: closure, subscriber: subscriber, priority: priority) - } - - updatePriority(suggestedPriority: priority) - - if !isStarted { - isStarted = true - start() - } - - // The task may have been completed synchronously by `starter`. - guard !isDisposed else { return nil } - return subscription - } - - // MARK: - TaskSubscriptionDelegate - - fileprivate func setPriority(_ priority: TaskPriority, for key: TaskSubscriptionKey) { - guard !isDisposed else { return } - - if key == 0 { - inlineSubscription?.priority = priority - } else { - subscriptions![key]?.priority = priority - } - updatePriority(suggestedPriority: priority) - } - - fileprivate func unsubsribe(key: TaskSubscriptionKey) { - if key == 0 { - guard inlineSubscription != nil else { return } - inlineSubscription = nil - } else { - guard subscriptions!.removeValue(forKey: key) != nil else { return } - } - - guard !isDisposed else { return } - - if inlineSubscription == nil && subscriptions?.isEmpty ?? true { - terminate(reason: .cancelled) - } else { - updatePriority(suggestedPriority: nil) - } - } - - // MARK: - Sending Events - - func send(value: Value, isCompleted: Bool = false) { - send(event: .value(value, isCompleted: isCompleted)) - } - - func send(error: Error) { - send(event: .error(error)) - } - - func send(progress: TaskProgress) { - send(event: .progress(progress)) - } - - private func send(event: Event) { - guard !isDisposed else { return } - - switch event { - case let .value(_, isCompleted): - if isCompleted { - terminate(reason: .finished) - } - case .progress: - break // Simply send the event - case .error: - terminate(reason: .finished) - } - - inlineSubscription?.closure(event) - if let subscriptions { - for subscription in subscriptions.values { - subscription.closure(event) - } - } - } - - // MARK: - Termination - - private enum TerminationReason { - case finished, cancelled - } - - private func terminate(reason: TerminationReason) { - guard !isDisposed else { return } - isDisposed = true - - if reason == .cancelled { - operation?.cancel() - dependency?.unsubscribe() - onCancelled?() - } - onDisposed?() - } - - // MARK: - Priority - - private func updatePriority(suggestedPriority: TaskPriority?) { - if let suggestedPriority, suggestedPriority >= priority { - // No need to recompute, won't go higher than that - priority = suggestedPriority - return - } - - var newPriority = inlineSubscription?.priority - // Same as subscriptions.map { $0?.priority }.max() but without allocating - // any memory for redundant arrays - if let subscriptions { - for subscription in subscriptions.values { - if newPriority == nil { - newPriority = subscription.priority - } else if subscription.priority > newPriority! { - newPriority = subscription.priority - } - } - } - self.priority = newPriority ?? .normal - } -} - -// MARK: - AsyncTask (Publisher) - -extension AsyncTask { - /// Publishes the results of the task. - struct Publisher { - fileprivate let task: AsyncTask - - /// Attaches the subscriber to the task. - /// - notes: Returns `nil` if the task is already disposed. - func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { - task.subscribe(priority: priority, subscriber: subscriber, closure) - } - - /// Attaches the subscriber to the task. Automatically forwards progress - /// and error events to the given task. - /// - notes: Returns `nil` if the task is already disposed. - func subscribe(_ task: AsyncTask, onValue: @escaping (Value, Bool) -> Void) -> TaskSubscription? { - subscribe(subscriber: task) { [weak task] event in - guard let task else { return } - switch event { - case let .value(value, isCompleted): - onValue(value, isCompleted) - case let .progress(progress): - task.send(progress: progress) - case let .error(error): - task.send(error: error) - } - } - } - } -} - -typealias TaskProgress = ImageTask.Progress // Using typealias for simplicity - -enum TaskPriority: Int, Comparable { - case veryLow = 0, low, normal, high, veryHigh - - var queuePriority: Operation.QueuePriority { - switch self { - case .veryLow: return .veryLow - case .low: return .low - case .normal: return .normal - case .high: return .high - case .veryHigh: return .veryHigh - } - } - - static func < (lhs: TaskPriority, rhs: TaskPriority) -> Bool { - lhs.rawValue < rhs.rawValue - } -} - -// MARK: - AsyncTask.Event { -extension AsyncTask { - enum Event { - case value(Value, isCompleted: Bool) - case progress(TaskProgress) - case error(Error) - } -} - -extension AsyncTask.Event: Equatable where Value: Equatable, Error: Equatable {} - -// MARK: - TaskSubscription - -/// Represents a subscription to a task. The observer must retain a strong -/// reference to a subscription. -struct TaskSubscription: Sendable { - private let task: any AsyncTaskSubscriptionDelegate - private let key: TaskSubscriptionKey - - fileprivate init(task: any AsyncTaskSubscriptionDelegate, key: TaskSubscriptionKey) { - self.task = task - self.key = key - } - - /// Removes the subscription from the task. The observer won't receive any - /// more events from the task. - /// - /// If there are no more subscriptions attached to the task, the task gets - /// cancelled along with its dependencies. The cancelled task is - /// marked as disposed. - func unsubscribe() { - task.unsubsribe(key: key) - } - - /// Updates the priority of the subscription. The priority of the task is - /// calculated as the maximum priority out of all of its subscription. When - /// the priority of the task is updated, the priority of a dependency also is. - /// - /// - note: The priority also automatically gets updated when the subscription - /// is removed from the task. - func setPriority(_ priority: TaskPriority) { - task.setPriority(priority, for: key) - } -} - -private protocol AsyncTaskSubscriptionDelegate: AnyObject, Sendable { - func unsubsribe(key: TaskSubscriptionKey) - func setPriority(_ priority: TaskPriority, for observer: TaskSubscriptionKey) -} - -private typealias TaskSubscriptionKey = Int - -// MARK: - TaskPool - -/// Contains the tasks which haven't completed yet. -final class TaskPool { - private let isCoalescingEnabled: Bool - private var map = [Key: AsyncTask]() - - init(_ isCoalescingEnabled: Bool) { - self.isCoalescingEnabled = isCoalescingEnabled - } - - /// Creates a task with the given key. If there is an outstanding task with - /// the given key in the pool, the existing task is returned. Tasks are - /// automatically removed from the pool when they are disposed. - func publisherForKey(_ key: @autoclosure () -> Key, _ make: () -> AsyncTask) -> AsyncTask.Publisher { - guard isCoalescingEnabled else { - return make().publisher - } - let key = key() - if let task = map[key] { - return task.publisher - } - let task = make() - map[key] = task - task.onDisposed = { [weak self] in - self?.map[key] = nil - } - return task.publisher - } -} diff --git a/Sources/Nuke/Tasks/TaskFetchOriginalImage.swift b/Sources/Nuke/Tasks/TaskFetchOriginalImage.swift deleted file mode 100644 index 1f9901de2..000000000 --- a/Sources/Nuke/Tasks/TaskFetchOriginalImage.swift +++ /dev/null @@ -1,69 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// Receives data from ``TaskLoadImageData`` and decodes it as it arrives. -final class TaskFetchOriginalImage: AsyncPipelineTask, @unchecked Sendable { - private var decoder: (any ImageDecoding)? - - override func start() { - dependency = pipeline.makeTaskFetchOriginalData(for: request).subscribe(self) { [weak self] in - self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) - } - } - - /// Receiving data from `TaskFetchOriginalData`. - private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { - guard isCompleted || pipeline.configuration.isProgressiveDecodingEnabled else { - return - } - - if !isCompleted && operation != nil { - return // Back pressure - already decoding another progressive data chunk - } - - if isCompleted { - operation?.cancel() // Cancel any potential pending progressive decoding tasks - } - - let context = ImageDecodingContext(request: request, data: data, isCompleted: isCompleted, urlResponse: urlResponse) - guard let decoder = getDecoder(for: context) else { - if isCompleted { - send(error: .decoderNotRegistered(context: context)) - } else { - // Try again when more data is downloaded. - } - return - } - - decode(context, decoder: decoder) { [weak self] in - self?.didFinishDecoding(context: context, result: $0) - } - } - - private func didFinishDecoding(context: ImageDecodingContext, result: Result) { - operation = nil - - switch result { - case .success(let response): - send(value: response, isCompleted: context.isCompleted) - case .failure(let error): - if context.isCompleted { - send(error: error) - } - } - } - - // Lazily creates decoding for task - private func getDecoder(for context: ImageDecodingContext) -> (any ImageDecoding)? { - // Return the existing processor in case it has already been created. - if let decoder { - return decoder - } - let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) - self.decoder = decoder - return decoder - } -} diff --git a/Sources/Nuke/Tasks/TaskFetchWithPublisher.swift b/Sources/Nuke/Tasks/TaskFetchWithPublisher.swift deleted file mode 100644 index 19faec294..000000000 --- a/Sources/Nuke/Tasks/TaskFetchWithPublisher.swift +++ /dev/null @@ -1,72 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -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 { - private lazy var data = Data() - - override func start() { - if request.options.contains(.skipDataLoadingQueue) { - loadData(finish: { /* do nothing */ }) - } else { - // Wrap data request in an operation to limit the maximum number of - // concurrent data tasks. - operation = pipeline.configuration.dataLoadingQueue.add { [weak self] finish in - guard let self else { - return finish() - } - self.pipeline.queue.async { - self.loadData { finish() } - } - } - } - } - - // This methods gets called inside data loading operation (Operation). - private func loadData(finish: @escaping () -> Void) { - guard !isDisposed else { - return finish() - } - - guard let publisher = request.publisher 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) - } - }) - - 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)) - } - } -} diff --git a/Sources/Nuke/Tasks/TaskLoadData.swift b/Sources/Nuke/Tasks/TaskLoadData.swift deleted file mode 100644 index c571c666e..000000000 --- a/Sources/Nuke/Tasks/TaskLoadData.swift +++ /dev/null @@ -1,36 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// Wrapper for tasks created by `loadData` calls. -final class TaskLoadData: AsyncPipelineTask, @unchecked Sendable { - override func start() { - if let data = pipeline.cache.cachedData(for: request) { - let container = ImageContainer(image: .init(), data: data) - let response = ImageResponse(container: container, request: request) - self.send(value: response, isCompleted: true) - } else { - self.loadData() - } - } - - private func loadData() { - guard !request.options.contains(.returnCacheDataDontLoad) else { - return send(error: .dataMissingInCache) - } - let request = request.withProcessors([]) - dependency = pipeline.makeTaskFetchOriginalData(for: request).subscribe(self) { [weak self] in - self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) - } - } - - private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { - let container = ImageContainer(image: .init(), data: data) - let response = ImageResponse(container: container, request: request, urlResponse: urlResponse) - if isCompleted { - send(value: response, isCompleted: isCompleted) - } - } -} diff --git a/Sources/Nuke/Tasks/TaskLoadImage.swift b/Sources/Nuke/Tasks/TaskLoadImage.swift deleted file mode 100644 index 2f4b6b7e2..000000000 --- a/Sources/Nuke/Tasks/TaskLoadImage.swift +++ /dev/null @@ -1,200 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// Wrapper for tasks created by `loadImage` calls. -/// -/// 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 { - override func start() { - if let container = pipeline.cache[request] { - let response = ImageResponse(container: container, request: request, cacheType: .memory) - send(value: response, isCompleted: !container.isPreview) - if !container.isPreview { - return // The final image is loaded - } - } - if let data = pipeline.cache.cachedData(for: request) { - decodeCachedData(data) - } else { - fetchImage() - } - } - - private func decodeCachedData(_ data: Data) { - let context = ImageDecodingContext(request: request, data: data, cacheType: .disk) - 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()) - } - } - - private func didFinishDecoding(with response: ImageResponse?) { - if let response { - didReceiveImageResponse(response, isCompleted: true) - } else { - fetchImage() - } - } - - // MARK: Fetch Image - - private func fetchImage() { - guard !request.options.contains(.returnCacheDataDontLoad) else { - return send(error: .dataMissingInCache) - } - if let processor = request.processors.last { - let request = request.withProcessors(request.processors.dropLast()) - dependency = pipeline.makeTaskLoadImage(for: request).subscribe(self) { [weak self] in - self?.process($0, isCompleted: $1, processor: processor) - } - } else { - dependency = pipeline.makeTaskFetchOriginalImage(for: request).subscribe(self) { [weak self] in - self?.didReceiveImageResponse($0, isCompleted: $1) - } - } - } - - // MARK: Processing - - private func process(_ response: ImageResponse, isCompleted: Bool, processor: any ImageProcessing) { - guard !isDisposed else { return } - if isCompleted { - operation?.cancel() // Cancel any potential pending progressive - } else if operation != nil { - return // Back pressure - already processing another progressive image - } - let context = ImageProcessingContext(request: request, response: response, isCompleted: isCompleted) - operation = pipeline.configuration.imageProcessingQueue.add { [weak self] in - guard let self else { return } - let result = signpost(isCompleted ? "ProcessImage" : "ProcessProgressiveImage") { - Result { - var response = response - response.container = try processor.process(response.container, context: context) - return response - }.mapError { error in - ImagePipeline.Error.processingFailed(processor: processor, context: context, error: error) - } - } - self.pipeline.queue.async { - self.operation = nil - self.didFinishProcessing(result: result, isCompleted: isCompleted) - } - } - } - - private func didFinishProcessing(result: Result, isCompleted: Bool) { - switch result { - case .success(let response): - didReceiveImageResponse(response, isCompleted: isCompleted) - case .failure(let error): - if isCompleted { - send(error: error) - } - } - } - - // MARK: Decompression - - private func didReceiveImageResponse(_ response: ImageResponse, isCompleted: Bool) { - guard isDecompressionNeeded(for: response) else { - return didReceiveDecompressedImage(response, isCompleted: isCompleted) - } - guard !isDisposed else { return } - if isCompleted { - operation?.cancel() // Cancel any potential pending progressive decompression tasks - } else if operation != nil { - return // Back-pressure: receiving progressive scans too fast - } - operation = pipeline.configuration.imageDecompressingQueue.add { [weak self] in - guard let self else { return } - let response = signpost(isCompleted ? "DecompressImage" : "DecompressProgressiveImage") { - self.pipeline.delegate.decompress(response: response, request: self.request, pipeline: self.pipeline) - } - self.pipeline.queue.async { - self.operation = nil - self.didReceiveDecompressedImage(response, isCompleted: isCompleted) - } - } - } - - private func isDecompressionNeeded(for response: ImageResponse) -> Bool { - ImageDecompression.isDecompressionNeeded(for: response) && - !request.options.contains(.skipDecompression) && - hasDirectSubscribers && - pipeline.delegate.shouldDecompress(response: response, for: request, pipeline: pipeline) - } - - private func didReceiveDecompressedImage(_ response: ImageResponse, isCompleted: Bool) { - storeImageInCaches(response) - send(value: response, isCompleted: isCompleted) - } - - // MARK: Caching - - private func storeImageInCaches(_ response: ImageResponse) { - guard hasDirectSubscribers else { - return - } - pipeline.cache[request] = response.container - if shouldStoreResponseInDataCache(response) { - storeImageInDataCache(response) - } - } - - private func storeImageInDataCache(_ response: ImageResponse) { - guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline) else { - return - } - let context = ImageEncodingContext(request: request, image: response.image, urlResponse: response.urlResponse) - let encoder = pipeline.delegate.imageEncoder(for: context, pipeline: pipeline) - let key = pipeline.cache.makeDataCacheKey(for: request) - pipeline.configuration.imageEncodingQueue.addOperation { [weak pipeline, request] in - guard let pipeline else { return } - let encodedData = signpost("EncodeImage") { - encoder.encode(response.container, context: context) - } - guard let data = encodedData, !data.isEmpty else { return } - pipeline.delegate.willCache(data: data, image: response.container, for: request, pipeline: pipeline) { - guard let data = $0, !data.isEmpty else { return } - // Important! Storing directly ignoring `ImageRequest.Options`. - dataCache.storeData(data, for: key) // This is instant, writes are async - } - } - if pipeline.configuration.debugIsSyncImageEncoding { // Only for debug - pipeline.configuration.imageEncodingQueue.waitUntilAllOperationsAreFinished() - } - } - - private func shouldStoreResponseInDataCache(_ response: ImageResponse) -> Bool { - guard !response.container.isPreview, - !(response.cacheType == .disk), - !(request.url?.isLocalResource ?? false) else { - return false - } - let isProcessed = !request.processors.isEmpty || request.thumbnail != nil - switch pipeline.configuration.dataCachePolicy { - case .automatic: - return isProcessed - case .storeOriginalData: - return false - case .storeEncodedImages: - return true - case .storeAll: - return isProcessed - } - } - - /// Returns `true` if the task has at least one image task that was directly - /// subscribed to it, which means that the request was initiated by the - /// user and not the framework. - private var hasDirectSubscribers: Bool { - subscribers.contains { $0 is ImageTask } - } -} diff --git a/Sources/NukeExtensions/ImageLoadingOptions.swift b/Sources/NukeExtensions/ImageLoadingOptions.swift index fa5420696..9f2c52300 100644 --- a/Sources/NukeExtensions/ImageLoadingOptions.swift +++ b/Sources/NukeExtensions/ImageLoadingOptions.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke diff --git a/Sources/Nuke/Internal/ImagePublisher.swift b/Sources/NukeExtensions/ImagePipeline+Combine.swift similarity index 79% rename from Sources/Nuke/Internal/ImagePublisher.swift rename to Sources/NukeExtensions/ImagePipeline+Combine.swift index 985fbc992..3f53520e5 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-2026 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. /// @@ -16,7 +29,7 @@ import Combine /// might emit more than a single value. struct ImagePublisher: Publisher, Sendable { typealias Output = ImageResponse - typealias Failure = ImagePipeline.Error + typealias Failure = ImageTask.Error let request: ImageRequest let pipeline: ImagePipeline @@ -31,7 +44,7 @@ struct ImagePublisher: Publisher, Sendable { } } -private final class ImageSubscription: Subscription where S: Subscriber, S: Sendable, S.Input == ImageResponse, S.Failure == ImagePipeline.Error { +private final class ImageSubscription: Subscription where S: Subscriber, S: Sendable, S.Input == ImageResponse, S.Failure == ImageTask.Error { private var task: ImageTask? private let subscriber: S? private let request: ImageRequest diff --git a/Sources/NukeExtensions/ImageViewExtensions.swift b/Sources/NukeExtensions/ImageViewExtensions.swift index 82bd8d7ea..b921ee3ae 100644 --- a/Sources/NukeExtensions/ImageViewExtensions.swift +++ b/Sources/NukeExtensions/ImageViewExtensions.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke @@ -15,7 +15,7 @@ import AppKit.NSImage #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) /// Displays images. Add the conformance to this protocol to your views to make -/// them compatible with Nuke image loading extensions. +/// Then compatible with Nuke image loading extensions. /// /// The protocol is defined as `@objc` to make it possible to override its /// methods in extensions (e.g. you can override `nuke_display(image:data:)` in @@ -91,7 +91,7 @@ extension TVPosterView: Nuke_ImageDisplaying { with url: URL?, options: ImageLoadingOptions? = nil, into view: ImageDisplayingView, - completion: @escaping (_ result: Result) -> Void + completion: @escaping (_ result: Result) -> Void ) -> ImageTask? { loadImage(with: url, options: options, into: view, progress: nil, completion: completion) } @@ -125,7 +125,7 @@ extension TVPosterView: Nuke_ImageDisplaying { options: ImageLoadingOptions? = nil, into view: ImageDisplayingView, progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? = nil, - completion: ((_ result: Result) -> Void)? = nil + completion: ((_ result: Result) -> Void)? = nil ) -> ImageTask? { let controller = ImageViewController.controller(for: view) return controller.loadImage(with: url.map({ ImageRequest(url: $0) }), options: options ?? .shared, progress: progress, completion: completion) @@ -139,7 +139,7 @@ extension TVPosterView: Nuke_ImageDisplaying { with request: ImageRequest?, options: ImageLoadingOptions? = nil, into view: ImageDisplayingView, - completion: @escaping (_ result: Result) -> Void + completion: @escaping (_ result: Result) -> Void ) -> ImageTask? { loadImage(with: request, options: options ?? .shared, into: view, progress: nil, completion: completion) } @@ -173,12 +173,46 @@ extension TVPosterView: Nuke_ImageDisplaying { options: ImageLoadingOptions? = nil, into view: ImageDisplayingView, progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? = nil, - completion: ((_ result: Result) -> Void)? = nil + completion: ((_ result: Result) -> Void)? = nil ) -> ImageTask? { let controller = ImageViewController.controller(for: view) return controller.loadImage(with: request, options: options ?? .shared, progress: progress, completion: completion) } +/// Loads an image with the given request and displays it in the view. +/// +/// - note: For more information, see ``loadImage(with:options:into:progress:completion:)-37z3t.`` +@MainActor +public func loadImage( + with url: URL?, + options: ImageLoadingOptions? = nil, + into view: ImageDisplayingView +) async throws(ImageTask.Error) { + let request = url.map { ImageRequest(url: $0) } + try await loadImage(with: request, options: options, into: view) +} + +/// Loads an image with the given request and displays it in the view. +/// +/// - note: For more information, see ``loadImage(with:options:into:progress:completion:)-37z3t.`` +@MainActor +public func loadImage( + with request: ImageRequest?, + options: ImageLoadingOptions? = nil, + into view: ImageDisplayingView +) async throws(ImageTask.Error) { + do { + _ = try await withUnsafeThrowingContinuation { continuation in + loadImage(with: request, options: options, into: view) { + continuation.resume(with: $0) + } + } + } catch { + // swiftlint:disable:next force_cast + throw error as! ImageTask.Error + } +} + /// Cancels an outstanding request associated with the view. @MainActor public func cancelRequest(for view: ImageDisplayingView) { @@ -216,12 +250,8 @@ private final class ImageViewController { // MARK: - Associating Controller -#if swift(>=5.10) // Safe because it's never mutated. nonisolated(unsafe) static let controllerAK = malloc(1)! -#else - static let controllerAK = malloc(1)! -#endif // Lazily create a controller for a given view and associate it with a view. static func controller(for view: ImageDisplayingView) -> ImageViewController { @@ -239,7 +269,7 @@ private final class ImageViewController { with request: ImageRequest?, options: ImageLoadingOptions, progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? = nil, - completion: ((_ result: Result) -> Void)? = nil + completion: ((_ result: Result) -> Void)? = nil ) -> ImageTask? { cancelOutstandingTask() @@ -263,7 +293,7 @@ private final class ImageViewController { if options.isPrepareForReuseEnabled { imageView.nuke_display(image: nil, data: nil) } - let result: Result = .failure(.imageRequestMissing) + let result: Result = .failure(.imageRequestMissing) handle(result: result, isFromMemory: true) completion?(result) return nil @@ -290,7 +320,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) } @@ -309,7 +339,7 @@ private final class ImageViewController { // MARK: - Handling Responses - private func handle(result: Result, isFromMemory: Bool) { + private func handle(result: Result, isFromMemory: Bool) { switch result { case let .success(response): display(response.container, isFromMemory, .success) @@ -418,11 +448,9 @@ extension ImageViewController { transitionView.frame = imageView.frame transitionView.tintColor = imageView.tintColor transitionView.tintAdjustmentMode = imageView.tintAdjustmentMode -#if swift(>=5.9) if #available(iOS 17.0, tvOS 17.0, *) { transitionView.preferredImageDynamicRange = imageView.preferredImageDynamicRange } -#endif transitionView.preferredSymbolConfiguration = imageView.preferredSymbolConfiguration transitionView.isHidden = imageView.isHidden transitionView.clipsToBounds = imageView.clipsToBounds diff --git a/Sources/NukeUI/FetchImage.swift b/Sources/NukeUI/FetchImage.swift index e0fc9025c..19c0f120d 100644 --- a/Sources/NukeUI/FetchImage.swift +++ b/Sources/NukeUI/FetchImage.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import SwiftUI import Combine @@ -100,12 +100,10 @@ public final class FetchImage: ObservableObject, Identifiable { /// Loads an image with the given request. public func load(_ request: ImageRequest?) { - assert(Thread.isMainThread, "Must be called from the main thread") - reset() guard var request else { - handle(result: .failure(ImagePipeline.Error.imageRequestMissing)) + handle(result: .failure(ImageTask.Error.imageRequestMissing)) return } @@ -194,11 +192,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/Internal.swift b/Sources/NukeUI/Internal.swift index 8d5b51fd8..44787eccc 100644 --- a/Sources/NukeUI/Internal.swift +++ b/Sources/NukeUI/Internal.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke diff --git a/Sources/NukeUI/LazyImage.swift b/Sources/NukeUI/LazyImage.swift index 1a5e68763..e11951230 100644 --- a/Sources/NukeUI/LazyImage.swift +++ b/Sources/NukeUI/LazyImage.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke @@ -16,7 +16,6 @@ public typealias ImageRequest = Nuke.ImageRequest /// can take advantage of all of its features, such as caching, prefetching, /// task coalescing, smart background decompression, request priorities, and more. @MainActor -@available(iOS 14.0, tvOS 14.0, watchOS 7.0, macOS 10.16, *) public struct LazyImage: View { @StateObject private var viewModel = FetchImage() @@ -190,7 +189,6 @@ private struct LazyImageContext: Equatable { } #if DEBUG -@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) struct LazyImage_Previews: PreviewProvider { static var previews: some View { Group { @@ -207,7 +205,6 @@ struct LazyImage_Previews: PreviewProvider { } // This demonstrates that the view reacts correctly to the URL changes. -@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) private struct LazyImageDemoView: View { @State var url = URL(string: "https://kean.blog/images/pulse/01.png") @State var isBlured = false diff --git a/Sources/NukeUI/LazyImageState.swift b/Sources/NukeUI/LazyImageState.swift index a30b04f8e..96c91bc79 100644 --- a/Sources/NukeUI/LazyImageState.swift +++ b/Sources/NukeUI/LazyImageState.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke diff --git a/Sources/NukeUI/LazyImageView.swift b/Sources/NukeUI/LazyImageView.swift index 5bc7fcff5..1c94a5779 100644 --- a/Sources/NukeUI/LazyImageView.swift +++ b/Sources/NukeUI/LazyImageView.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke @@ -255,8 +255,6 @@ public final class LazyImageView: _PlatformBaseView { /// Loads an image with the given request. private func load(_ request: ImageRequest?) { - assert(Thread.isMainThread, "Must be called from the main thread") - cancel() if isResetEnabled { @@ -266,7 +264,7 @@ public final class LazyImageView: _PlatformBaseView { } guard var request else { - handle(result: .failure(ImagePipeline.Error.imageRequestMissing), isSync: true) + handle(result: .failure(ImageTask.Error.imageRequestMissing), isSync: true) return } @@ -290,22 +288,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/AVDataAsset.swift b/Sources/NukeVideo/AVDataAsset.swift index 4e2f20ffa..2fe2c2e40 100644 --- a/Sources/NukeVideo/AVDataAsset.swift +++ b/Sources/NukeVideo/AVDataAsset.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import AVKit import Foundation diff --git a/Sources/NukeVideo/ImageDecoders+Video.swift b/Sources/NukeVideo/ImageDecoders+Video.swift index 2b0a22bfa..371a9c2f3 100644 --- a/Sources/NukeVideo/ImageDecoders+Video.swift +++ b/Sources/NukeVideo/ImageDecoders+Video.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). #if !os(watchOS) && !os(visionOS) diff --git a/Sources/NukeVideo/VideoPlayerView.swift b/Sources/NukeVideo/VideoPlayerView.swift index 9120033ec..8bab4e41d 100644 --- a/Sources/NukeVideo/VideoPlayerView.swift +++ b/Sources/NukeVideo/VideoPlayerView.swift @@ -1,13 +1,8 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 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/Extensions/AsyncExpectation+Extensions.swift b/Tests/Helpers/Extensions/AsyncExpectation+Extensions.swift new file mode 100644 index 000000000..b020a98e9 --- /dev/null +++ b/Tests/Helpers/Extensions/AsyncExpectation+Extensions.swift @@ -0,0 +1,109 @@ +import Foundation +import Combine +import Testing + +@testable import Nuke + +extension AsyncExpectation where Value == Void { + convenience init(notification: Notification.Name, object: AnyObject) { + self.init() + + NotificationCenter.default + .publisher(for: notification, object: object) + .sink { [weak self] _ in self?.fulfill() } + .store(in: &cancellables) + } +} + +func expect(notification: Notification.Name, object : AnyObject) -> AsyncExpectation { + AsyncExpectation(notification: notification, object: object) +} + +extension Publisher where Output: Sendable { + func expectToPublishValue() -> AsyncExpectation { + let expectation = AsyncExpectation() + sink(receiveCompletion: { _ in + // Do nothing + }, receiveValue: { + expectation.fulfill(with: $0) + }).store(in: &expectation.cancellables) + return expectation + } + + // Record values until the publisher completes. + func record(count: Int? = nil) -> AsyncExpectation<[Output]> { + let expectation = AsyncExpectation<[Output]>() + var output: [Output] = [] + sink(receiveCompletion: { result in + switch result { + case .finished: + if count == nil { + expectation.fulfill(with: output) + } + case .failure(let failure): + Issue.record(failure, "Unexpected failure") + } + }, receiveValue: { + output.append($0) + if let count, output.count == count { + expectation.fulfill(with: output) + } + }).store(in: &expectation.cancellables) + return expectation + } +} + +extension JobQueue { + func expectJobAdded() -> AsyncExpectation { + let expectation = AsyncExpectation() + onEvent = { event in + if case .added(let value) = event { + expectation.fulfill(with: value) + } + } + return expectation + } + + func expectJobsAdded(count: Int) -> AsyncExpectation<[JobHandle]> { + let expectation = AsyncExpectation<[JobHandle]>() + var operations: [JobHandle] = [] + onEvent = { event in + if case .added(let item) = event { + operations.append(item) + if operations.count == count { + expectation.fulfill(with: operations) + } else if operations.count > count { + Issue.record("Unexpectedly received more than \(count) items") + } + } + } + return expectation + } + + func expectPriorityUpdated(for job: JobHandle) -> AsyncExpectation { + let expectation = AsyncExpectation() + onEvent = { event in + if case let .priorityUpdated(value, priority) = event { + if value === job { + expectation.fulfill(with: priority) + } + } + } + return expectation + } + + func expectJobCancelled(_ job: JobHandle) -> AsyncExpectation { + let expectation = AsyncExpectation() + onEvent = { event in + if case .cancelled(let value) = event { + if value === job { + expectation.fulfill() + } + } + } + return expectation + } +} + +// Just no. +extension JobQueue.JobHandle: @retroactive @unchecked Sendable {} diff --git a/Tests/Helpers/Extensions/AsyncExpectation.swift b/Tests/Helpers/Extensions/AsyncExpectation.swift new file mode 100644 index 000000000..0f74fcc0b --- /dev/null +++ b/Tests/Helpers/Extensions/AsyncExpectation.swift @@ -0,0 +1,74 @@ +import Foundation +import Combine +import Testing + +final class AsyncExpectation: @unchecked Sendable { + private var state = Mutex(wrappedValue: State()) + + var cancellables: [AnyCancellable] = [] + + private struct State { + var value: Value? + var continuation: UnsafeContinuation? + var isInvalidated = false + var count = 1 + } + + var value: Value { + get async { + await wait() + } + } + + @discardableResult + func wait() async -> Value { + await withUnsafeContinuation { continuation in + let value = state.withLock { + if $0.value == nil { + $0.continuation = continuation + } + return $0.value + } + if let value { + continuation.resume(returning: value) + } + } + } + + func invalidate() { + state.withLock { + $0.isInvalidated = true + } + } + + func fulfill(with value: Value) { + let continuation: UnsafeContinuation? = state.withLock { + guard !$0.isInvalidated else { + return nil + } + $0.count -= 1 + guard $0.count == 0 else { + return nil + } + #expect($0.value == nil, "fulfill called multiple times") + $0.value = value + let continuation = $0.continuation + $0.continuation = nil + return continuation + } + continuation?.resume(returning: value) + } +} + +extension AsyncExpectation where Value == Void { + convenience init(expectedFulfillmentCount: Int) { + self.init() + self.state.withLock { + $0.count = expectedFulfillmentCount + } + } + + func fulfill() { + fulfill(with: ()) + } +} diff --git a/Tests/CombineExtensions.swift b/Tests/Helpers/Extensions/CombineExtensions.swift similarity index 96% rename from Tests/CombineExtensions.swift rename to Tests/Helpers/Extensions/CombineExtensions.swift index 99ef50517..7a207e289 100644 --- a/Tests/CombineExtensions.swift +++ b/Tests/Helpers/Extensions/CombineExtensions.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Nuke import Combine diff --git a/Tests/Helpers.swift b/Tests/Helpers/Extensions/Helpers.swift similarity index 85% rename from Tests/Helpers.swift rename to Tests/Helpers/Extensions/Helpers.swift index bc341248e..aa9502a95 100644 --- a/Tests/Helpers.swift +++ b/Tests/Helpers/Extensions/Helpers.swift @@ -1,10 +1,18 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Nuke import XCTest +#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) +import UIKit +#endif + +#if os(macOS) +import Cocoa +#endif + private final class BundleToken {} // Test data. @@ -169,7 +177,7 @@ extension Result { } } -@propertyWrapper final class Atomic { +@propertyWrapper final class Mutex: @unchecked Sendable { private var value: T private let lock: os_unfair_lock_t @@ -189,7 +197,7 @@ extension Result { set { setValue(newValue) } } - private func getValue() -> T { + private func getValue() -> T { os_unfair_lock_lock(lock) defer { os_unfair_lock_unlock(lock) } return value @@ -200,4 +208,23 @@ extension Result { defer { os_unfair_lock_unlock(lock) } value = newValue } + + func withLock(_ closure: (inout T) -> U) -> U { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return closure(&value) + } +} + +func isEqual(_ lhs: PlatformImage, _ rhs: PlatformImage) -> Bool { + guard lhs.sizeInPixels == rhs.sizeInPixels else { + return false + } + // Note: this will probably need more work. + let encoder = ImageEncoders.ImageIO(type: .png, compressionRatio: 1) + return encoder.encode(lhs) == encoder.encode(rhs) +} + +func rnd(_ uniform: Int) -> Int { + return Int.random(in: 0 ..< uniform) } diff --git a/Tests/NukeExtensions.swift b/Tests/Helpers/Extensions/NukeExtensions.swift similarity index 73% rename from Tests/NukeExtensions.swift rename to Tests/Helpers/Extensions/NukeExtensions.swift index b449836b5..5437f8969 100644 --- a/Tests/NukeExtensions.swift +++ b/Tests/Helpers/Extensions/NukeExtensions.swift @@ -1,12 +1,12 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke -extension ImagePipeline.Error: Equatable { - public static func == (lhs: ImagePipeline.Error, rhs: ImagePipeline.Error) -> Bool { +extension ImageTask.Error: @retroactive Equatable { + public static func == (lhs: ImageTask.Error, rhs: ImageTask.Error) -> Bool { switch (lhs, rhs) { case (.dataMissingInCache, .dataMissingInCache): return true case let (.dataLoadingFailed(lhs), .dataLoadingFailed(rhs)): @@ -17,38 +17,26 @@ extension ImagePipeline.Error: Equatable { case (.processingFailed, .processingFailed): return true case (.imageRequestMissing, .imageRequestMissing): return true case (.pipelineInvalidated, .pipelineInvalidated): return true + case (.cancelled, .cancelled): return true default: return false } } } -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) } } -extension ImagePipeline { - private static var stack = [ImagePipeline]() - - static func pushShared(_ shared: ImagePipeline) { - stack.append(ImagePipeline.shared) - ImagePipeline.shared = shared - } - - static func popShared() { - ImagePipeline.shared = stack.removeLast() - } -} - extension ImageProcessing { /// A throwing version of a regular method. func processThrowing(_ image: PlatformImage) throws -> PlatformImage { diff --git a/Tests/Helpers/Mocks/ImagePipelineObserver.swift b/Tests/Helpers/Mocks/ImagePipelineObserver.swift new file mode 100644 index 000000000..2442600ad --- /dev/null +++ b/Tests/Helpers/Mocks/ImagePipelineObserver.swift @@ -0,0 +1,62 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import XCTest +import Nuke + +final class ImagePipelineObserver: ImagePipeline.Delegate, @unchecked Sendable { + var createdTaskCount = 0 + var cancelledTaskCount = 0 + var completedTaskCount = 0 + + 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 = [ImageTask.Event]() + + private let lock = NSLock() + + private func append(_ event: ImageTask.Event) { + lock.lock() + events.append(event) + lock.unlock() + } + + func imageTaskCreated(_ task: ImageTask, pipeline: ImagePipeline) { + 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): + if case .failure(.cancelled) = result { + cancelledTaskCount += 1 + NotificationCenter.default.post(name: ImagePipelineObserver.didCancelTask, object: self, userInfo: [ImagePipelineObserver.taskKey: task]) + } else { + completedTaskCount += 1 + NotificationCenter.default.post(name: ImagePipelineObserver.didCompleteTask, object: self, userInfo: [ImagePipelineObserver.taskKey: task, ImagePipelineObserver.resultKey: result]) + } + default: + break + } + } +} + +extension ImageTask.Event: @retroactive Equatable { + public static func == (lhs: ImageTask.Event, rhs: ImageTask.Event) -> Bool { + switch (lhs, rhs) { + case let (.progress(lhs), .progress(rhs)): lhs == rhs + case let (.preview(lhs), .preview(rhs)): lhs == rhs + case let (.finished(lhs), .finished(rhs)): lhs == rhs + default: false + } + } +} diff --git a/Tests/MockDataCache.swift b/Tests/Helpers/Mocks/MockDataCache.swift similarity index 91% rename from Tests/MockDataCache.swift rename to Tests/Helpers/Mocks/MockDataCache.swift index 9260a6f99..96673e1af 100644 --- a/Tests/MockDataCache.swift +++ b/Tests/Helpers/Mocks/MockDataCache.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke diff --git a/Tests/MockDataLoader.swift b/Tests/Helpers/Mocks/MockDataLoader.swift similarity index 55% rename from Tests/MockDataLoader.swift rename to Tests/Helpers/Mocks/MockDataLoader.swift index 272acce19..a7d4b8380 100644 --- a/Tests/MockDataLoader.swift +++ b/Tests/Helpers/Mocks/MockDataLoader.swift @@ -1,24 +1,24 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation 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/MockImageCache.swift b/Tests/Helpers/Mocks/MockImageCache.swift similarity index 91% rename from Tests/MockImageCache.swift rename to Tests/Helpers/Mocks/MockImageCache.swift index d981abd98..7b1243563 100644 --- a/Tests/MockImageCache.swift +++ b/Tests/Helpers/Mocks/MockImageCache.swift @@ -1,9 +1,9 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation -@testable import Nuke +import Nuke class MockImageCache: ImageCaching, @unchecked Sendable { let queue = DispatchQueue(label: "com.github.Nuke.MockCache") diff --git a/Tests/MockImageDecoder.swift b/Tests/Helpers/Mocks/MockImageDecoder.swift similarity index 95% rename from Tests/MockImageDecoder.swift rename to Tests/Helpers/Mocks/MockImageDecoder.swift index 7db3c4cf8..f5865da1d 100644 --- a/Tests/MockImageDecoder.swift +++ b/Tests/Helpers/Mocks/MockImageDecoder.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke diff --git a/Tests/MockImageEncoder.swift b/Tests/Helpers/Mocks/MockImageEncoder.swift similarity index 84% rename from Tests/MockImageEncoder.swift rename to Tests/Helpers/Mocks/MockImageEncoder.swift index 3492aa8ee..a1177cac6 100644 --- a/Tests/MockImageEncoder.swift +++ b/Tests/Helpers/Mocks/MockImageEncoder.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke diff --git a/Tests/MockImageProcessor.swift b/Tests/Helpers/Mocks/MockImageProcessor.swift similarity index 89% rename from Tests/MockImageProcessor.swift rename to Tests/Helpers/Mocks/MockImageProcessor.swift index 0e203d608..8b14bbe4d 100644 --- a/Tests/MockImageProcessor.swift +++ b/Tests/Helpers/Mocks/MockImageProcessor.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke @@ -17,12 +17,8 @@ extension PlatformImage { } private enum AssociatedKeys { -#if swift(>=5.10) // Safe because it's never mutated. nonisolated(unsafe) static let processorId = malloc(1)! -#else - static let processorId = malloc(1)! -#endif } // MARK: - MockImageProcessor @@ -87,9 +83,8 @@ final class MockEmptyImageProcessor: ImageProcessing { // MARK: - MockProcessorFactory /// Counts number of applied processors -final class MockProcessorFactory { - var numberOfProcessorsApplied: Int = 0 - let lock = NSLock() +final class MockProcessorFactory: @unchecked Sendable { + @Mutex var numberOfProcessorsApplied = 0 private final class Processor: ImageProcessing, @unchecked Sendable { var identifier: String { processor.identifier } @@ -101,9 +96,7 @@ final class MockProcessorFactory { } func process(_ image: PlatformImage) -> PlatformImage? { - factory.lock.lock() factory.numberOfProcessorsApplied += 1 - factory.lock.unlock() return processor.process(image) } } diff --git a/Tests/MockProgressiveDataLoader.swift b/Tests/Helpers/Mocks/MockProgressiveDataLoader.swift similarity index 77% rename from Tests/MockProgressiveDataLoader.swift rename to Tests/Helpers/Mocks/MockProgressiveDataLoader.swift index 84ff40dfe..83665d4b4 100644 --- a/Tests/MockProgressiveDataLoader.swift +++ b/Tests/Helpers/Mocks/MockProgressiveDataLoader.swift @@ -1,18 +1,18 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation 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() @@ -49,7 +49,7 @@ final class MockProgressiveDataLoader: DataLoading, @unchecked Sendable { } // Serves the next chunk. - func resume(_ completed: @escaping () -> Void = {}) { + func resume(_ completed: @Sendable @escaping () -> Void = {}) { DispatchQueue.main.async { if let chunk = self.chunks.first { self.chunks.removeFirst() diff --git a/Tests/Host/AppDelegate.swift b/Tests/Host/AppDelegate.swift index af778739a..ee40f6b71 100644 --- a/Tests/Host/AppDelegate.swift +++ b/Tests/Host/AppDelegate.swift @@ -1,11 +1,22 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import UIKit -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? } + +class ViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + // Do any additional setup after loading the view, typically from a nib. + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } +} diff --git a/Tests/Host/ViewController.swift b/Tests/Host/ViewController.swift deleted file mode 100644 index b455e303f..000000000 --- a/Tests/Host/ViewController.swift +++ /dev/null @@ -1,17 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import UIKit - -class ViewController: UIViewController { - override func viewDidLoad() { - super.viewDidLoad() - // Do any additional setup after loading the view, typically from a nib. - } - - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. - } -} diff --git a/Tests/ImagePipelineObserver.swift b/Tests/ImagePipelineObserver.swift deleted file mode 100644 index c619c59b8..000000000 --- a/Tests/ImagePipelineObserver.swift +++ /dev/null @@ -1,86 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -@testable import Nuke - -final class ImagePipelineObserver: ImagePipelineDelegate, @unchecked Sendable { - var startedTaskCount = 0 - var cancelledTaskCount = 0 - var completedTaskCount = 0 - - static let didStartTask = Notification.Name("com.github.kean.Nuke.Tests.ImagePipelineObserver.DidStartTask") - 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)? - - private let lock = NSLock() - - private func append(_ event: ImageTaskEvent) { - 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]) - } -} - -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 { - 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 - } - } -} diff --git a/Tests/NukeExtensionsTests/ImagePipelinePublisherProgressiveDecodingTests.swift b/Tests/NukeExtensionsTests/ImagePipelinePublisherProgressiveDecodingTests.swift new file mode 100644 index 000000000..da27c552b --- /dev/null +++ b/Tests/NukeExtensionsTests/ImagePipelinePublisherProgressiveDecodingTests.swift @@ -0,0 +1,110 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing +import Combine + +@testable import Nuke + +@MainActor +@Suite class ImagePipelinePublisherProgressiveDecodingTests { + private var dataLoader: MockProgressiveDataLoader! + private var imageCache: MockImageCache! + private var pipeline: ImagePipeline! + private var cancellable: AnyCancellable? + + init() { + dataLoader = MockProgressiveDataLoader() + + imageCache = MockImageCache() + ResumableDataStorage.shared.removeAllResponses() + + pipeline = ImagePipeline { + $0.dataLoader = dataLoader + $0.imageCache = imageCache + $0.isResumableDataEnabled = false + $0.isProgressiveDecodingEnabled = true + $0.isStoringPreviewsInMemoryCache = true + } + } + + @Test func imagePreviewsAreDelivered() async throws { + let expectation = AsyncExpectation() + var output: [ImageResponse] = [] + + // When + cancellable = pipeline.imagePublisher(with: Test.url).sink(receiveCompletion: { completion in + switch completion { + case .failure: + Issue.record() + case .finished: + expectation.fulfill() + } + + }, receiveValue: { response in + output.append(response) + self.dataLoader.resume() + }) + + await expectation.wait() + + // Then + guard output.count == 3 else { + Issue.record() + return + } + + #expect(output[0].isPreview == true) + #expect(output[1].isPreview == true) + #expect(output[2].isPreview == false) + + } + + @Test func imagePreviewsAreDeliveredFromMemoryCacheSynchronously() async { + // Given + pipeline.cache[Test.request] = ImageContainer(image: Test.image, isPreview: true) + + let expectation = AsyncExpectation() + var isFirstPreviewProduced = false + var output: [ImageResponse] = [] + + // When + let publisher = pipeline.imagePublisher(with: Test.url) + cancellable = publisher.sink(receiveCompletion: { completion in + switch completion { + case .failure: + Issue.record() + case .finished: + expectation.fulfill() + } + + }, receiveValue: { response in + isFirstPreviewProduced = true + output.append(response) + self.dataLoader.resume() + }) + + // Then first preview is delived synchronously + #expect(isFirstPreviewProduced) + + await expectation.wait() + + // Then + guard output.count == 4 else { + Issue.record() + return + } + + // 1 preview from sync cache lookup + // 1 preview from async cache lookup (we don't want it really though) + // 2 previews from data loading + // 1 final image + // we also expect resumable data to kick in for real downloads + #expect(output[0].isPreview == true) + #expect(output[1].isPreview == true) + #expect(output[2].isPreview == true) + #expect(output[3].isPreview == false) + } +} diff --git a/Tests/NukeExtensionsTests/ImagePipelinePublisherTests.swift b/Tests/NukeExtensionsTests/ImagePipelinePublisherTests.swift new file mode 100644 index 000000000..a24aa8117 --- /dev/null +++ b/Tests/NukeExtensionsTests/ImagePipelinePublisherTests.swift @@ -0,0 +1,54 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing +import Combine + +@testable import Nuke + +@MainActor +@Suite struct ImagePipelinePublisherTests { + var dataLoader: MockDataLoader! + var imageCache: MockImageCache! + var dataCache: MockDataCache! + var observer: ImagePipelineObserver! + var pipeline: ImagePipeline! + + init() { + dataLoader = MockDataLoader() + imageCache = MockImageCache() + dataCache = MockDataCache() + observer = ImagePipelineObserver() + pipeline = ImagePipeline(delegate: observer) { + $0.dataLoader = dataLoader + $0.imageCache = imageCache + $0.dataCache = dataCache + } + } + + @Test func cancellation() async { + // Given + dataLoader.isSuspended = true + + // When + let cancellable = pipeline + .imagePublisher(with: Test.request) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + + let expectation = AsyncExpectation(notification: ImagePipelineObserver.didCancelTask, object: observer) + cancellable.cancel() + + // Then + await expectation.wait() + } + + @Test func initWithURL() { + _ = pipeline.imagePublisher(with: URL(string: "https://example.com/image.jpeg")!) + } + + @Test func initWithImageRequest() { + _ = pipeline.imagePublisher(with: ImageRequest(url: URL(string: "https://example.com/image.jpeg"))) + } +} diff --git a/Tests/NukeTests/ImagePublisherTests.swift b/Tests/NukeExtensionsTests/ImagePublisherTests.swift similarity index 65% rename from Tests/NukeTests/ImagePublisherTests.swift rename to Tests/NukeExtensionsTests/ImagePublisherTests.swift index bbebddd9c..59f2b56c9 100644 --- a/Tests/NukeTests/ImagePublisherTests.swift +++ b/Tests/NukeExtensionsTests/ImagePublisherTests.swift @@ -1,19 +1,19 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest -@testable import Nuke +import Testing +import Foundation import Combine -class ImagePublisherTests: XCTestCase { +@testable import Nuke + +@Suite class ImagePublisherTests { var dataLoader: MockDataLoader! var pipeline: ImagePipeline! var cancellable: AnyCancellable? - override func setUp() { - super.setUp() - + init() { dataLoader = MockDataLoader() pipeline = ImagePipeline { $0.dataLoader = dataLoader @@ -23,15 +23,15 @@ class ImagePublisherTests: XCTestCase { // MARK: Common Use Cases - func testLowDataMode() { - // GIVEN + @Test func lowDataMode() async throws { + // Given let highQualityImageURL = URL(string: "https://example.com/high-quality-image.jpeg")! let lowQualityImageURL = URL(string: "https://example.com/low-quality-image.jpeg")! dataLoader.results[highQualityImageURL] = .failure(URLError(networkUnavailableReason: .constrained) as NSError) dataLoader.results[lowQualityImageURL] = .success((Test.data, Test.urlResponse)) - // WHEN + // When let pipeline = self.pipeline! // Create the default request to fetch the high quality image. @@ -39,66 +39,65 @@ class ImagePublisherTests: XCTestCase { urlRequest.allowsConstrainedNetworkAccess = false let request = ImageRequest(urlRequest: urlRequest) - // WHEN - let publisher = pipeline.imagePublisher(with: request).tryCatch { error -> AnyPublisher in + // When + let publisher = pipeline.imagePublisher(with: request).tryCatch { error -> AnyPublisher in guard (error.dataLoadingError as? URLError)?.networkUnavailableReason == .constrained else { throw error } return pipeline.imagePublisher(with: lowQualityImageURL) } - let expectation = self.expectation(description: "LowDataImageFetched") - cancellable = publisher.sink(receiveCompletion: { result in - switch result { - case .finished: - break // Expected result - case .failure: - XCTFail() - } - }, receiveValue: { - XCTAssertNotNil($0.image) - expectation.fulfill() - }) - wait() + try await withUnsafeThrowingContinuation { continuation in + cancellable = publisher.sink(receiveCompletion: { result in + switch result { + case .finished: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + }, receiveValue: { + #expect($0.image != nil) + }) + } } // MARK: Basics - func testSyncCacheLookup() { - // GIVEN + @Test func syncCacheLookup() { + // Given let cache = MockImageCache() cache[Test.request] = ImageContainer(image: Test.image) pipeline = pipeline.reconfigured { $0.imageCache = cache } - // WHEN + // When var image: PlatformImage? cancellable = pipeline.imagePublisher(with: Test.url).sink(receiveCompletion: { result in switch result { case .finished: break // Expected result case .failure: - XCTFail() + Issue.record() } }, receiveValue: { image = $0.image }) - // THEN image returned synchronously - XCTAssertNotNil(image) + // Then image returned synchronously + #expect(image != nil) } - func testCancellation() { + @Test func cancellation() async { dataLoader.queue.isSuspended = true - expectNotification(MockDataLoader.DidStartTask, object: dataLoader) + let expectation1 = AsyncExpectation(notification: MockDataLoader.DidStartTask, object: dataLoader) let cancellable = pipeline.imagePublisher(with: Test.url).sink(receiveCompletion: { _ in }, receiveValue: { _ in }) - wait() // Wait till operation is created + await expectation1.wait() // Wait till operation is created - expectNotification(MockDataLoader.DidCancelTask, object: dataLoader) + let expectation2 = AsyncExpectation(notification: MockDataLoader.DidCancelTask, object: dataLoader) cancellable.cancel() - wait() + await expectation2.wait() } } diff --git a/Tests/NukeExtensionsTests/ImageViewExtensionsProgressiveDecodingTests.swift b/Tests/NukeExtensionsTests/ImageViewExtensionsProgressiveDecodingTests.swift index 8c2f75790..3c186ab23 100644 --- a/Tests/NukeExtensionsTests/ImageViewExtensionsProgressiveDecodingTests.swift +++ b/Tests/NukeExtensionsTests/ImageViewExtensionsProgressiveDecodingTests.swift @@ -1,20 +1,21 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation -import XCTest @testable import Nuke @testable import NukeExtensions -class ImagePipelineProgressiveDecodingTests: XCTestCase { +@ImagePipelineActor +@Suite struct ImagePipelineProgressiveDecodingTests { private var dataLoader: MockProgressiveDataLoader! private var pipeline: ImagePipeline! private var cache: MockImageCache! private var processorsFactory: MockProcessorFactory! - override func setUp() { - super.setUp() - + init() { dataLoader = MockProgressiveDataLoader() ResumableDataStorage.shared.removeAllResponses() @@ -43,73 +44,71 @@ class ImagePipelineProgressiveDecodingTests: XCTestCase { #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) @MainActor - func testParitalImagesAreDisplayed() { + @Test func paritalImagesAreDisplayed() async { // Given ImagePipeline.pushShared(pipeline) let imageView = _ImageView() - - let expectPartialImageProduced = self.expectation(description: "Partial Image Produced") - // We expect two partial images (at 5 scans, and 9 scans marks). - expectPartialImageProduced.expectedFulfillmentCount = 2 - - let expectedFinalLoaded = self.expectation(description: "Final Image Produced") - - // When/Then - NukeExtensions.loadImage( - with: Test.request, - into: imageView, - progress: { response, _, _ in - if let image = response?.image { - XCTAssertTrue(imageView.image === image) - expectPartialImageProduced.fulfill() - self.dataLoader.resume() + var previewCount = 0 + + // When + await withUnsafeContinuation { continuation in + NukeExtensions.loadImage( + with: Test.request, + into: imageView, + progress: { response, _, _ in + if let image = response?.image { + previewCount += 1 + #expect(imageView.image === image) + self.dataLoader.resume() + } + }, + completion: { result in + #expect(imageView.image === result.value?.image) + continuation.resume() } - }, - completion: { result in - XCTAssertTrue(imageView.image === result.value?.image) - expectedFinalLoaded.fulfill() - } - ) - wait() + ) + } + + // Then we expect two partial images (at 5 scans, and 9 scans marks). + #expect(previewCount == 2) ImagePipeline.popShared() } @MainActor - func testDisablingProgressiveRendering() { + @Test func disablingProgressiveRendering() async { // Given ImagePipeline.pushShared(pipeline) let imageView = _ImageView() + var previewCount = 0 var options = ImageLoadingOptions() options.isProgressiveRenderingEnabled = false - let expectPartialImageProduced = self.expectation(description: "Partial Image Produced") - // We expect two partial images (at 5 scans, and 9 scans marks). - expectPartialImageProduced.expectedFulfillmentCount = 2 - - let expectedFinalLoaded = self.expectation(description: "Final Image Produced") - - // When/Then - NukeExtensions.loadImage( - with: Test.request, - options: options, - into: imageView, - progress: { response, _, _ in - if response?.image != nil { - XCTAssertNil(imageView.image) - expectPartialImageProduced.fulfill() - self.dataLoader.resume() + // When + await withUnsafeContinuation { continuation in + NukeExtensions.loadImage( + with: Test.request, + options: options, + into: imageView, + progress: { response, _, _ in + if response?.image != nil { + #expect(imageView.image == nil) + previewCount += 1 + self.dataLoader.resume() + } + }, + completion: { result in + #expect(imageView.image === result.value?.image) + continuation.resume() } - }, - completion: { result in - XCTAssertTrue(imageView.image === result.value?.image) - expectedFinalLoaded.fulfill() - } - ) - wait() + ) + } + + // Then we expect two partial images (at 5 scans, and 9 scans marks). + #expect(previewCount == 2) ImagePipeline.popShared() } diff --git a/Tests/NukeExtensionsTests/ImageViewExtensionsTests.swift b/Tests/NukeExtensionsTests/ImageViewExtensionsTests.swift index 40b671de2..7d58b88eb 100644 --- a/Tests/NukeExtensionsTests/ImageViewExtensionsTests.swift +++ b/Tests/NukeExtensionsTests/ImageViewExtensionsTests.swift @@ -1,27 +1,29 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation -import XCTest #if os(tvOS) import TVUIKit #endif + @testable import Nuke @testable import NukeExtensions #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) -class ImageViewExtensionsTests: XCTestCase { +@MainActor +@Suite class ImageViewExtensionsTests { var imageView: _ImageView! var observer: ImagePipelineObserver! + var options = ImageLoadingOptions() var imageCache: MockImageCache! var dataLoader: MockDataLoader! var pipeline: ImagePipeline! - - @MainActor - override func setUp() { - super.setUp() - + + init() { imageCache = MockImageCache() dataLoader = MockDataLoader() observer = ImagePipelineObserver() @@ -29,295 +31,266 @@ class ImageViewExtensionsTests: XCTestCase { $0.dataLoader = dataLoader $0.imageCache = imageCache } - - // Nuke.loadImage(...) methods use shared pipeline by default. - ImagePipeline.pushShared(pipeline) - + + options.pipeline = pipeline + imageView = _ImageView() } - - override func tearDown() { - super.tearDown() - - ImagePipeline.popShared() - } - + // MARK: - Loading - @MainActor - func testImageLoaded() { - // When requesting an image with request - expectToLoadImage(with: Test.request, into: imageView) - wait() - + @Test func imageLoaded() async throws { + // When + try await loadImage(with: Test.request, options: options, into: imageView) + // Expect the image to be downloaded and displayed - XCTAssertNotNil(imageView.image) + #expect(imageView.image != nil) } - + #if os(tvOS) - @MainActor - func testImageLoadedToTVPosterView() { + @Test func imageLoadedToTVPosterView() async throws { // Use local instance for this tvOS specific test for simplicity let posterView = TVPosterView() - + // When requesting an image with request - expectToLoadImage(with: Test.request, into: posterView) - wait() - + try await loadImage(with: Test.request, options: options, into: posterView) + // Expect the image to be downloaded and displayed - XCTAssertNotNil(posterView.image) + #expect(posterView.image != nil) } #endif - - @MainActor - func testImageLoadedWithURL() { + + @Test func imageLoadedWithURL() async throws { // When requesting an image with URL - let expectation = self.expectation(description: "Image loaded") - NukeExtensions.loadImage(with: Test.url, into: imageView) { _ in - expectation.fulfill() - } - wait() - + try await loadImage(with: Test.url, options: options, into: imageView) + // Expect the image to be downloaded and displayed - XCTAssertNotNil(imageView.image) + #expect(imageView.image != nil) } - - @MainActor - func testLoadImageWithNilRequest() { - // WHEN + + @Test func loadImageWithNilRequest() async throws { + // When imageView.image = Test.image - - let expectation = self.expectation(description: "Image loaded") + let request: ImageRequest? = nil - NukeExtensions.loadImage(with: request, into: imageView) { - XCTAssertEqual($0.error, .imageRequestMissing) - expectation.fulfill() + do { + try await loadImage(with: request, options: options, into: imageView) + Issue.record() + } catch { + #expect(error == .imageRequestMissing) } - wait() - - // THEN - XCTAssertNil(imageView.image) + + // Then + #expect(imageView.image == nil) } - - @MainActor - func testLoadImageWithNilRequestAndPlaceholder() { - // GIVEN + + @Test func loadImageWithNilRequestAndPlaceholder() async throws { + // Given let failureImage = Test.image - - // WHEN - let options = ImageLoadingOptions(failureImage: failureImage) + options.failureImage = failureImage + + // When let request: ImageRequest? = nil - NukeExtensions.loadImage(with: request, options: options, into: imageView) - - // THEN failure image is displayed - XCTAssertTrue(imageView.image === failureImage) + do { + try await loadImage(with: request, options: options, into: imageView) + Issue.record() + } catch { + #expect(error == .imageRequestMissing) + } + + // Then failure image is displayed + #expect(imageView.image === failureImage) } - - // MARK: - Managing Tasks - - @MainActor - func testTaskReturned() { + +// // MARK: - Managing Tasks +// + @Test func taskReturned() { // When requesting an image - let task = NukeExtensions.loadImage(with: Test.request, into: imageView) - + let task = NukeExtensions.loadImage(with: Test.request, options: options, into: imageView) + // Expect Nuke to return a task - XCTAssertNotNil(task) - + #expect(task != nil) + // Expect the task's request to be equivalent to the one provided - XCTAssertEqual(task?.request.urlRequest, Test.request.urlRequest) + #expect(task?.request.urlRequest == Test.request.urlRequest) } - - @MainActor - func testTaskIsNilWhenImageInMemoryCache() { + + @Test func taskIsNilWhenImageInMemoryCache() { // When the requested image is stored in memory cache let request = Test.request imageCache[request] = ImageContainer(image: PlatformImage()) - + // When requesting an image - let task = NukeExtensions.loadImage(with: request, into: imageView) - + let task = NukeExtensions.loadImage(with: request, options: options, into: imageView) + // Expect Nuke to not return any tasks - XCTAssertNil(task) + #expect(task == nil) } - + // MARK: - Prepare For Reuse - - @MainActor - func testViewPreparedForReuse() { + + @Test func viewPreparedForReuse() { // Given an image view displaying an image imageView.image = Test.image - + // When requesting the new image - NukeExtensions.loadImage(with: Test.request, into: imageView) - + NukeExtensions.loadImage(with: Test.request, options: options, into: imageView) + // Then - XCTAssertNil(imageView.image) + #expect(imageView.image == nil) } - - @MainActor - func testViewPreparedForReuseDisabled() { + + @Test func viewPreparedForReuseDisabled() { // Given an image view displaying an image let image = Test.image imageView.image = image - + // When requesting the new image with prepare for reuse disabled - var options = ImageLoadingOptions() options.isPrepareForReuseEnabled = false NukeExtensions.loadImage(with: Test.request, options: options, into: imageView) - + // Expect the original image to still be displayed - XCTAssertEqual(imageView.image, image) + #expect(imageView.image == image) } - + // MARK: - Memory Cache - - @MainActor - func testMemoryCacheUsed() { + + @Test func memoryCacheUsed() { // Given the requested image stored in memory cache let image = Test.image imageCache[Test.request] = ImageContainer(image: image) - + // When requesting the new image - NukeExtensions.loadImage(with: Test.request, into: imageView) - + NukeExtensions.loadImage(with: Test.request, options: options, into: imageView) + // Expect image to be displayed immediately - XCTAssertEqual(imageView.image, image) + #expect(imageView.image == image) } - - @MainActor - func testMemoryCacheDisabled() { + + @Test func memoryCacheDisabled() { // Given the requested image stored in memory cache imageCache[Test.request] = Test.container - + // When requesting the image with memory cache read disabled var request = Test.request request.options.insert(.disableMemoryCacheReads) - NukeExtensions.loadImage(with: request, into: imageView) - + NukeExtensions.loadImage(with: request, options: options, into: imageView) + // Expect image to not be displayed, loaded asyncrounously instead - XCTAssertNil(imageView.image) + #expect(imageView.image == nil) } - + // MARK: - Completion and Progress Closures - - @MainActor - func testCompletionCalled() { + + @Test func completionCalled() async { + // When var didCallCompletion = false - let expectation = self.expectation(description: "Image loaded") + let expectation = AsyncExpectation() + NukeExtensions.loadImage( with: Test.request, + options: options, into: imageView, completion: { result in // Expect completion to be called on the main thread - XCTAssertTrue(Thread.isMainThread) - XCTAssertTrue(result.isSuccess) + #expect(Thread.isMainThread) + #expect(result.isSuccess) didCallCompletion = true expectation.fulfill() } ) - - // Expect completion to be called asynchronously - XCTAssertFalse(didCallCompletion) - wait() + + // Then expect completion to be called asynchronously + #expect(!didCallCompletion) + await expectation.wait() } - - @MainActor - func testCompletionCalledImageFromCache() { - // GIVEN the requested image stored in memory cache + + @Test func completionCalledImageFromCache() { + // Given the requested image stored in memory cache imageCache[Test.request] = Test.container - + var didCallCompletion = false NukeExtensions.loadImage( with: Test.request, + options: options, into: imageView, completion: { result in // Expect completion to be called synchronously on the main thread - XCTAssertTrue(Thread.isMainThread) - XCTAssertTrue(result.isSuccess) + #expect(Thread.isMainThread) + #expect(result.isSuccess) didCallCompletion = true } ) - XCTAssertTrue(didCallCompletion) + #expect(didCallCompletion) } - - @MainActor - func testProgressHandlerCalled() { - // GIVEN + + @Test func progressHandlerCalled() async { + // Given dataLoader.results[Test.url] = .success( (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) ) - - let expectedProgress = expectProgress([(10, 20), (20, 20)]) - - // WHEN loading an image into a view - NukeExtensions.loadImage( - with: Test.request, - into: imageView, - progress: { _, completed, total in - // Expect progress to be reported, on the main thread - XCTAssertTrue(Thread.isMainThread) - expectedProgress.received((completed, total)) - } - ) - - wait() + + var progress: [(Int64, Int64)] = [] + + // When loading an image into a view + _ = await withUnsafeContinuation { continuation in + NukeExtensions.loadImage( + with: Test.request, + options: options, + into: imageView, + progress: { _, completed, total in + // Expect progress to be reported, on the main thread + #expect(Thread.isMainThread) + progress.append((completed, total)) + }, completion: { _ in + continuation.resume() + } + ) + } + + // Then + #expect(progress.map(\.0) == [10, 20]) + #expect(progress.map(\.1) == [20, 20]) } - + // MARK: - Cancellation - + @MainActor - func testRequestCancelled() { + @Test func requestCancelled() async { dataLoader.isSuspended = true - + // Given an image view with an associated image task - expectNotification(ImagePipelineObserver.didStartTask, object: observer) - NukeExtensions.loadImage(with: Test.url, into: imageView) - wait() - + let expectation1 = expect(notification: ImagePipelineObserver.didCreateTask, object: observer) + Task { + try? await loadImage(with: Test.url, options: options, into: imageView) + } + await expectation1.wait() + // Expect the task to get cancelled - expectNotification(ImagePipelineObserver.didCancelTask, object: observer) - // When asking Nuke to cancel the request for the view - NukeExtensions.cancelRequest(for: imageView) - wait() + let expectation2 = expect(notification: ImagePipelineObserver.didCancelTask, object: observer) + cancelRequest(for: imageView) + await expectation2.wait() } - - @MainActor - func testRequestCancelledWhenNewRequestStarted() { + + @Test func requestCancelledWhenNewRequestStarted() async { dataLoader.isSuspended = true - + // Given an image view with an associated image task - expectNotification(ImagePipelineObserver.didStartTask, object: observer) - NukeExtensions.loadImage(with: Test.url, into: imageView) - wait() - + let expectation1 = expect(notification: ImagePipelineObserver.didCreateTask, object: observer) + Task { @MainActor in + try? await loadImage(with: Test.url, options: options, into: imageView) + } + await expectation1.wait() + expectation1.invalidate() + // When starting loading a new image // Expect previous task to get cancelled - expectNotification(ImagePipelineObserver.didCancelTask, object: observer) - NukeExtensions.loadImage(with: Test.url, into: imageView) - wait() - } - - @MainActor - 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 - var imageView: _ImageView! = _ImageView() - expectNotification(ImagePipelineObserver.didStartTask, object: observer) - NukeExtensions.loadImage(with: Test.url, into: imageView) - wait() - - // Expect the task to be cancelled automatically - expectNotification(ImagePipelineObserver.didCancelTask, object: observer) - - // When the view is deallocated - imageView = nil + let expectation2 = expect(notification: ImagePipelineObserver.didCancelTask, object: observer) + Task { @MainActor in + try? await loadImage(with: Test.url, options: options, into: imageView) } - wait() + await expectation2.wait() } } diff --git a/Tests/NukeExtensionsTests/ImageViewIntegrationTests.swift b/Tests/NukeExtensionsTests/ImageViewIntegrationTests.swift deleted file mode 100644 index 38b64892d..000000000 --- a/Tests/NukeExtensionsTests/ImageViewIntegrationTests.swift +++ /dev/null @@ -1,161 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -@testable import Nuke -@testable import NukeExtensions - -#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) - -class ImageViewIntegrationTests: XCTestCase { - var imageView: _ImageView! - var pipeline: ImagePipeline! - - @MainActor - override func setUp() { - super.setUp() - - pipeline = ImagePipeline { - $0.dataLoader = DataLoader() - $0.imageCache = MockImageCache() - } - - // Nuke.loadImage(...) methods use shared pipeline by default. - ImagePipeline.pushShared(pipeline) - - imageView = _ImageView() - } - - override func tearDown() { - super.tearDown() - - ImagePipeline.popShared() - } - - var url: URL { - Test.url(forResource: "fixture", extension: "jpeg") - } - - var request: ImageRequest { - ImageRequest(url: url) - } - - // MARK: - Loading - - @MainActor - func testImageLoaded() { - // When - expectToLoadImage(with: request, into: imageView) - wait() - - // Then - XCTAssertNotNil(imageView.image) - } - - @MainActor - func testImageLoadedWithURL() { - // When - let expectation = self.expectation(description: "Image loaded") - NukeExtensions.loadImage(with: url, into: imageView) { _ in - expectation.fulfill() - } - wait() - - // Then - XCTAssertNotNil(imageView.image) - } - - // MARK: - Loading with Invalid URL - - @MainActor - func testLoadImageWithInvalidURLString() { - // WHEN - let expectation = self.expectation(description: "Image loaded") - NukeExtensions.loadImage(with: URL(string: ""), into: imageView) { result in - XCTAssertEqual(result.error, .imageRequestMissing) - expectation.fulfill() - } - wait() - - // THEN - XCTAssertNil(imageView.image) - } - - @MainActor - func testLoadingWithNilURL() { - // GIVEN - var urlRequest = URLRequest(url: Test.url) - urlRequest.url = nil // Not sure why this is even possible - - // WHEN - let expectation = self.expectation(description: "Image loaded") - NukeExtensions.loadImage(with: ImageRequest(urlRequest: urlRequest), into: imageView) { result in - // THEN - XCTAssertNotNil(result.error?.dataLoadingError) - expectation.fulfill() - } - wait() - - // THEN - XCTAssertNil(imageView.image) - } - - func testLoadingWithRequestWithNilURL() { - // GIVEN - let input = ImageRequest(url: nil) - - // WHEN/THEN - let expectation = self.expectation(description: "ImageLoaded") - pipeline.loadImage(with: input) { - XCTAssertTrue($0.isFailure) - XCTAssertNoThrow($0.error?.dataLoadingError) - expectation.fulfill() - } - wait() - } - - // MARK: - Data Passed - -#if os(iOS) || os(visionOS) - private final class MockView: UIView, Nuke_ImageDisplaying { - func nuke_display(image: PlatformImage?, data: Data?) { - recordedData.append(data) - } - - var recordedData = [Data?]() - } - - @MainActor - func _testThatAttachedDataIsPassed() throws { - // GIVEN - pipeline = pipeline.reconfigured { - $0.makeImageDecoder = { _ in - ImageDecoders.Empty() - } - } - - let imageView = MockView() - - var options = ImageLoadingOptions() - options.pipeline = pipeline - options.isPrepareForReuseEnabled = false - - // WHEN - let expectation = self.expectation(description: "Image loaded") - NukeExtensions.loadImage(with: Test.url, options: options, into: imageView) { result in - XCTAssertNotNil(result.value) - XCTAssertNotNil(result.value?.container.data) - expectation.fulfill() - } - wait() - - // THEN - let data = try XCTUnwrap(imageView.recordedData.first) - XCTAssertNotNil(data) - } - -#endif -} - -#endif diff --git a/Tests/NukeExtensionsTests/ImageViewLoadingOptionsTests.swift b/Tests/NukeExtensionsTests/ImageViewLoadingOptionsTests.swift index 2cce4be80..eb78d9999 100644 --- a/Tests/NukeExtensionsTests/ImageViewLoadingOptionsTests.swift +++ b/Tests/NukeExtensionsTests/ImageViewLoadingOptionsTests.swift @@ -1,351 +1,293 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation -import XCTest @testable import Nuke @testable import NukeExtensions #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) -class ImageViewLoadingOptionsTests: XCTestCase { +@MainActor +@Suite class ImageViewLoadingOptionsTests { var mockCache: MockImageCache! var dataLoader: MockDataLoader! var imageView: _ImageView! - - @MainActor - override func setUp() { - super.setUp() - + var options = ImageLoadingOptions() + + init() { mockCache = MockImageCache() dataLoader = MockDataLoader() let pipeline = ImagePipeline { $0.dataLoader = dataLoader $0.imageCache = mockCache } - // Nuke.loadImage(...) methods use shared pipeline by default. - ImagePipeline.pushShared(pipeline) - + options.pipeline = pipeline + imageView = _ImageView() } - - override func tearDown() { - super.tearDown() - - ImagePipeline.popShared() - } - + // MARK: - Transition - - @MainActor - func testCustomTransitionPerformed() { + + @Test func customTransitionPerformed() async throws { // Given - var options = ImageLoadingOptions() - - let expectTransition = self.expectation(description: "") + let expectTransition = AsyncExpectation() options.transition = .custom({ (view, image) in // Then - XCTAssertEqual(view, self.imageView) - XCTAssertNil(self.imageView.image) // Image isn't displayed automatically. - XCTAssertEqual(view, self.imageView) + #expect(view == self.imageView) + #expect(self.imageView.image == nil) // Image isn't displayed automatically. // Image isn't displayed automatically. + #expect(view == self.imageView) self.imageView.image = image expectTransition.fulfill() }) - + // When - expectToLoadImage(with: Test.request, options: options, into: imageView) - wait() + try await loadImage(with: Test.request, options: options, into: imageView) + await expectTransition.wait() } - + // Tests https://github.com/kean/Nuke/issues/206 - @MainActor - func testImageIsDisplayedFadeInTransition() { + @Test func imageIsDisplayedFadeInTransition() async throws { // Given options with .fadeIn transition - let options = ImageLoadingOptions(transition: .fadeIn(duration: 10)) - + options.transition = .fadeIn(duration: 10) + // When loading an image into an image view - expectToLoadImage(with: Test.request, options: options, into: imageView) - wait() - + try await loadImage(with: Test.request, options: options, into: imageView) + // Then image is actually displayed - XCTAssertNotNil(imageView.image) + #expect(imageView.image != nil) } - + // MARK: - Placeholder - - @MainActor - func testPlaceholderDisplayed() { + + @Test func placeholderDisplayed() { // Given - var options = ImageLoadingOptions() let placeholder = PlatformImage() options.placeholder = placeholder - + // When - NukeExtensions.loadImage(with: Test.request, options: options, into: imageView) - + loadImage(with: Test.request, options: options, into: imageView) + // Then - XCTAssertEqual(imageView.image, placeholder) + #expect(imageView.image == placeholder) } - + // MARK: - Failure Image - - @MainActor - func testFailureImageDisplayed() { + + @Test func failureImageDisplayed() async throws { // Given dataLoader.results[Test.url] = .failure( NSError(domain: "ErrorDomain", code: 42, userInfo: nil) ) - - var options = ImageLoadingOptions() + let failureImage = PlatformImage() options.failureImage = failureImage - + // When - expectToFinishLoadingImage(with: Test.request, options: options, into: imageView) - wait() - + try? await loadImage(with: Test.request, options: options, into: imageView) + // Then - XCTAssertEqual(imageView.image, failureImage) + #expect(imageView.image == failureImage) } - @MainActor - func testFailureImageTransitionRun() { + @Test func failureImageTransitionRun() async throws { // Given dataLoader.results[Test.url] = .failure( NSError(domain: "t", code: 42, userInfo: nil) ) - - var options = ImageLoadingOptions() + let failureImage = PlatformImage() options.failureImage = failureImage - + // Given - let expectTransition = self.expectation(description: "") + let expectTransition = AsyncExpectation() options.failureImageTransition = .custom({ (view, image) in // Then - XCTAssertEqual(view, self.imageView) - XCTAssertEqual(image, failureImage) + #expect(view == self.imageView) + #expect(image == failureImage) self.imageView.image = image expectTransition.fulfill() }) - + // When - expectToFinishLoadingImage(with: Test.request, options: options, into: imageView) - wait() - + try? await loadImage(with: Test.request, options: options, into: imageView) + _ = await expectTransition.wait() + // Then - XCTAssertEqual(imageView.image, failureImage) + #expect(imageView.image == failureImage) } - + #if !os(macOS) - + // MARK: - Content Modes - - @MainActor - func testPlaceholderAndSuccessContentModesApplied() { + + @Test func placeholderAndSuccessContentModesApplied() async throws { // Given - var options = ImageLoadingOptions() options.contentModes = .init( success: .scaleAspectFill, // default is .scaleToFill failure: .center, placeholder: .center ) options.placeholder = PlatformImage() - + // When - expectToFinishLoadingImage(with: Test.request, options: options, into: imageView) - + let expectation = AsyncExpectation() + loadImage(with: Test.request, options: options, into: imageView) { _ in + expectation.fulfill() + } + // Then - XCTAssertEqual(imageView.contentMode, .center) - wait() - XCTAssertEqual(imageView.contentMode, .scaleAspectFill) + #expect(imageView.contentMode == .center) + await expectation.wait() + #expect(imageView.contentMode == .scaleAspectFill) } - - @MainActor - func testSuccessContentModeAppliedWhenFromMemoryCache() { + + @Test func successContentModeAppliedWhenFromMemoryCache() async throws { // Given - var options = ImageLoadingOptions() options.contentModes = ImageLoadingOptions.ContentModes( success: .scaleAspectFill, failure: .center, placeholder: .center ) - + mockCache[Test.request] = Test.container - + // When - NukeExtensions.loadImage(with: Test.request, options: options, into: imageView) - + try await loadImage(with: Test.request, options: options, into: imageView) + // Then - XCTAssertEqual(imageView.contentMode, .scaleAspectFill) + #expect(imageView.contentMode == .scaleAspectFill) } - - @MainActor - func testFailureContentModeApplied() { + + @Test func failureContentModeApplied() async { // Given - var options = ImageLoadingOptions() options.contentModes = ImageLoadingOptions.ContentModes( success: .scaleAspectFill, failure: .center, placeholder: .center ) options.failureImage = PlatformImage() - + dataLoader.results[Test.url] = .failure( NSError(domain: "t", code: 42, userInfo: nil) ) - + // When - expectToFinishLoadingImage(with: Test.request, options: options, into: imageView) - wait() - + try? await loadImage(with: Test.request, options: options, into: imageView) + // Then - XCTAssertEqual(imageView.contentMode, .center) + #expect(imageView.contentMode == .center) } - + #endif - + #if os(iOS) || os(tvOS) || os(visionOS) - + // MARK: - Tint Colors - - @MainActor - func testPlaceholderAndSuccessTintColorApplied() { + + @Test func placeholderAndSuccessTintColorApplied() async throws { // Given - var options = ImageLoadingOptions() options.tintColors = .init( success: .blue, failure: nil, placeholder: .yellow ) options.placeholder = PlatformImage() - + // When - expectToFinishLoadingImage(with: Test.request, options: options, into: imageView) - + try await loadImage(with: Test.request, options: options, into: imageView) + // Then - XCTAssertEqual(imageView.tintColor, .yellow) - wait() - XCTAssertEqual(imageView.tintColor, .blue) - XCTAssertEqual(imageView.image?.renderingMode, .alwaysTemplate) + #expect(imageView.tintColor == .blue) + #expect(imageView.image?.renderingMode == .alwaysTemplate) } - @MainActor - func testSuccessTintColorAppliedWhenFromMemoryCache() { + @Test func successTintColorAppliedWhenFromMemoryCache() { // Given - var options = ImageLoadingOptions() options.tintColors = .init( success: .blue, failure: nil, placeholder: nil ) - + mockCache[Test.request] = Test.container - + // When NukeExtensions.loadImage(with: Test.request, options: options, into: imageView) - + // Then - XCTAssertEqual(imageView.tintColor, .blue) - XCTAssertEqual(imageView.image?.renderingMode, .alwaysTemplate) + #expect(imageView.tintColor == .blue) + #expect(imageView.image?.renderingMode == .alwaysTemplate) } - - @MainActor - func testFailureTintColorApplied() { + + @Test func failureTintColorApplied() async throws { // Given - var options = ImageLoadingOptions() options.tintColors = .init( success: nil, failure: .red, placeholder: nil ) options.failureImage = PlatformImage() - + dataLoader.results[Test.url] = .failure( NSError(domain: "t", code: 42, userInfo: nil) ) - + // When - expectToFinishLoadingImage(with: Test.request, options: options, into: imageView) - wait() - + try? await loadImage(with: Test.request, options: options, into: imageView) + // Then - XCTAssertEqual(imageView.tintColor, .red) - XCTAssertEqual(imageView.image?.renderingMode, .alwaysTemplate) + #expect(imageView.tintColor == .red) + #expect(imageView.image?.renderingMode == .alwaysTemplate) } - + #endif - + // MARK: - Pipeline - - @MainActor - func testCustomPipelineUsed() { + + @Test func customPipelineUsed() async throws { // Given let dataLoader = MockDataLoader() let pipeline = ImagePipeline { $0.dataLoader = dataLoader $0.imageCache = nil } - + var options = ImageLoadingOptions() options.pipeline = pipeline - - // When - expectToFinishLoadingImage(with: Test.request, options: options, into: imageView) - - // Then - wait { _ in - _ = pipeline - XCTAssertEqual(dataLoader.createdTaskCount, 1) - XCTAssertEqual(self.dataLoader.createdTaskCount, 0) - } - } - - // MARK: - Shared Options - - @MainActor - func testSharedOptionsUsed() { - // Given - var options = ImageLoadingOptions.shared - let placeholder = PlatformImage() - options.placeholder = placeholder - - ImageLoadingOptions.pushShared(options) - + // When - NukeExtensions.loadImage(with: Test.request, options: options, into: imageView) - + try await loadImage(with: Test.request, options: options, into: imageView) + // Then - XCTAssertEqual(imageView.image, placeholder) - - ImageLoadingOptions.popShared() + _ = pipeline // retain + #expect(dataLoader.createdTaskCount == 1) + #expect(self.dataLoader.createdTaskCount == 0) } - + // MARK: - Cache Policy - - @MainActor - func testReloadIgnoringCachedData() { + + @Test func reloadIgnoringCachedData() async throws { // When the requested image is stored in memory cache var request = Test.request mockCache[request] = ImageContainer(image: PlatformImage()) - + request.options = [.reloadIgnoringCachedData] - + // When - expectToFinishLoadingImage(with: request, into: imageView) - wait() - + try await loadImage(with: request, options: options, into: imageView) + // Then - XCTAssertEqual(dataLoader.createdTaskCount, 1) + #expect(dataLoader.createdTaskCount == 1) } - + // MARK: - Misc - + #if os(iOS) || os(tvOS) || os(visionOS) - @MainActor - func testTransitionCrossDissolve() { - // GIVEN - var options = ImageLoadingOptions() + @Test func transitionCrossDissolve() async throws { + // Given options.placeholder = Test.image options.transition = .fadeIn(duration: 0.33) options.isPrepareForReuseEnabled = false @@ -354,30 +296,26 @@ class ImageViewLoadingOptionsTests: XCTestCase { failure: .center, placeholder: .center ) - + imageView.image = Test.image - - // WHEN - expectToFinishLoadingImage(with: Test.request, options: options, into: imageView) - wait() - - // THEN make sure we run the pass with cross-disolve and at least + + // When + try await loadImage(with: Test.request, options: options, into: imageView) + + // Then make sure we run the pass with cross-disolve and at least // it doesn't crash } #endif - - @MainActor - func testSettingDefaultProcessor() { - // GIVEN - var options = ImageLoadingOptions() + + @Test func settingDefaultProcessor() async throws { + // Given options.processors = [MockImageProcessor(id: "p1")] - - // WHEN - expectToFinishLoadingImage(with: Test.request, options: options, into: imageView) - wait() - - // THEN - XCTAssertEqual(imageView.image?.nk_test_processorIDs, ["p1"]) + + // When + try await loadImage(with: Test.request, options: options, into: imageView) + + // Then + #expect(imageView.image?.nk_test_processorIDs == ["p1"]) } } diff --git a/Tests/NukeExtensionsTests/NukeExtensionsTestsHelpers.swift b/Tests/NukeExtensionsTests/NukeExtensionsTestsHelpers.swift deleted file mode 100644 index 4529912d0..000000000 --- a/Tests/NukeExtensionsTests/NukeExtensionsTestsHelpers.swift +++ /dev/null @@ -1,51 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -@testable import Nuke -@testable import NukeExtensions - -#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) -extension XCTestCase { - @MainActor - func expectToFinishLoadingImage(with request: ImageRequest, - options: ImageLoadingOptions? = nil, - into imageView: ImageDisplayingView, - completion: ((_ result: Result) -> Void)? = nil) { - let expectation = self.expectation(description: "Image loaded for \(request)") - NukeExtensions.loadImage( - with: request, - options: options, - into: imageView, - completion: { result in - XCTAssertTrue(Thread.isMainThread) - completion?(result) - expectation.fulfill() - }) - } - - @MainActor - func expectToLoadImage(with request: ImageRequest, options: ImageLoadingOptions? = nil, into imageView: ImageDisplayingView) { - expectToFinishLoadingImage(with: request, options: options, into: imageView) { result in - XCTAssertTrue(result.isSuccess) - } - } -} - -extension ImageLoadingOptions { - @MainActor - private static var stack = [ImageLoadingOptions]() - - @MainActor - static func pushShared(_ shared: ImageLoadingOptions) { - stack.append(ImageLoadingOptions.shared) - ImageLoadingOptions.shared = shared - } - - @MainActor - static func popShared() { - ImageLoadingOptions.shared = stack.removeLast() - } -} -#endif diff --git a/Tests/NukePerformanceTests/DataCachePeformanceTests.swift b/Tests/NukePerformanceTests/DataCachePeformanceTests.swift index 0d0b5761a..1f7c8f8ff 100644 --- a/Tests/NukePerformanceTests/DataCachePeformanceTests.swift +++ b/Tests/NukePerformanceTests/DataCachePeformanceTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import XCTest import Nuke diff --git a/Tests/NukePerformanceTests/ImageCachePerformanceTests.swift b/Tests/NukePerformanceTests/ImageCachePerformanceTests.swift index 18358c4e7..e0638e4f3 100644 --- a/Tests/NukePerformanceTests/ImageCachePerformanceTests.swift +++ b/Tests/NukePerformanceTests/ImageCachePerformanceTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import XCTest import Nuke diff --git a/Tests/NukePerformanceTests/ImagePipelinePerformanceTests.swift b/Tests/NukePerformanceTests/ImagePipelinePerformanceTests.swift index f7431c22e..24604913f 100644 --- a/Tests/NukePerformanceTests/ImagePipelinePerformanceTests.swift +++ b/Tests/NukePerformanceTests/ImagePipelinePerformanceTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import XCTest import Nuke @@ -12,26 +12,6 @@ class ImagePipelinePerfomanceTests: XCTestCase { func testLoaderOverallPerformance() { let pipeline = makePipeline() - let requests = (0...5000).map { ImageRequest(url: URL(string: "http://test.com/\($0)")) } - let callbackQueue = DispatchQueue(label: "testLoaderOverallPerformance") - measure { - var finished: Int = 0 - let semaphore = DispatchSemaphore(value: 0) - for request in requests { - pipeline.loadImage(with: request, queue: callbackQueue, progress: nil) { _ in - finished += 1 - if finished == requests.count { - semaphore.signal() - } - } - } - semaphore.wait() - } - } - - func testAsyncAwaitPerformance() { - let pipeline = makePipeline() - let requests = (0...5000).map { ImageRequest(url: URL(string: "http://test.com/\($0)")) } measure { @@ -50,7 +30,7 @@ class ImagePipelinePerfomanceTests: XCTestCase { } } - func testAsyncImageTaskPerformance() { + func testAsyncImageTaskEventsPerformance() { let pipeline = makePipeline() let requests = (0...5000).map { ImageRequest(url: URL(string: "http://test.com/\($0)")) } @@ -61,7 +41,10 @@ class ImagePipelinePerfomanceTests: XCTestCase { await withTaskGroup(of: Void.self) { group in for request in requests { group.addTask { - _ = try? await pipeline.imageTask(with: request).image + let imageTask = pipeline.imageTask(with: request) + for await event in imageTask.events { + _ = event + } } } } @@ -81,6 +64,17 @@ private func makePipeline() -> ImagePipeline { } } + final class MockDataLoader: DataLoading { + let response = (Test.data, URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 22789, textEncodingName: nil)) + + func loadData(for request: URLRequest) -> AsyncThrowingStream<(Data, URLResponse), any Error> { + AsyncThrowingStream { continuation in + continuation.yield(response) + continuation.finish() + } + } + } + let pipeline = ImagePipeline { $0.imageCache = nil diff --git a/Tests/NukePerformanceTests/ImageProcessingPerformanceTests.swift b/Tests/NukePerformanceTests/ImageProcessingPerformanceTests.swift index b4f87f117..921c96c74 100644 --- a/Tests/NukePerformanceTests/ImageProcessingPerformanceTests.swift +++ b/Tests/NukePerformanceTests/ImageProcessingPerformanceTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import XCTest import Nuke diff --git a/Tests/NukePerformanceTests/ImageRequestPerformanceTests.swift b/Tests/NukePerformanceTests/ImageRequestPerformanceTests.swift index b7feb3daf..79c07a2ec 100644 --- a/Tests/NukePerformanceTests/ImageRequestPerformanceTests.swift +++ b/Tests/NukePerformanceTests/ImageRequestPerformanceTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import XCTest import Nuke diff --git a/Tests/NukePerformanceTests/ImageViewPerformanceTests.swift b/Tests/NukePerformanceTests/ImageViewPerformanceTests.swift index 1c728ab99..f0c3d3755 100644 --- a/Tests/NukePerformanceTests/ImageViewPerformanceTests.swift +++ b/Tests/NukePerformanceTests/ImageViewPerformanceTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import XCTest import Nuke diff --git a/Tests/NukeTests/DataCacheTests.swift b/Tests/NukeTests/DataCacheTests.swift index b5fb25758..43d4de77b 100644 --- a/Tests/NukeTests/DataCacheTests.swift +++ b/Tests/NukeTests/DataCacheTests.swift @@ -1,96 +1,91 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest +import Testing +import Foundation import Security -@testable import Nuke -let blob = "123".data(using: .utf8) -let otherBlob = "456".data(using: .utf8) +@testable import Nuke -class DataCacheTests: XCTestCase { +@Suite class DataCacheTests { var cache: DataCache! - override func setUp() { - super.setUp() - + init() throws { // Make sure that file names are different from the keys so that we // could know for sure that keyEncoder works as expected. - cache = try! DataCache( + cache = try DataCache( name: UUID().uuidString, filenameGenerator: { String($0.reversed()) } ) } - override func tearDown() { - super.tearDown() - + deinit { try? FileManager.default.removeItem(at: cache.path) } // MARK: Init - func testInitWithName() { + @Test func initWithName() throws { // Given let name = UUID().uuidString // When - let cache = try! DataCache(name: name, filenameGenerator: { $0 }) + let cache = try DataCache(name: name, filenameGenerator: { $0 }) // Then - XCTAssertEqual(cache.path.lastPathComponent, name) - XCTAssertNotNil(FileManager.default.fileExists(atPath: cache.path.absoluteString)) + #expect(cache.path.lastPathComponent == name) + #expect(FileManager.default.fileExists(atPath: cache.path.absoluteString) != nil) } - func testInitWithPath() { + @Test func initWithPath() throws { // Given let name = UUID().uuidString let path = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent(name) // When - let cache = try! DataCache(path: path, filenameGenerator: { $0 }) + let cache = try DataCache(path: path, filenameGenerator: { $0 }) // Then - XCTAssertEqual(cache.path, path) - XCTAssertNotNil(FileManager.default.fileExists(atPath: path.absoluteString)) + #expect(cache.path == path) + #expect(FileManager.default.fileExists(atPath: path.absoluteString) != nil) } // MARK: Default Key Encoder - func testDefaultKeyEncoder() { - let cache = try! DataCache(name: UUID().uuidString) + @Test func defaultKeyEncoder() throws { + let cache = try DataCache(name: UUID().uuidString) let filename = cache.filename(for: "http://test.com") - XCTAssertEqual(filename, "50334ee0b51600df6397ce93ceed4728c37fee4e") + #expect(filename == "50334ee0b51600df6397ce93ceed4728c37fee4e") } - func testSHA1() { - XCTAssertEqual("http://test.com".sha1, "50334ee0b51600df6397ce93ceed4728c37fee4e") + @Test func sHA1() { + #expect("http://test.com".sha1 == "50334ee0b51600df6397ce93ceed4728c37fee4e") } // MARK: Add - func testAdd() { + @Test func add() { cache.withSuspendedIO { // When cache["key"] = blob // Then - XCTAssertEqual(cache["key"], blob) + #expect(cache["key"] == blob) } } - func testWhenAddContentNotFlushedImmediately() { + @Test func whenAddContentNotFlushedImmediately() { cache.withSuspendedIO { // When cache["key"] = blob // Then - XCTAssertEqual(cache.contents.count, 0) + #expect(cache.contents.count == 0) } } - func testAddAndFlush() { + @Test func addAndFlush() throws { // Given cache.withSuspendedIO { cache["key"] = blob @@ -100,12 +95,12 @@ class DataCacheTests: XCTestCase { cache.flush() // Then - XCTAssertEqual(cache.contents.count, 1) - XCTAssertEqual(cache["key"], blob) - XCTAssertEqual(try? Data(contentsOf: cache.contents.first!), blob) + #expect(cache.contents.count == 1) + #expect(cache["key"] == blob) + #expect(try Data(contentsOf: cache.contents.first!) == blob) } - func testReplace() { + @Test func replace() { cache.withSuspendedIO { // Given cache["key"] = blob @@ -114,100 +109,100 @@ class DataCacheTests: XCTestCase { cache["key"] = otherBlob // Then - XCTAssertEqual(cache["key"], otherBlob) + #expect(cache["key"] == otherBlob) } } - func testReplaceFlushed() { + @Test func replaceFlushed() throws { // Given cache["key"] = blob cache.flush() - cache.withSuspendedIO { + try cache.withSuspendedIO { cache["key"] = otherBlob - XCTAssertEqual(cache.contents.count, 1) + #expect(cache.contents.count == 1) // Test that before flush we still have the old blob on disk, // but new blob in staging - XCTAssertEqual(try? Data(contentsOf: cache.contents.first!), blob) - XCTAssertEqual(cache["key"], otherBlob) + try #expect(Data(contentsOf: cache.contents.first!) == blob) + #expect(cache["key"] == otherBlob) } // Flush and test that data on disk was updated. cache.flush() - XCTAssertEqual(cache.contents.count, 1) - XCTAssertEqual(try? Data(contentsOf: cache.contents.first!), otherBlob) - XCTAssertEqual(cache["key"], otherBlob) + #expect(cache.contents.count == 1) + #expect(try Data(contentsOf: cache.contents.first!) == otherBlob) + #expect(cache["key"] == otherBlob) } // MARK: Removal - func testRemoveNonExistent() { + @Test func removeNonExistent() { cache["key"] = nil cache.flush() } // - Remove + write (new) staged -> remove from staging - func testRemoveFromStaging() { + @Test func removeFromStaging() { cache.withSuspendedIO { cache["key"] = blob cache["key"] = nil - XCTAssertNil(cache["key"]) + #expect(cache["key"] == nil) } cache.flush() - XCTAssertNil(cache["key"]) + #expect(cache["key"] == nil) } // - Remove + write (new) staged -> remove from staging - func testRemoveReplaced() { + @Test func removeReplaced() { cache.withSuspendedIO { cache["key"] = blob cache["key"] = otherBlob cache["key"] = nil } cache.flush() - XCTAssertNil(cache["key"]) - XCTAssertEqual(cache.contents.count, 0) + #expect(cache["key"] == nil) + #expect(cache.contents.count == 0) } // - Remove + write (replace) staged -> schedule removal - func testRemoveReplacedFlushed() { + @Test func removeReplacedFlushed() throws { cache["key"] = blob cache.flush() - cache.withSuspendedIO { + try cache.withSuspendedIO { cache["key"] = otherBlob cache["key"] = nil - XCTAssertNil(cache["key"]) - XCTAssertEqual(try? Data(contentsOf: cache.contents.first!), blob) + #expect(cache["key"] == nil) + try #expect(Data(contentsOf: cache.contents.first!) == blob) } cache.flush() // Should still perform deletion of "blob" - XCTAssertEqual(cache.contents.count, 0) + #expect(cache.contents.count == 0) } // - Remove + flushed -> schedule removal - func testRemoveFlushed() { + @Test func removeFlushed() throws { // Given cache["key"] = blob cache.flush() - cache.withSuspendedIO { + try cache.withSuspendedIO { cache["key"] = nil - XCTAssertNil(cache["key"]) + #expect(cache["key"] == nil) // Still have data in cache - XCTAssertEqual(cache.contents.count, 1) - XCTAssertEqual(try? Data(contentsOf: cache.contents.first!), blob) + #expect(cache.contents.count == 1) + try #expect(Data(contentsOf: cache.contents.first!) == blob) } cache.flush() - XCTAssertNil(cache["key"]) + #expect(cache["key"] == nil) // IO performed - XCTAssertEqual(cache.contents.count, 0) + #expect(cache.contents.count == 0) } // - Remove + removal staged -> noop - func testRemoveWhenRemovalAlreadyScheduled() { + @Test func removeWhenRemovalAlreadyScheduled() { // Given cache["key"] = blob cache.flush() @@ -218,10 +213,10 @@ class DataCacheTests: XCTestCase { cache.flush() // Then - XCTAssertEqual(cache.contents.count, 0) + #expect(cache.contents.count == 0) } - func testRemoveAndThenReplace() { + @Test func removeAndThenReplace() throws { // Given cache["key"] = blob cache.flush() @@ -232,14 +227,14 @@ class DataCacheTests: XCTestCase { cache.flush() // Then - XCTAssertEqual(cache["key"], blob) - XCTAssertEqual(cache.contents.count, 1) - XCTAssertEqual(try? Data(contentsOf: cache.contents.first!), blob) + #expect(cache["key"] == blob) + #expect(cache.contents.count == 1) + #expect(try Data(contentsOf: cache.contents.first!) == blob) } // MARK: Remove All - func testRemoveAll() { + @Test func removeAll() { cache.withSuspendedIO { // Given cache["key"] = blob @@ -248,11 +243,11 @@ class DataCacheTests: XCTestCase { cache.removeAll() // Then - XCTAssertNil(cache["key"]) + #expect(cache["key"] == nil) } } - func testRemoveAllFlushed() { + @Test func removeAllFlushed() { // Given cache["key"] = blob cache.flush() @@ -260,11 +255,11 @@ class DataCacheTests: XCTestCase { // When cache.withSuspendedIO { cache.removeAll() - XCTAssertNil(cache["key"]) + #expect(cache["key"] == nil) } } - func testRemoveAllFlushedAndFlush() { + @Test func removeAllFlushedAndFlush() { // Given cache["key"] = blob cache.flush() @@ -274,13 +269,13 @@ class DataCacheTests: XCTestCase { cache.flush() // Then - XCTAssertNil(cache["key"]) - XCTAssertEqual(cache.contents.count, 0) + #expect(cache["key"] == nil) + #expect(cache.contents.count == 0) } - func testRemoveAllAndAdd() { - // Given + @Test func removeAllAndAdd() { cache.withSuspendedIO { + // Given cache["key"] = blob // When @@ -288,13 +283,13 @@ class DataCacheTests: XCTestCase { cache["key"] = blob // Then - XCTAssertEqual(cache["key"], blob) + #expect(cache["key"] == blob) } } - func testRemoveAllTwice() { - // Given + @Test func removeAllTwice() { cache.withSuspendedIO { + // Given cache["key"] = blob // When @@ -303,13 +298,13 @@ class DataCacheTests: XCTestCase { cache.removeAll() // Then - XCTAssertNil(cache["key"]) + #expect(cache["key"] == nil) } } // MARK: DataCaching - func testGetCachedDataHitFromStaging() { + @Test func getCachedDataHitFromStaging() { // Given cache.flush() // Index is loaded @@ -319,23 +314,23 @@ class DataCacheTests: XCTestCase { // When/Then let data = cache.cachedData(for: "key") - XCTAssertEqual(data, blob) + #expect(data == blob) } } - func testGetCachedData() { + @Test func getCachedData() { // Given cache["key"] = blob cache.flush() // When/Then let data = cache.cachedData(for: "key") - XCTAssertEqual(data, blob) + #expect(data == blob) } // MARK: Flush - func testFlush() { + @Test func flush() { // Given cache.flushInterval = .seconds(20) cache["key"] = blob @@ -344,10 +339,10 @@ class DataCacheTests: XCTestCase { cache.flush() // Then - XCTAssertEqual(cache.contents, [cache.url(for: "key")].compactMap { $0 }) + #expect(cache.contents == [cache.url(for: "key")].compactMap { $0 }) } - func testFlushForKey() { + @Test func flushForKey() { // Given cache.flushInterval = .seconds(20) cache["key"] = blob @@ -356,10 +351,10 @@ class DataCacheTests: XCTestCase { cache.flush(for: "key") // Then - XCTAssertEqual(cache.contents, [cache.url(for: "key")].compactMap { $0 }) + #expect(cache.contents == [cache.url(for: "key")].compactMap { $0 }) } - func testFlushForKey2() { + @Test func flushForKey2() { // Given cache.flushInterval = .seconds(20) cache["key1"] = blob @@ -369,13 +364,13 @@ class DataCacheTests: XCTestCase { cache.flush(for: "key1") // Then only flushes content for the specific key - XCTAssertEqual(cache.contents, [cache.url(for: "key1")].compactMap { $0 }) + #expect(cache.contents == [cache.url(for: "key1")].compactMap { $0 }) } // MARK: Sweep - func testSweep() { - // GIVEN + @Test func sweep() { + // Given let mb = 1024 * 1024 // allocated size is usually about 4 KB on APFS, so use 1 MB just to be sure cache.sizeLimit = mb * 3 cache["key1"] = Data(repeating: 1, count: mb) @@ -384,107 +379,100 @@ class DataCacheTests: XCTestCase { cache["key4"] = Data(repeating: 1, count: mb) cache.flush() - // WHEN + // When cache.sweep() - // THEN - XCTAssertEqual(cache.totalSize, mb * 2) + // Then + #expect(cache.totalSize == mb * 2) } // MARK: Inspection - func testContainsData() { - // GIVEN + @Test func containsData() { + // Given cache["key"] = blob cache.flush(for: "key") - // WHEN/THEN - XCTAssertTrue(cache.containsData(for: "key")) + // When/Then + #expect(cache.containsData(for: "key")) } - func testContainsDataInStaging() { - // GIVEN + @Test func containsDataInStaging() { + // Given cache.flushInterval = .seconds(20) cache["key"] = blob - // WHEN/THEN - XCTAssertTrue(cache.containsData(for: "key")) + // When/Then + #expect(cache.containsData(for: "key")) } - func testContainsDataAfterRemoval() { - // GIVEN + @Test func containsDataAfterRemoval() { + // Given cache.flushInterval = .seconds(20) cache["key"] = blob cache.flush(for: "key") cache["key"] = nil - // WHEN/THEN - XCTAssertFalse(cache.containsData(for: "key")) + // When/Then + #expect(!cache.containsData(for: "key")) } - func testTotalCount() { - XCTAssertEqual(cache.totalCount, 0) + @Test func totalCount() { + #expect(cache.totalCount == 0) cache["1"] = "1".data(using: .utf8) cache.flush() - XCTAssertEqual(cache.totalCount, 1) + #expect(cache.totalCount == 1) } - func testTotalSize() { - XCTAssertEqual(cache.totalSize, 0) + @Test func totalSize() { + #expect(cache.totalSize == 0) cache["1"] = "1".data(using: .utf8) cache.flush() - XCTAssertTrue(cache.totalSize > 0) + #expect(cache.totalSize > 0) } - func testTotalAllocatedSize() { - XCTAssertEqual(cache.totalAllocatedSize, 0) + @Test func totalAllocatedSize() { + #expect(cache.totalAllocatedSize == 0) cache["1"] = "1".data(using: .utf8) cache.flush() // Depends on the file system. - XCTAssertTrue(cache.totalAllocatedSize > 0) + #expect(cache.totalAllocatedSize > 0) } // MARK: Resilience - func testWhenDirectoryDeletedCacheAutomaticallyRecreatesIt() { + @Test func whenDirectoryDeletedCacheAutomaticallyRecreatesIt() throws { cache["1"] = "2".data(using: .utf8) cache.flush() - do { - try FileManager.default.removeItem(at: cache.path) - } catch { - XCTFail("Fail to remove cache directory") - } + try FileManager.default.removeItem(at: cache.path) cache["1"] = "2".data(using: .utf8) cache.flush() - do { - guard let url = cache.url(for: "1") else { - return XCTFail("Failed to create URL") - } - let data = try Data(contentsOf: url) - XCTAssertEqual(String(data: data, encoding: .utf8), "2") - } catch { - XCTFail("Failed to read data") - } + let url = try #require(cache.url(for: "1")) + let data = try Data(contentsOf: url) + #expect(String(data: data, encoding: .utf8) == "2") } } extension DataCache { var contents: [URL] { - return try! FileManager.default.contentsOfDirectory(at: self.path, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) + try! FileManager.default.contentsOfDirectory(at: self.path, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) } - func withSuspendedIO(_ closure: () -> Void) { + func withSuspendedIO(_ closure: () throws -> Void) rethrows { queue.suspend() - closure() + try closure() queue.resume() } } + +private let blob = "123".data(using: .utf8) +private let otherBlob = "456".data(using: .utf8) 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/DeprecationTests.swift b/Tests/NukeTests/DeprecationTests.swift deleted file mode 100644 index 38003fd3f..000000000 --- a/Tests/NukeTests/DeprecationTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -@testable import Nuke diff --git a/Tests/NukeTests/ImageCacheTests.swift b/Tests/NukeTests/ImageCacheTests.swift index 788b9925a..e0ccef8a0 100644 --- a/Tests/NukeTests/ImageCacheTests.swift +++ b/Tests/NukeTests/ImageCacheTests.swift @@ -1,423 +1,398 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 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() { - // GIVEN + + @Test func removeAll() { + // Given cache[request1] = Test.container cache[request2] = Test.container - - // WHEN + + // When cache.removeAll() - - // THEN - XCTAssertEqual(cache.totalCount, 0) - XCTAssertEqual(cache.totalCost, 0) + + // Then + #expect(cache.totalCount == 0) + #expect(cache.totalCost == 0) } - -#if os(iOS) || os(tvOS) || os(visionOS) - @MainActor - func testThatSomeImagesAreRemovedOnDidEnterBackground() async { - // GIVEN + +#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) - - // WHEN + #expect(cache.totalCount == 10) + + // When let task = Task { @MainActor in NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - - // THEN - XCTAssertEqual(cache.totalCount, 1) + + // Then + #expect(cache.totalCount == 1) } await task.value } - - @MainActor - func testThatSomeImagesAreRemovedBasedOnCostOnDidEnterBackground() async { - // GIVEN + + @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) - - // WHEN + #expect(cache.totalCount == 10) + + // When let task = Task { @MainActor in NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - - // THEN - XCTAssertEqual(cache.totalCount, 1) + + // Then + #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..e72023617 100644 --- a/Tests/NukeTests/ImageDecoderRegistryTests.swift +++ b/Tests/NukeTests/ImageDecoderRegistryTests.swift @@ -1,21 +1,21 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 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..273245632 100644 --- a/Tests/NukeTests/ImageDecoderTests.swift +++ b/Tests/NukeTests/ImageDecoderTests.swift @@ -1,238 +1,237 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 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 == .heic) + #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 { - 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) - } + @Test func decodeBaselineWebP() throws { + let data = Test.data(name: "baseline", extension: "webp") + let container = try ImageDecoders.Default().decode(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..1530dc953 100644 --- a/Tests/NukeTests/ImageEncoderTests.swift +++ b/Tests/NukeTests/ImageEncoderTests.swift @@ -1,96 +1,96 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 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) == .heic) } - + #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..2908e164e 100644 --- a/Tests/NukeTests/ImagePipelineTests/DocumentationTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/DocumentationTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). import Foundation import Nuke @@ -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 deleted file mode 100644 index caf15c69b..000000000 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineAsyncAwaitTests.swift +++ /dev/null @@ -1,476 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -@testable import Nuke - -class ImagePipelineAsyncAwaitTests: XCTestCase, @unchecked Sendable { - var dataLoader: MockDataLoader! - var pipeline: ImagePipeline! - - private var recordedEvents: [ImageTask.Event] = [] - private var recordedResult: Result? - private var recordedProgress: [ImageTask.Progress] = [] - 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() - - dataLoader = MockDataLoader() - pipeline = ImagePipeline(delegate: pipelineDelegate) { - $0.dataLoader = dataLoader - $0.imageCache = nil - $0._callbackQueue = callbackQueue - } - - callbackQueue.setSpecific(key: callbackQueueKey, value: ()) - } - - // MARK: - Basics - - func testImageIsLoaded() async throws { - // WHEN - let image = try await pipeline.image(for: Test.request) - - // THEN - XCTAssertEqual(image.sizeInPixels, CGSize(width: 640, height: 480)) - } - - // MARK: - Task-based API - - func testTaskBasedImageResponse() async throws { - // GIVEN - let task = pipeline.imageTask(with: Test.request) - - // WHEN - let response = try await task.response - - // THEN - XCTAssertEqual(response.image.sizeInPixels, CGSize(width: 640, height: 480)) - } - - func testTaskBasedImage() async throws { - // GIVEN - let task = pipeline.imageTask(with: Test.request) - - // WHEN - let image = try await task.image - - // THEN - XCTAssertEqual(image.sizeInPixels, CGSize(width: 640, height: 480)) - } - - private var observer: AnyObject? - - // MARK: - Cancellation - - func testCancellation() async throws { - dataLoader.queue.isSuspended = true - - let task = Task { - try await pipeline.image(for: Test.url) - } - - observer = NotificationCenter.default.addObserver(forName: MockDataLoader.DidStartTask, object: dataLoader, queue: OperationQueue()) { _ in - task.cancel() - } - - var caughtError: Error? - do { - _ = try await task.value - } catch { - caughtError = error - } - 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 - - let task = Task { - try await pipeline.image(for: Test.url) - } - task.cancel() - - var caughtError: Error? - do { - _ = try await task.value - } catch { - caughtError = error - } - XCTAssertTrue(caughtError is CancellationError) - } - - func testCancelFromProgress() async throws { - dataLoader.queue.isSuspended = true - - let task = Task { - let task = pipeline.imageTask(with: Test.url) - for await value in task.progress { - recordedProgress.append(value) - } - } - - task.cancel() - - _ = await task.value - - // THEN nothing is recorded because the task is cancelled and - // stop observing the events - XCTAssertEqual(recordedProgress, []) - } - - func testObserveProgressAndCancelFromOtherTask() async throws { - dataLoader.queue.isSuspended = true - - let task = pipeline.imageTask(with: Test.url) - - let task1 = Task { - for await event in task.progress { - recordedProgress.append(event) - } - } - - let task2 = Task { - try await task.response - } - - task2.cancel() - - async let result1: () = task1.value - async let result2 = task2.value - - // THEN you are able to observe `event` update because - // this task does no get cancelled - var caughtError: Error? - do { - _ = try await (result1, result2) - } catch { - caughtError = error - } - XCTAssertTrue(caughtError is CancellationError) - XCTAssertEqual(recordedProgress, []) - } - - 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 { - _ = try await task.image - } catch { - caughtError = error - } - XCTAssertTrue(caughtError is CancellationError) - } - - // MARK: - Load Data - - func testLoadData() async throws { - // GIVEN - dataLoader.results[Test.url] = .success((Test.data, Test.urlResponse)) - - // WHEN - let (data, response) = try await pipeline.data(for: Test.request) - - // THEN - XCTAssertEqual(data.count, 22788) - XCTAssertNotNil(response?.url, Test.url.absoluteString) - } - - func testLoadDataCancelImmediately() async throws { - dataLoader.queue.isSuspended = true - - let task = Task { - try await pipeline.data(for: Test.request) - } - task.cancel() - - var caughtError: Error? - do { - _ = try await task.value - } catch { - caughtError = error - } - 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( - (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) - ) - - // WHEN - do { - let task = pipeline.imageTask(with: Test.url) - for await progress in task.progress { - recordedProgress.append(progress) - } - _ = try await task.image - } catch { - // Do nothing - } - - // THEN - XCTAssertEqual(recordedProgress, [ - ImageTask.Progress(completed: 10, total: 20), - ImageTask.Progress(completed: 20, total: 20) - ]) - } - - func testThatProgressivePreviewsAreDelivered() async throws { - // GIVEN - let dataLoader = MockProgressiveDataLoader() - pipeline = pipeline.reconfigured { - $0.dataLoader = dataLoader - $0.isProgressiveDecodingEnabled = true - } - - // WHEN - let task = pipeline.imageTask(with: Test.url) - Task { - for try await preview in task.previews { - recordedPreviews.append(preview) - dataLoader.resume() - } - } - _ = try await task.image - - // THEN - XCTAssertEqual(recordedPreviews.count, 2) - XCTAssertTrue(recordedPreviews.allSatisfy { $0.container.isPreview }) - } - - // MARK: - Update Priority - - func testUpdatePriority() { - // GIVEN - let queue = pipeline.configuration.dataLoadingQueue - queue.isSuspended = true - - let request = Test.request - XCTAssertEqual(request.priority, .normal) - - let observer = expect(queue).toEnqueueOperationsWithCount(1) - let imageTask = pipeline.imageTask(with: request) - - Task.detached { - try await imageTask.response - } - wait() - - // WHEN/THEN - guard let operation = observer.operations.first else { - return XCTFail("Failed to find operation") - } - expect(operation).toUpdatePriority() - imageTask.priority = .high - wait() - } - - // MARK: - ImageRequest with Async/Await - - func testImageRequestWithAsyncAwaitSuccess() async throws { - if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { - // GIVEN - let localURL = Test.url(forResource: "fixture", extension: "jpeg") - - // WHEN - let request = ImageRequest(id: "test", data: { - let (data, _) = try await URLSession.shared.data(for: URLRequest(url: localURL)) - return data - }) - - let image = try await pipeline.image(for: request) - - // THEN - XCTAssertEqual(image.sizeInPixels, CGSize(width: 640, height: 480)) - } - } - - func testImageRequestWithAsyncAwaitFailure() async throws { - if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { - // WHEN - let request = ImageRequest(id: "test", data: { - throw URLError(networkUnavailableReason: .cellular) - }) - - do { - _ = try await pipeline.image(for: request) - XCTFail() - } catch { - if case let .dataLoadingFailed(error) = error as? ImagePipeline.Error { - XCTAssertEqual((error as? URLError)?.networkUnavailableReason, .cellular) - } else { - XCTFail() - } - } - } - } - - // MARK: Common Use Cases - - func testLowDataMode() async throws { - // GIVEN - let highQualityImageURL = URL(string: "https://example.com/high-quality-image.jpeg")! - let lowQualityImageURL = URL(string: "https://example.com/low-quality-image.jpeg")! - - dataLoader.results[highQualityImageURL] = .failure(URLError(networkUnavailableReason: .constrained) as NSError) - dataLoader.results[lowQualityImageURL] = .success((Test.data, Test.urlResponse)) - - // WHEN - let pipeline = self.pipeline! - - // Create the default request to fetch the high quality image. - var urlRequest = URLRequest(url: highQualityImageURL) - urlRequest.allowsConstrainedNetworkAccess = false - let request = ImageRequest(urlRequest: urlRequest) - - // WHEN - @Sendable func loadImage() async throws -> PlatformImage { - do { - return try await pipeline.image(for: request) - } catch { - guard let error = (error as? ImagePipeline.Error), - (error.dataLoadingError as? URLError)?.networkUnavailableReason == .constrained else { - throw error - } - return try await pipeline.image(for: lowQualityImageURL) - } - } - - _ = try await loadImage() - } - - // MARK: - ImageTask Integration - - @available(macOS 12, iOS 15, tvOS 15, watchOS 9, *) - func testImageTaskEvents() async { - // GIVEN - let dataLoader = MockProgressiveDataLoader() - pipeline = pipeline.reconfigured { - $0.dataLoader = dataLoader - $0.isProgressiveDecodingEnabled = true - } - - // WHEN - let task = pipeline.loadImage(with: Test.request) { _ in } - for await event in task.events { - switch event { - case .preview(let response): - recordedPreviews.append(response) - dataLoader.resume() - case .finished(let result): - recordedResult = result - default: - break - } - recordedEvents.append(event) - } - - // THEN - guard recordedPreviews.count == 2 else { - return XCTFail("Unexpected number of previews") - } - - XCTAssertEqual(recordedEvents.filter { - if case .progress = $0 { - return false // There is guarantee if all will arrive - } - return true - }, [ - .preview(recordedPreviews[0]), - .preview(recordedPreviews[1]), - .finished(try XCTUnwrap(recordedResult)) - ]) - } -} - -/// We have to mock it because there is no way to construct native `URLError` -/// with a `networkUnavailableReason`. -private struct URLError: Swift.Error { - var networkUnavailableReason: NetworkUnavailableReason? - - enum NetworkUnavailableReason { - case cellular - case expensive - 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/ImagePipelineCacheLayerPriorityTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCacheLayerPriorityTests.swift new file mode 100644 index 000000000..a189fd394 --- /dev/null +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCacheLayerPriorityTests.swift @@ -0,0 +1,293 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing + +@testable import Nuke + +/// Make sure that cache layers are checked in the correct order and the +/// minimum necessary number of cache lookups are performed. +@Suite class ImagePipelineCacheLayerPriorityTests { + var pipeline: ImagePipeline! + var dataLoader: MockDataLoader! + var imageCache: MockImageCache! + var dataCache: MockDataCache! + var processorFactory: MockProcessorFactory! + + var request: ImageRequest! + var intermediateRequest: ImageRequest! + var processedImage: ImageContainer! + var intermediateImage: ImageContainer! + var originalRequest: ImageRequest! + var originalImage: ImageContainer! + + init() { + dataCache = MockDataCache() + dataLoader = MockDataLoader() + imageCache = MockImageCache() + processorFactory = MockProcessorFactory() + + pipeline = ImagePipeline { + $0.dataLoader = dataLoader + $0.dataCache = dataCache + $0.imageCache = imageCache + $0.debugIsSyncImageEncoding = true + } + + request = ImageRequest(url: Test.url, processors: [ + processorFactory.make(id: "1"), + processorFactory.make(id: "2") + ]) + + intermediateRequest = ImageRequest(url: Test.url, processors: [ + processorFactory.make(id: "1") + ]) + + originalRequest = ImageRequest(url: Test.url) + + do { + let image = PlatformImage(data: Test.data)! + image.nk_test_processorIDs = ["1", "2"] + processedImage = ImageContainer(image: image) + } + + do { + let image = PlatformImage(data: Test.data)! + image.nk_test_processorIDs = ["1"] + intermediateImage = ImageContainer(image: image) + } + + originalImage = ImageContainer(image: PlatformImage(data: Test.data)!) + } + + @Test func givenProcessedImageInMemoryCache() async throws { + // Given + imageCache[request] = processedImage + + // When + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.cacheType == .memory) + #expect(response.image === processedImage.image) + + // Then + #expect(imageCache.readCount == 1) + #expect(imageCache.writeCount == 1) // Initial write // Initial write + #expect(dataCache.readCount == 0) + #expect(dataCache.writeCount == 0) + #expect(dataLoader.createdTaskCount == 0) + } + + @Test func givenProcessedImageInBothMemoryAndDiskCache() async throws { + // Given + pipeline.cache.storeCachedImage(processedImage, for: request, caches: [.all]) + + // When + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.cacheType == .memory) + #expect(response.image === processedImage.image) + + // Then + #expect(imageCache.readCount == 1) + #expect(imageCache.writeCount == 1) // Initial write // Initial write + #expect(dataCache.readCount == 0) + #expect(dataCache.writeCount == 1) // Initial write // Initial write + #expect(dataLoader.createdTaskCount == 0) + } + + @Test func givenProcessedImageInDiskCache() async throws { + // Given + pipeline.cache.storeCachedImage(processedImage, for: request, caches: [.disk]) + + // When + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.cacheType == .disk) + + // Then + #expect(imageCache.readCount == 1) + #expect(imageCache.writeCount == 1) // Initial write // Initial write + #expect(dataCache.readCount == 1) + #expect(dataCache.writeCount == 1) // Initial write // Initial write + #expect(dataLoader.createdTaskCount == 0) + } + + @Test func givenProcessedImageInDiskCacheAndIndermediateImageInMemoryCache() async throws { + // Given + pipeline.cache.storeCachedImage(processedImage, for: request, caches: [.disk]) + pipeline.cache.storeCachedImage(intermediateImage, for: intermediateRequest, caches: [.memory]) + + // When + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.cacheType == .disk) + + // Then + #expect(imageCache.readCount == 1) + #expect(imageCache.writeCount == 2) // Initial write // Initial write + #expect(imageCache[request] != nil) + #expect(imageCache[intermediateRequest] != nil) + #expect(dataCache.readCount == 1) + #expect(dataCache.writeCount == 1) // Initial write // Initial write + #expect(dataLoader.createdTaskCount == 0) + } + + @Test func givenIndermediateImageInMemoryCache() async throws { + // Given + pipeline.cache.storeCachedImage(intermediateImage, for: intermediateRequest, caches: [.memory]) + + // When + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.image.nk_test_processorIDs == ["1", "2"]) + #expect(response.cacheType == .memory) + + // Then + #expect(imageCache.readCount == 2) // Processed + intermediate // Processed + intermediate + #expect(imageCache.writeCount == 2) // Initial write // Initial write + #expect(imageCache[request] != nil) + #expect(imageCache[intermediateRequest] != nil) + #expect(dataCache.readCount == 1) // Check original image // Check original image + #expect(dataCache.writeCount == 0) + #expect(dataLoader.createdTaskCount == 0) + } + + @Test func givenOriginalAndIntermediateImageInMemoryCache() async throws { + // Given + pipeline.cache.storeCachedImage(intermediateImage, for: intermediateRequest, caches: [.memory]) + pipeline.cache.storeCachedImage(originalImage, for: originalRequest, caches: [.memory]) + + // When + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.image.nk_test_processorIDs == ["1", "2"]) + #expect(response.cacheType == .memory) + + // Then + #expect(imageCache.readCount == 2) // Processed + intermediate // Processed + intermediate + #expect(imageCache.writeCount == 3) // Initial write + write processed // Initial write + write processed + #expect(imageCache[originalRequest] != nil) + #expect(imageCache[request] != nil) + #expect(imageCache[intermediateRequest] != nil) + #expect(dataCache.readCount == 1) // Check original image // Check original image + #expect(dataCache.writeCount == 0) + #expect(dataLoader.createdTaskCount == 0) + } + + @Test func givenOriginalImageInBothCaches() async throws { + // Given + pipeline.cache.storeCachedImage(originalImage, for: originalRequest, caches: [.all]) + + // When + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.image.nk_test_processorIDs == ["1", "2"]) + #expect(response.cacheType == .memory) + + // Then + #expect(imageCache.readCount == 3) // Processed + intermediate + original // Processed + intermediate + original + #expect(imageCache.writeCount == 2) // Processed + original // Processed + original + #expect(imageCache[originalRequest] != nil) + #expect(imageCache[request] != nil) + #expect(dataCache.readCount == 2) // "1", "2" // "1", "2" + #expect(dataCache.writeCount == 1) // Initial // Initial + #expect(dataLoader.createdTaskCount == 0) + } + + @Test func givenOriginalImageInDiskCache() async throws { + // Given + pipeline.cache.storeCachedImage(originalImage, for: originalRequest, caches: [.disk]) + + // When + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.image.nk_test_processorIDs == ["1", "2"]) + #expect(response.cacheType == .disk) + + // Then + #expect(imageCache.readCount == 3) // Processed + intermediate + original // Processed + intermediate + original + #expect(imageCache.writeCount == 1) // Processed // Processed + #expect(imageCache[request] != nil) + #expect(dataCache.readCount == 3) // "1" + "2" + original // "1" + "2" + original + #expect(dataCache.writeCount == 1) // Initial // Initial + #expect(dataLoader.createdTaskCount == 0) + } + + @Test func policyStoreEncodedImagesGivenDataAlreadyStored() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeEncodedImages + } + + let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) + pipeline.cache.storeCachedImage(Test.container, for: request, caches: [.disk]) + dataCache.resetCounters() + imageCache.resetCounters() + + // When + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.image != nil) + #expect(response.cacheType == .disk) + + // Then + #expect(imageCache.readCount == 1) + #expect(imageCache.writeCount == 1) + #expect(dataCache.readCount == 1) + #expect(dataCache.writeCount == 0) + #expect(dataLoader.createdTaskCount == 0) + } + + // MARK: ImageRequest.Options + + @Test func givenOriginalImageInDiskCacheAndDiskReadsDisabled() async throws { + // Given + pipeline.cache.storeCachedImage(originalImage, for: originalRequest, caches: [.disk]) + + // When + request.options.insert(.disableDiskCacheReads) + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.image.nk_test_processorIDs == ["1", "2"]) + #expect(response.cacheType == nil) + + // Then + #expect(imageCache.readCount == 3) // Processed + intermediate + original // Processed + intermediate + original + #expect(imageCache.writeCount == 1) // Processed // Processed + #expect(imageCache[request] != nil) + #expect(dataCache.readCount == 0) // Processed + original // Processed + original + #expect(dataCache.writeCount == 2) // Initial + processed // Initial + processed + #expect(dataLoader.createdTaskCount == 1) + } + + @Test func givenNoImageDataInDiskCacheAndDiskWritesDisabled() async throws { + // When + request.options.insert(.disableDiskCacheWrites) + let response = try await pipeline.imageTask(with: request).response + + // Then + #expect(response.image.nk_test_processorIDs == ["1", "2"]) + #expect(response.cacheType == nil) + + // Then + #expect(imageCache.readCount == 3) // Processed + intermediate + original // Processed + intermediate + original + #expect(imageCache.writeCount == 1) // Processed // Processed + #expect(imageCache[request] != nil) + #expect(dataCache.readCount == 3) // "1" + "2" + original // "1" + "2" + original + #expect(dataCache.writeCount == 0) + #expect(dataLoader.createdTaskCount == 1) + } +} diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineCacheTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCacheTests.swift index 5541e7a97..f6fe4525f 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineCacheTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCacheTests.swift @@ -1,20 +1,20 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation -import XCTest @testable import Nuke -class ImagePipelineCacheTests: XCTestCase { +@Suite class ImagePipelineCacheTests { var memoryCache: MockImageCache! var diskCache: MockDataCache! var dataLoader: MockDataLoader! var pipeline: ImagePipeline! var cache: ImagePipeline.Cache { pipeline.cache } - override func setUp() { - super.setUp() - + init() { dataLoader = MockDataLoader() diskCache = MockDataCache() memoryCache = MockImageCache() @@ -28,469 +28,469 @@ class ImagePipelineCacheTests: XCTestCase { // MARK: Subscripts - func testSubscript() { - // GIVEN + @Test func subscriptSimple() { + // Given cache[Test.request] = Test.container - // THEN - XCTAssertNotNil(cache[Test.request]) + // Then + #expect(cache[Test.request] != nil) } - func testDisableMemoryCacheRead() { - // GIVEN + @Test func disableMemoryCacheRead() { + // Given cache[Test.request] = Test.container let request = ImageRequest(url: Test.url, options: [.disableMemoryCacheReads]) - // THEN - XCTAssertNil(cache[request]) + // Then + #expect(cache[request] == nil) } - func testDisableMemoryCacheWrite() { - // GIVEN + @Test func disableMemoryCacheWrite() { + // Given let request = ImageRequest(url: Test.url, options: [.disableMemoryCacheWrites]) cache[request] = Test.container - // THEN - XCTAssertNil(cache[Test.request]) + // Then + #expect(cache[Test.request] == nil) } - func testSubscriptRemove() { - // GIVEN + @Test func subscriptRemove() { + // Given cache[Test.request] = Test.container - // WHEN + // When cache[Test.request] = nil - // THEN - XCTAssertNil(cache[Test.request]) + // Then + #expect(cache[Test.request] == nil) } - func testSubscriptStoringPreviewWhenDisabled() { - // GIVEN + @Test func subscriptStoringPreviewWhenDisabled() { + // Given pipeline = pipeline.reconfigured { $0.isStoringPreviewsInMemoryCache = false } - // WHEN + // When cache[Test.request] = ImageContainer(image: Test.image, isPreview: true) - // THEN - XCTAssertNil(cache[Test.request]) + // Then + #expect(cache[Test.request] == nil) } - func testSubscriptStoringPreviewWhenEnabled() throws { - // GIVEN + @Test func subscriptStoringPreviewWhenEnabled() throws { + // Given pipeline = pipeline.reconfigured { $0.isStoringPreviewsInMemoryCache = true } - // WHEN + // When cache[Test.request] = ImageContainer(image: Test.image, isPreview: true) - // THEN - let response = try XCTUnwrap(cache[Test.request]) - XCTAssertTrue(response.isPreview) + // Then + let response = try #require(cache[Test.request]) + #expect(response.isPreview) } - func testSubscriptWhenNoImageCache() { - // GIVEN + @Test func subscriptWhenNoImageCache() { + // Given pipeline = pipeline.reconfigured { $0.imageCache = nil } cache[Test.request] = Test.container - // THEN - XCTAssertNil(cache[Test.request]) + // Then + #expect(cache[Test.request] == nil) } - func testSubscriptWithRealImageCache() { - // GIVEN + @Test func subscriptWithRealImageCache() { + // Given pipeline = pipeline.reconfigured { $0.imageCache = ImageCache() } cache[Test.request] = Test.container - // THEN - XCTAssertNotNil(cache[Test.request]) + // Then + #expect(cache[Test.request] != nil) } // MARK: Cached Image - func testGetCachedImageDefaultFromMemoryCache() { - // GIVEN + @Test func getCachedImageDefaultFromMemoryCache() { + // Given let request = Test.request memoryCache[cache.makeImageCacheKey(for: request)] = Test.container - // WHEN + // When let image = cache.cachedImage(for: request) - // THEN - XCTAssertNotNil(image) + // Then + #expect(image != nil) } - func testGetCachedImageDefaultFromDiskCache() { - // GIVEN + @Test func getCachedImageDefaultFromDiskCache() { + // Given let request = Test.request diskCache.storeData(Test.data, for: cache.makeDataCacheKey(for: request)) - // WHEN + // When let image = cache.cachedImage(for: request) - // THEN - XCTAssertNotNil(image) + // Then + #expect(image != nil) } - func testGetCachedImageDefaultFromDiskCacheWhenOptionEnabled() { - // GIVEN + @Test func getCachedImageDefaultFromDiskCacheWhenOptionEnabled() { + // Given let request = Test.request diskCache.storeData(Test.data, for: cache.makeDataCacheKey(for: request)) - // WHEN + // When let image = cache.cachedImage(for: request, caches: [.disk]) - // THEN returns nil because queries only memory cache by default - XCTAssertNotNil(image) + // Then returns nil because queries only memory cache by default + #expect(image != nil) } - func testGetCachedImageDefaultNotStored() { - // GIVEN + @Test func getCachedImageDefaultNotStored() { + // Given let request = Test.request - // WHEN + // When let image = cache.cachedImage(for: request) - // THEN - XCTAssertNil(image) + // Then + #expect(image == nil) } - func testGetCachedImageDefaultFromMemoryCacheWhenCachePolicyPreventsLookup() { - // GIVEN + @Test func getCachedImageDefaultFromMemoryCacheWhenCachePolicyPreventsLookup() { + // Given var request = Test.request memoryCache[cache.makeImageCacheKey(for: request)] = Test.container - // WHEN + // When request.options = [.reloadIgnoringCachedData] let image = cache.cachedImage(for: request) - // THEN - XCTAssertNil(image) + // Then + #expect(image == nil) } - func testGetCachedImageDefaultFromDiskCacheWhenCachePolicyPreventsLookup() { - // GIVEN + @Test func getCachedImageDefaultFromDiskCacheWhenCachePolicyPreventsLookup() { + // Given var request = Test.request diskCache.storeData(Test.data, for: cache.makeDataCacheKey(for: request)) - // WHEN + // When request.options = [.reloadIgnoringCachedData] let image = cache.cachedImage(for: request, caches: [.disk]) - // THEN - XCTAssertNil(image) + // Then + #expect(image == nil) } - func testGetCachedImageOnlyFromMemoryStoredInMemory() { - // GIVEN + @Test func getCachedImageOnlyFromMemoryStoredInMemory() { + // Given let request = Test.request memoryCache[cache.makeImageCacheKey(for: request)] = Test.container - // WHEN + // When let image = cache.cachedImage(for: request, caches: [.memory]) - // THEN - XCTAssertNotNil(image) + // Then + #expect(image != nil) } - func testGetCachedImageOnlyFromMemoryStoredOnDisk() { - // GIVEN + @Test func getCachedImageOnlyFromMemoryStoredOnDisk() { + // Given let request = Test.request diskCache.storeData(Test.data, for: cache.makeDataCacheKey(for: request)) - // WHEN + // When let image = cache.cachedImage(for: request, caches: [.memory]) - // THEN - XCTAssertNil(image) + // Then + #expect(image == nil) } - func testDisableDiskCacheReads() { - // GIVEN + @Test func disableDiskCacheReads() { + // Given cache.storeCachedData(Test.data, for: Test.request) let request = ImageRequest(url: Test.url, options: [.disableDiskCacheReads]) - // THEN - XCTAssertNil(cache.cachedData(for: request)) + // Then + #expect(cache.cachedData(for: request) == nil) } - func testDisableDiskCacheWrites() { - // GIVEN + @Test func disableDiskCacheWrites() { + // Given let request = ImageRequest(url: Test.url, options: [.disableDiskCacheWrites]) cache.storeCachedData(Test.data, for: request) - // THEN - XCTAssertNil(cache.cachedData(for: Test.request)) + // Then + #expect(cache.cachedData(for: Test.request) == nil) } // MARK: Store Cached Image - func testStoreCachedImageMemoryCache() { - // WHEN + @Test func storeCachedImageMemoryCache() { + // When let request = Test.request cache.storeCachedImage(Test.container, for: request) - // THEN - XCTAssertNotNil(cache.cachedImage(for: request)) - XCTAssertNotNil(memoryCache[cache.makeImageCacheKey(for: request)]) + // Then + #expect(cache.cachedImage(for: request) != nil) + #expect(memoryCache[cache.makeImageCacheKey(for: request)] != nil) - XCTAssertNotNil(cache.cachedImage(for: request, caches: [.disk])) - XCTAssertNotNil(diskCache.cachedData(for: cache.makeDataCacheKey(for: request))) + #expect(cache.cachedImage(for: request, caches: [.disk]) != nil) + #expect(diskCache.cachedData(for: cache.makeDataCacheKey(for: request)) != nil) } - func testStoreCachedImageInDiskCache() { - // WHEN + @Test func storeCachedImageInDiskCache() { + // When let request = Test.request cache.storeCachedImage(Test.container, for: request, caches: [.disk]) - // THEN - XCTAssertNotNil(cache.cachedImage(for: request)) - XCTAssertNil(memoryCache[cache.makeImageCacheKey(for: request)]) + // Then + #expect(cache.cachedImage(for: request) != nil) + #expect(memoryCache[cache.makeImageCacheKey(for: request)] == nil) - XCTAssertNotNil(cache.cachedImage(for: request, caches: [.disk])) - XCTAssertNotNil(diskCache.cachedData(for: cache.makeDataCacheKey(for: request))) + #expect(cache.cachedImage(for: request, caches: [.disk]) != nil) + #expect(diskCache.cachedData(for: cache.makeDataCacheKey(for: request)) != nil) } - func testStoreCachedImageInBothLayers() { - // WHEN + @Test func storeCachedImageInBothLayers() { + // When let request = Test.request cache.storeCachedImage(Test.container, for: request, caches: [.memory, .disk]) - // THEN - XCTAssertNotNil(cache.cachedImage(for: request)) - XCTAssertNotNil(memoryCache[cache.makeImageCacheKey(for: request)]) + // Then + #expect(cache.cachedImage(for: request) != nil) + #expect(memoryCache[cache.makeImageCacheKey(for: request)] != nil) - XCTAssertNotNil(cache.cachedImage(for: request, caches: [.disk])) - XCTAssertNotNil(diskCache.cachedData(for: cache.makeDataCacheKey(for: request))) + #expect(cache.cachedImage(for: request, caches: [.disk]) != nil) + #expect(diskCache.cachedData(for: cache.makeDataCacheKey(for: request)) != nil) } // MARK: Cached Data - func testStoreCachedData() { - // WHEN + @Test func storeCachedData() { + // When let request = Test.request cache.storeCachedData(Test.data, for: request) - // THEN - XCTAssertNotNil(cache.cachedImage(for: request)) - XCTAssertNil(memoryCache[cache.makeImageCacheKey(for: request)]) + // Then + #expect(cache.cachedImage(for: request) != nil) + #expect(memoryCache[cache.makeImageCacheKey(for: request)] == nil) - XCTAssertNotNil(cache.cachedImage(for: request, caches: [.disk])) - XCTAssertNotNil(diskCache.cachedData(for: cache.makeDataCacheKey(for: request))) + #expect(cache.cachedImage(for: request, caches: [.disk]) != nil) + #expect(diskCache.cachedData(for: cache.makeDataCacheKey(for: request)) != nil) } - func testStoreCacheImageWhenMemoryCacheWriteDisabled() { - // WHEN + @Test func storeCacheImageWhenMemoryCacheWriteDisabled() { + // When var request = Test.request request.options.insert(.disableMemoryCacheWrites) cache.storeCachedImage(Test.container, for: request, caches: [.memory]) - // THEN - XCTAssertNil(cache.cachedImage(for: request)) - XCTAssertNil(memoryCache[cache.makeImageCacheKey(for: request)]) + // Then + #expect(cache.cachedImage(for: request) == nil) + #expect(memoryCache[cache.makeImageCacheKey(for: request)] == nil) - XCTAssertNil(cache.cachedImage(for: request, caches: [.disk])) - XCTAssertNil(diskCache.cachedData(for: cache.makeDataCacheKey(for: request))) + #expect(cache.cachedImage(for: request, caches: [.disk]) == nil) + #expect(diskCache.cachedData(for: cache.makeDataCacheKey(for: request)) == nil) } - func testStoreCacheDataWhenNoDataCache() { - // GIVEN + @Test func storeCacheDataWhenNoDataCache() { + // Given pipeline = pipeline.reconfigured { $0.dataCache = nil } - // WHEN + // When cache.storeCachedData(Test.data, for: Test.request) - // THEN just make sure it doesn't do anything weird - XCTAssertNil(cache.cachedData(for: Test.request)) + // Then just make sure it doesn't do anything weird + #expect(cache.cachedData(for: Test.request) == nil) } - func testGetCachedDataWhenNoDataCache() { - // GIVEN + @Test func getCachedDataWhenNoDataCache() { + // Given pipeline = pipeline.reconfigured { $0.dataCache = nil } - // THEN just make sure it doesn't do anything weird - XCTAssertNil(cache.cachedData(for: Test.request)) + // Then just make sure it doesn't do anything weird + #expect(cache.cachedData(for: Test.request) == nil) cache.removeCachedData(for: Test.request) } // MARK: Contains - func testContainsWhenStoredInMemoryCache() { - // GIVEN + @Test func containsWhenStoredInMemoryCache() { + // Given cache.storeCachedImage(Test.container, for: Test.request, caches: [.memory]) - // WHEN/THEN - XCTAssertTrue(cache.containsCachedImage(for: Test.request)) - XCTAssertTrue(cache.containsCachedImage(for: Test.request, caches: [.all])) - XCTAssertTrue(cache.containsCachedImage(for: Test.request, caches: [.memory])) - XCTAssertFalse(cache.containsCachedImage(for: Test.request, caches: [.disk])) + // When/THEN + #expect(cache.containsCachedImage(for: Test.request)) + #expect(cache.containsCachedImage(for: Test.request, caches: [.all])) + #expect(cache.containsCachedImage(for: Test.request, caches: [.memory])) + #expect(!cache.containsCachedImage(for: Test.request, caches: [.disk])) } - func testContainsWhenStoredInDiskCache() { - // GIVEN + @Test func containsWhenStoredInDiskCache() { + // Given cache.storeCachedImage(Test.container, for: Test.request, caches: [.disk]) - // WHEN/THEN - XCTAssertTrue(cache.containsCachedImage(for: Test.request)) - XCTAssertTrue(cache.containsCachedImage(for: Test.request, caches: [.all])) - XCTAssertFalse(cache.containsCachedImage(for: Test.request, caches: [.memory])) - XCTAssertTrue(cache.containsCachedImage(for: Test.request, caches: [.disk])) + // When/THEN + #expect(cache.containsCachedImage(for: Test.request)) + #expect(cache.containsCachedImage(for: Test.request, caches: [.all])) + #expect(!cache.containsCachedImage(for: Test.request, caches: [.memory])) + #expect(cache.containsCachedImage(for: Test.request, caches: [.disk])) } - func testsContainsStoredInBoth() { - // GIVEN + @Test func sContainsStoredInBoth() { + // Given cache.storeCachedImage(Test.container, for: Test.request, caches: [.all]) - // WHEN/THEN - XCTAssertTrue(cache.containsCachedImage(for: Test.request)) - XCTAssertTrue(cache.containsCachedImage(for: Test.request, caches: [.all])) - XCTAssertTrue(cache.containsCachedImage(for: Test.request, caches: [.memory])) - XCTAssertTrue(cache.containsCachedImage(for: Test.request, caches: [.disk])) + // When/THEN + #expect(cache.containsCachedImage(for: Test.request)) + #expect(cache.containsCachedImage(for: Test.request, caches: [.all])) + #expect(cache.containsCachedImage(for: Test.request, caches: [.memory])) + #expect(cache.containsCachedImage(for: Test.request, caches: [.disk])) } - func testContainsData() { - // GIVEN + @Test func containsData() { + // Given cache.storeCachedImage(Test.container, for: Test.request, caches: [.disk]) - // WHEN/THEN - XCTAssertTrue(cache.containsData(for: Test.request)) + // When/THEN + #expect(cache.containsData(for: Test.request)) } - func testContainsDataWithNoDataCache() { - // GIVEN + @Test func containsDataWithNoDataCache() { + // Given pipeline = pipeline.reconfigured { $0.dataCache = nil } - // WHEN/THEN - XCTAssertFalse(cache.containsData(for: Test.request)) + // When/THEN + #expect(!cache.containsData(for: Test.request)) } // MARK: Remove - func testRemoveFromMemoryCache() { - // GIVEN + @Test func removeFromMemoryCache() { + // Given let request = Test.request cache.storeCachedImage(Test.container, for: request) - // WHEN + // When cache.removeCachedImage(for: request) - // THEN - XCTAssertNil(cache.cachedImage(for: request)) - XCTAssertNil(memoryCache[cache.makeImageCacheKey(for: request)]) + // Then + #expect(cache.cachedImage(for: request) == nil) + #expect(memoryCache[cache.makeImageCacheKey(for: request)] == nil) } - func testRemoveFromDiskCache() { - // GIVEN + @Test func removeFromDiskCache() { + // Given let request = Test.request cache.storeCachedImage(Test.container, for: request, caches: [.disk]) - // WHEN + // When cache.removeCachedImage(for: request, caches: [.disk]) - // THEN - XCTAssertNil(cache.cachedImage(for: request, caches: [.disk])) - XCTAssertNil(diskCache.cachedData(for: cache.makeDataCacheKey(for: request))) + // Then + #expect(cache.cachedImage(for: request, caches: [.disk]) == nil) + #expect(diskCache.cachedData(for: cache.makeDataCacheKey(for: request)) == nil) } - func testRemoveFromAllCaches() { - // GIVEN + @Test func removeFromAllCaches() { + // Given let request = Test.request cache.storeCachedImage(Test.container, for: request, caches: [.memory, .disk]) - // WHEN + // When cache.removeCachedImage(for: request, caches: [.memory, .disk]) - // THEN - XCTAssertNil(cache.cachedImage(for: request)) - XCTAssertNil(memoryCache[cache.makeImageCacheKey(for: request)]) + // Then + #expect(cache.cachedImage(for: request) == nil) + #expect(memoryCache[cache.makeImageCacheKey(for: request)] == nil) - XCTAssertNil(cache.cachedImage(for: request, caches: [.disk])) - XCTAssertNil(diskCache.cachedData(for: cache.makeDataCacheKey(for: request))) + #expect(cache.cachedImage(for: request, caches: [.disk]) == nil) + #expect(diskCache.cachedData(for: cache.makeDataCacheKey(for: request)) == nil) } // MARK: Remove All - func testRemoveAll() { - // GIVEN + @Test func removeAll() { + // Given let request = Test.request cache.storeCachedImage(Test.container, for: request, caches: [.memory, .disk]) - // WHEN + // When cache.removeAll() - // THEN - XCTAssertNil(cache.cachedImage(for: request)) - XCTAssertNil(memoryCache[cache.makeImageCacheKey(for: request)]) + // Then + #expect(cache.cachedImage(for: request) == nil) + #expect(memoryCache[cache.makeImageCacheKey(for: request)] == nil) - XCTAssertNil(cache.cachedImage(for: request, caches: [.disk])) - XCTAssertNil(diskCache.cachedData(for: cache.makeDataCacheKey(for: request))) + #expect(cache.cachedImage(for: request, caches: [.disk]) == nil) + #expect(diskCache.cachedData(for: cache.makeDataCacheKey(for: request)) == nil) } - func testRemoveAllWithAllStatic() { - // GIVEN + @Test func removeAllWithAllStatic() { + // Given let request = Test.request cache.storeCachedImage(Test.container, for: request, caches: [.all]) - // WHEN + // When cache.removeAll() - // THEN - XCTAssertNil(cache.cachedImage(for: request)) - XCTAssertNil(memoryCache[cache.makeImageCacheKey(for: request)]) + // Then + #expect(cache.cachedImage(for: request) == nil) + #expect(memoryCache[cache.makeImageCacheKey(for: request)] == nil) - XCTAssertNil(cache.cachedImage(for: request, caches: [.disk])) - XCTAssertNil(diskCache.cachedData(for: cache.makeDataCacheKey(for: request))) + #expect(cache.cachedImage(for: request, caches: [.disk]) == nil) + #expect(diskCache.cachedData(for: cache.makeDataCacheKey(for: request)) == nil) } // MARK: - Image Orientation #if canImport(UIKit) - func testThatImageOrientationIsPreserved() throws { - // GIVEN opaque jpeg with orientation + @Test func thatImageOrientationIsPreserved() throws { + // Given opaque jpeg with orientation let image = Test.image(named: "right-orientation", extension: "jpeg") - XCTAssertTrue(image.cgImage!.isOpaque) - XCTAssertEqual(image.imageOrientation, .right) - - // WHEN + #expect(image.cgImage!.isOpaque) + #expect(image.imageOrientation == .right) + + // When let pipeline = ImagePipeline(configuration: .withDataCache) pipeline.cache.storeCachedImage(ImageContainer(image: image), for: Test.request, caches: [.disk]) let cached = pipeline.cache.cachedImage(for: Test.request, caches: [.disk])!.image - - // THEN orientation is preserved - XCTAssertTrue(cached.cgImage!.isOpaque) - XCTAssertEqual(cached.imageOrientation, .right) - } - - func testThatImageOrientationIsPreservedForProcessedImages() throws { - // GIVEN opaque jpeg with orientation + + // Then orientation is preserved + #expect(cached.cgImage!.isOpaque) + #expect(cached.imageOrientation == .right) + } + + @Test func thatImageOrientationIsPreservedForProcessedImages() throws { + // Given opaque jpeg with orientation let image = Test.image(named: "right-orientation", extension: "jpeg") - XCTAssertTrue(image.cgImage!.isOpaque) - XCTAssertEqual(image.imageOrientation, .right) - - let resized = try XCTUnwrap(ImageProcessors.Resize(width: 100).process(image)) - - // WHEN + #expect(image.cgImage!.isOpaque) + #expect(image.imageOrientation == .right) + + let resized = try #require(ImageProcessors.Resize(width: 100).process(image)) + + // When let pipeline = ImagePipeline(configuration: .withDataCache) pipeline.cache.storeCachedImage(ImageContainer(image: resized), for: Test.request, caches: [.disk]) let cached = pipeline.cache.cachedImage(for: Test.request, caches: [.disk])!.image - - // THEN orientation is preserved - XCTAssertTrue(cached.cgImage!.isOpaque) - XCTAssertEqual(cached.imageOrientation, .right) + + // Then orientation is preserved + #expect(cached.cgImage!.isOpaque) + #expect(cached.imageOrientation == .right) } #endif } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineCallbacksTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCallbacksTests.swift new file mode 100644 index 000000000..c99830d19 --- /dev/null +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCallbacksTests.swift @@ -0,0 +1,148 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Combine +import Foundation + +@testable import Nuke + +@ImagePipelineActor +@Suite class ImagePipelineCallbacksTests { + var dataLoader: MockDataLoader! + var pipeline: ImagePipeline! + + init() { + dataLoader = MockDataLoader() + pipeline = ImagePipeline { + $0.dataLoader = dataLoader + $0.imageCache = nil + } + } + + // MARK: - Completion + + @Test func loadImageCallbackCalled() async throws { + // When + let response = try await withCheckedThrowingContinuation { continuation in + pipeline.loadImage(with: Test.request) { result in + #expect(Thread.isMainThread) + continuation.resume(with: result) + } + } + + // Then + #expect(response.image.sizeInPixels == CGSize(width: 640, height: 480)) + } + + @Test func loadDataCallbackCalled() async throws { + // When + let response = try await withCheckedThrowingContinuation { continuation in + pipeline.loadData(with: Test.request) { result in + #expect(Thread.isMainThread) + continuation.resume(with: result) + } + } + + // Then + #expect(response.data.count == 22789) + } + + // MARK: - Progress + + @Test func loadImageProgressReported() async { + // Given + let request = ImageRequest(url: Test.url) + + dataLoader.results[Test.url] = .success( + (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) + ) + + // When + let recordedProgress = Mutex<[ImageTask.Progress]>(wrappedValue: []) + await withCheckedContinuation { continuation in + pipeline.loadImage( + with: request, + progress: { _, completed, total in + // Then + #expect(Thread.isMainThread) + recordedProgress.withLock { + $0.append(ImageTask.Progress(completed: completed, total: total)) + } + }, + completion: { _ in + continuation.resume() + } + ) + } + + // Then + #expect(recordedProgress.wrappedValue == [ + ImageTask.Progress(completed: 10, total: 20), + ImageTask.Progress(completed: 20, total: 20) + ]) + } + + @Test func loadDataProgressReported() async { + // Given + let request = ImageRequest(url: Test.url) + + dataLoader.results[Test.url] = .success( + (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) + ) + + // When + let recordedProgress = Mutex<[ImageTask.Progress]>(wrappedValue: []) + await withCheckedContinuation { continuation in + pipeline.loadData( + with: request, + progress: { completed, total in + // Then + #expect(Thread.isMainThread) + recordedProgress.withLock { + $0.append(ImageTask.Progress(completed: completed, total: total)) + } + }, + completion: { _ in + continuation.resume() + } + ) + } + + // Then + #expect(recordedProgress.wrappedValue == [ + ImageTask.Progress(completed: 10, total: 20), + ImageTask.Progress(completed: 20, total: 20) + ]) + } + + // MARK: Error Handling + + @Test func dataLoadingFailedErrorReturned() async { + // Given + let dataLoader = MockDataLoader() + let pipeline = ImagePipeline { + $0.dataLoader = dataLoader + $0.imageCache = nil + } + + let expectedError = NSError(domain: "t", code: 23, userInfo: nil) + dataLoader.results[Test.url] = .failure(expectedError) + + // When + let error = await withCheckedContinuation { continuation in + pipeline.loadImage(with: Test.request) { result in + switch result { + case .success: + Issue.record("Unexpected success") + case .failure(let error): + continuation.resume(returning: error) + } + } + } + + // Then + #expect(error == .dataLoadingFailed(error: expectedError)) + } +} diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift index b655b4100..d804f79af 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift @@ -2,17 +2,18 @@ // // Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -import XCTest +import Foundation +import Testing + @testable import Nuke -class ImagePipelineCoalescingTests: XCTestCase { +@ImagePipelineActor +@Suite struct ImagePipelineCoalescingTests { var dataLoader: MockDataLoader! var pipeline: ImagePipeline! var observations = [NSKeyValueObservation]() - override func setUp() { - super.setUp() - + init() { dataLoader = MockDataLoader() pipeline = ImagePipeline { $0.dataLoader = dataLoader @@ -20,63 +21,54 @@ class ImagePipelineCoalescingTests: XCTestCase { } } - // MARK: - Deduplication - - func testDeduplicationGivenSameURLDifferentSameProcessors() { + // MARK: - Coalescing + @Test func coalescingGivenSameURLDifferentSameProcessors() async throws { // Given requests with the same URLs and same processors let processors = MockProcessorFactory() let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) // When loading images for those requests + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + let (image1, image2) = try await (task1, task2) + // Then the correct proessors are applied. - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: request1) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) - } - expect(pipeline).toLoadImage(with: request2) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) - } - } + #expect(image1.nk_test_processorIDs == ["1"]) + #expect(image2.nk_test_processorIDs == ["1"]) - wait { _ in - // Then the original image is loaded once, and the image is processed - // also only once - XCTAssertEqual(processors.numberOfProcessorsApplied, 1) - XCTAssertEqual(self.dataLoader.createdTaskCount, 1) - } + // Then the original image is loaded once + #expect(dataLoader.createdTaskCount == 1) + + // Then the image is processed once + #expect(processors.numberOfProcessorsApplied == 1) } - func testDeduplicationGivenSameURLDifferentProcessors() { + @Test func coalescingGivenSameURLDifferentProcessors() async throws { // Given requests with the same URLs but different processors let processors = MockProcessorFactory() let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "2")]) // When loading images for those requests + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + let (image1, image2) = try await (task1, task2) + // Then the correct proessors are applied. - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: request1) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) - } - expect(pipeline).toLoadImage(with: request2) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["2"]) - } - } + // Then the correct proessors are applied. + #expect(image1.nk_test_processorIDs == ["1"]) + #expect(image2.nk_test_processorIDs == ["2"]) - wait { _ in - // Then the original image is loaded once, but both processors are applied - XCTAssertEqual(processors.numberOfProcessorsApplied, 2) - XCTAssertEqual(self.dataLoader.createdTaskCount, 1) - } + // Then the original image is loaded once + #expect(dataLoader.createdTaskCount == 1) + + // Then the image is processed twice + #expect(processors.numberOfProcessorsApplied == 2) } - func testDeduplicationGivenSameURLDifferentProcessorsOneEmpty() { + @Test func coalescingGivenSameURLDifferentProcessorsOneEmpty() async throws { // Given requests with the same URLs but different processors where one // processor is empty let processors = MockProcessorFactory() @@ -86,148 +78,136 @@ class ImagePipelineCoalescingTests: XCTestCase { request2.processors = [] // When loading images for those requests + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + let (image1, image2) = try await (task1, task2) + // Then the correct proessors are applied. - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: request1) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) - } - expect(pipeline).toLoadImage(with: request2) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], []) - } - } + #expect(image1.nk_test_processorIDs == ["1"]) + #expect(image2.nk_test_processorIDs == []) - wait { _ in - // Then - // The original image is loaded once, the first processor is applied - XCTAssertEqual(processors.numberOfProcessorsApplied, 1) - XCTAssertEqual(self.dataLoader.createdTaskCount, 1) - } - } + // Then the original image is loaded once + #expect(dataLoader.createdTaskCount == 1) - func testNoDeduplicationGivenNonEquivalentRequests() { + // Then the image is processed once + #expect(processors.numberOfProcessorsApplied == 1) + } + @Test func noCoalescingGivenNonEquivalentRequests() async throws { 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) { - expect(pipeline).toLoadImage(with: request1) - expect(pipeline).toLoadImage(with: request2) - } + // When loading images for those requests + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + _ = try await (task1, task2) + + // Then no coalescing happens + #expect(dataLoader.createdTaskCount == 2) + } + + // MARK: - Caching - wait { _ in - XCTAssertEqual(self.dataLoader.createdTaskCount, 2) + @Test func memoryCacheLookupPerformedBeforeCoalescing() async throws { + // Given + let cache = MockImageCache() + let pipeline = pipeline.reconfigured { + $0.imageCache = cache } + + dataLoader.isSuspended = true + + // When one request is pending + let exepctation = pipeline.configuration.dataLoadingQueue.expectJobAdded() + pipeline.imageTask(with: Test.request) + await exepctation.wait() + + // When image is added to memory cache + cache[Test.request] = Test.container + + // Then when second request is started the image is returned immediatelly + _ = try await pipeline.image(for: Test.request) } // MARK: - Scale #if !os(macOS) - func testOverridingImageScale() throws { - // GIVEN requests with the same URLs but one accesses thumbnail + @Test func overridingImageScale() async throws { + // Given requests with the same URLs but one accesses thumbnail let request1 = ImageRequest(url: Test.url, userInfo: [.scaleKey: 2]) let request2 = ImageRequest(url: Test.url, userInfo: [.scaleKey: 3]) - // WHEN loading images for those requests - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: request1) { result in - // THEN - guard let image = result.value?.image else { return XCTFail() } - XCTAssertEqual(image.scale, 2) - } - expect(pipeline).toLoadImage(with: request2) { result in - // THEN - guard let image = result.value?.image else { return XCTFail() } - XCTAssertEqual(image.scale, 3) - } - } + // When loading images for those requests + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + let (image1, image2) = try await (task1, task2) - wait() + // Then correct scale values are applied (despite coalescing) + #expect(image1.scale == 2) + #expect(image2.scale == 3) - XCTAssertEqual(self.dataLoader.createdTaskCount, 1) + // Then images is loaded once + #expect(dataLoader.createdTaskCount == 1) } #endif // MARK: - Thumbnail - func testDeduplicationGivenSameURLButDifferentThumbnailOptions() { - // GIVEN requests with the same URLs but one accesses thumbnail + @Test func coalescingGivenSameURLButDifferentThumbnailOptions() async throws { + // Given requests with the same URLs but one accesses thumbnail let request1 = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions(maxPixelSize: 400)]) let request2 = ImageRequest(url: Test.url) - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - - // WHEN loading images for those requests - expect(pipeline).toLoadImage(with: request1) { result in - // THEN - guard let image = result.value?.image else { return XCTFail() } - XCTAssertEqual(image.sizeInPixels, CGSize(width: 400, height: 300)) - } - expect(pipeline).toLoadImage(with: request2) { result in - // THEN - guard let image = result.value?.image else { return XCTFail() } - XCTAssertEqual(image.sizeInPixels, CGSize(width: 640.0, height: 480.0)) - } - - } + // When loading images for those requests + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + let (image1, image2) = try await (task1, task2) - wait { _ in - // THEN the image data is fetched once - XCTAssertEqual(self.dataLoader.createdTaskCount, 1) - } + // Then the correct thumbnails are generated (despite coalescing) + #expect(image1.sizeInPixels == CGSize(width: 400, height: 300)) + #expect(image2.sizeInPixels == CGSize(width: 640, height: 480)) } - func testDeduplicationGivenSameURLButDifferentThumbnailOptionsReversed() { - // GIVEN requests with the same URLs but one accesses thumbnail + @Test func coelascingGivenSameURLButDifferentThumbnailOptionsReversed() async throws { + // Given requests with the same URLs but one accesses thumbnail // (in this test, order is reversed) let request1 = ImageRequest(url: Test.url) let request2 = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions(maxPixelSize: 400)]) - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - // WHEN loading images for those requests - expect(pipeline).toLoadImage(with: request1) { result in - // THEN - guard let image = result.value?.image else { return XCTFail() } - XCTAssertEqual(image.sizeInPixels, CGSize(width: 640.0, height: 480.0)) - } - expect(pipeline).toLoadImage(with: request2) { result in - // THEN - guard let image = result.value?.image else { return XCTFail() } - XCTAssertEqual(image.sizeInPixels, CGSize(width: 400, height: 300)) - } - } + // When loading images for those requests + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + let (image1, image2) = try await (task1, task2) - wait { _ in - // THEN the image data is fetched once - XCTAssertEqual(self.dataLoader.createdTaskCount, 1) - } + // Then the correct thumbnails are generated (despite coalescing) + #expect(image1.sizeInPixels == CGSize(width: 640, height: 480)) + #expect(image2.sizeInPixels == CGSize(width: 400, height: 300)) + + // Then the image data is fetched once + #expect(self.dataLoader.createdTaskCount == 1) } // MARK: - Processing - func testProcessorsAreDeduplicated() { + @Test func processorsAreDeduplicated() async throws { // Given - // Make sure we don't start processing when some requests haven't - // started yet. let processors = MockProcessorFactory() - let queueObserver = OperationQueueObserver(queue: pipeline.configuration.imageProcessingQueue) // When - suspendDataLoading(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")])) - } + let expectation = pipeline.configuration.imageProcessingQueue.expectJobsAdded(count: 2) - // When/Then - wait { _ in - XCTAssertEqual(queueObserver.operations.count, 2) - XCTAssertEqual(processors.numberOfProcessorsApplied, 2) - } + async let task1 = pipeline.image(for: ImageRequest(url: Test.url, processors: [processors.make(id: "1")])) + async let task2 = pipeline.image(for: ImageRequest(url: Test.url, processors: [processors.make(id: "2")])) + async let task3 = pipeline.image(for: ImageRequest(url: Test.url, processors: [processors.make(id: "1")])) + + _ = try await [task1, task2, task3] + + // Then + await expectation.wait() + #expect(processors.numberOfProcessorsApplied == 2) } - func testSubscribingToExisingSessionWhenProcessingAlreadyStarted() { + @Test func subscribingToExisingTaskWhenProcessingAlreadyStarted() async throws { // Given let queue = pipeline.configuration.imageProcessingQueue queue.isSuspended = true @@ -236,39 +216,28 @@ class ImagePipelineCoalescingTests: XCTestCase { let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) - let queueObserver = OperationQueueObserver(queue: queue) - - let expectation = self.expectation(description: "Second request completed") - - queueObserver.didAddOperation = { _ in - queueObserver.didAddOperation = nil - - // When loading image with the same request and processing for - // the first request has already started - self.pipeline.loadImage(with: request2) { result in - let image = result.value?.image - // Then the image is still loaded and processors is applied - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) - expectation.fulfill() - } - queue.isSuspended = false + // When first task is stated and processing operation is registered + let expectation = queue.expectJobAdded() + let task = Task { + try await pipeline.image(for: request1) } + await expectation.wait() + queue.isSuspended = false - expect(pipeline).toLoadImage(with: request1) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) - } + let image2 = try await pipeline.image(for: request2) + let image1 = try await task.value - wait { _ in - // Then the original image is loaded only once, but processors are - // applied twice - XCTAssertEqual(self.dataLoader.createdTaskCount, 1) - XCTAssertEqual(processors.numberOfProcessorsApplied, 1) - XCTAssertEqual(queueObserver.operations.count, 1) - } + // Then the images is still loaded and processors is applied + #expect(image1.nk_test_processorIDs == ["1"]) + #expect(image2.nk_test_processorIDs == ["1"]) + + // Then the original image is loaded only once, but processors are + // applied twice + #expect(dataLoader.createdTaskCount == 1) + #expect(processors.numberOfProcessorsApplied == 1) } - func testCorrectImageIsStoredInMemoryCache() { + @Test func correctImageIsStoredInMemoryCache() async throws { let imageCache = MockImageCache() let pipeline = ImagePipeline { $0.dataLoader = dataLoader @@ -280,193 +249,191 @@ class ImagePipelineCoalescingTests: XCTestCase { let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "2")]) - // When loading images for those requests - // Then the correct proessors are applied. - expect(pipeline).toLoadImage(with: request1) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) - } - expect(pipeline).toLoadImage(with: request2) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["2"]) - } - wait() - // Then - XCTAssertNotNil(imageCache[request1]) - XCTAssertEqual(imageCache[request1]?.image.nk_test_processorIDs ?? [], ["1"]) - XCTAssertNotNil(imageCache[request2]) - XCTAssertEqual(imageCache[request2]?.image.nk_test_processorIDs ?? [], ["2"]) + // When loading images for those requests + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + let (image1, image2) = try await (task1, task2) + + // Then the correct processors are applied. + #expect(image1.nk_test_processorIDs == ["1"]) + #expect(image2.nk_test_processorIDs == ["2"]) + + // Then the images are stored in memory cache + #expect(imageCache[request1] != nil) + #expect(imageCache[request1]?.image.nk_test_processorIDs == ["1"]) + #expect(imageCache[request2] != nil) + #expect(imageCache[request2]?.image.nk_test_processorIDs == ["2"]) } // MARK: - Cancellation - func testCancellation() { + @Test func cancellation() async { dataLoader.queue.isSuspended = true // Given two equivalent requests - - // When both tasks are cancelled the image loading session is cancelled - - _ = expectNotification(MockDataLoader.DidStartTask, object: dataLoader) - let task1 = pipeline.loadImage(with: Test.request) { _ in } - let task2 = pipeline.loadImage(with: Test.request) { _ in } - wait() // wait until the tasks is started or we might be cancelling non-existing task - - _ = expectNotification(MockDataLoader.DidCancelTask, object: dataLoader) + // When both tasks are cancelled + let expectation1 = AsyncExpectation(notification: MockDataLoader.DidStartTask, object: dataLoader) + let task1 = pipeline.imageTask(with: Test.request) + let task2 = pipeline.imageTask(with: Test.request) + _ = await expectation1.wait() // wait until the tasks is started or we might be cancelling non-existing task + + // Then the image task is cancelled + let expectation2 = AsyncExpectation(notification: MockDataLoader.DidCancelTask, object: dataLoader) task1.cancel() task2.cancel() - wait() + _ = await expectation2.wait() } - func testCancellatioOnlyCancelOneTask() { + @Test func cancellationCancelOnlyOneTask() async throws { dataLoader.queue.isSuspended = true let task1 = pipeline.loadImage(with: Test.request) { _ in - XCTFail() + Issue.record() } - expect(pipeline).toLoadImage(with: Test.request) + let task2 = pipeline.imageTask(with: Test.request) // When cancelling only only only one of the tasks task1.cancel() - // Then the image is still loaded - + // Then the image for task2 is still loaded dataLoader.queue.isSuspended = false - wait() + _ = try await task2.image } - func testProcessingOperationsAreCancelledSeparately() { - dataLoader.queue.isSuspended = true - - // Given + @Test func processingOperationsAreCancelledSeparately() async { let queue = pipeline.configuration.imageProcessingQueue queue.isSuspended = true - // When/Then - let operations = expect(queue).toEnqueueOperationsWithCount(2) - + // Given two requests with different processors let processors = MockProcessorFactory() let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "2")]) - _ = pipeline.loadImage(with: request1) { _ in } - let task2 = pipeline.loadImage(with: request2) { _ in } - - dataLoader.queue.isSuspended = false - - wait() + // When + let expectation1 = queue.expectJobAdded() + pipeline.imageTask(with: request1) + _ = await expectation1.wait() - // When/Then - let expectation = self.expectation(description: "One operation got cancelled") - for operation in operations.operations { - // Pass the same expectation into both operations, only - // one should get cancelled. - expect(operation).toCancel(with: expectation) - } + let expectation2 = queue.expectJobAdded() + let task2 = pipeline.imageTask(with: request2) + let item2 = await expectation2.wait() + // When + let expectation3 = queue.expectJobCancelled(item2) task2.cancel() - wait() + await expectation3.wait() } // MARK: - Priority - func testProcessingOperationPriorityUpdated() { + @Test func processingOperationPriorityUpdated() async { // Given - dataLoader.queue.isSuspended = true let queue = pipeline.configuration.imageProcessingQueue queue.isSuspended = true - // Given - let operations = expect(queue).toEnqueueOperationsWithCount(1) + // When + let expectation1 = queue.expectJobAdded() + var request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")], priority: .low) + pipeline.imageTask(with: request) - pipeline.loadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")], priority: .low)) { _ in } + // Then the item is created with a low priority + let job = await expectation1.wait() + #expect(job.priority == .low) - dataLoader.queue.isSuspended = false - wait { _ in - XCTAssertEqual(operations.operations.first!.queuePriority, .low) - } + // When new operation is added with a higher priority + let expectation2 = queue.expectPriorityUpdated(for: job) + request.priority = .high + let task = pipeline.imageTask(with: request) + let newPriority1 = await expectation2.wait() - // When/Then - expect(operations.operations.first!).toUpdatePriority(from: .low, to: .high) - let task = pipeline.loadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")], priority: .high)) { _ in } - wait() + // Then priority is raised + #expect(newPriority1 == .high) - // When/Then - expect(operations.operations.first!).toUpdatePriority(from: .high, to: .low) + // When + let expectation3 = queue.expectPriorityUpdated(for: job) task.priority = .low - wait() + + // Then priority is lowered again + let newPriority2 = await expectation3.wait() + #expect(newPriority2 == .low) } - func testProcessingOperationPriorityUpdatedWhenCancellingTask() { + @Test func processingOperationPriorityUpdatedWhenCancellingTask() async { // Given - dataLoader.queue.isSuspended = true let queue = pipeline.configuration.imageProcessingQueue queue.isSuspended = true - // Given - let operations = expect(queue).toEnqueueOperationsWithCount(1) - pipeline.loadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")], priority: .low)) { _ in } - dataLoader.queue.isSuspended = false - wait() + // When + let expectation1 = queue.expectJobAdded() + var request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")], priority: .low) + pipeline.imageTask(with: request) - // Given - // Note: adding a second task separately because we should guarantee - // that both are subscribed by the time we start our test. - expect(operations.operations.first!).toUpdatePriority(from: .low, to: .high) - let task = pipeline.loadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")], priority: .high)) { _ in } - wait() - - // When/Then - expect(operations.operations.first!).toUpdatePriority(from: .high, to: .low) + // Then the item is created with a low priority + let job = await expectation1.wait() + #expect(job.priority == .low) + + // When new operation is added with a higher priority + let expectation2 = queue.expectPriorityUpdated(for: job) + request.priority = .high + let task = pipeline.imageTask(with: request) + let newPriority1 = await expectation2.wait() + + // Then priority is raised + #expect(newPriority1 == .high) + + // When high-priority task is cancelled + let expectation3 = queue.expectPriorityUpdated(for: job) task.cancel() - wait() + + // Then priority is lowered again + let newPriority2 = await expectation3.wait() + #expect(newPriority2 == .low) } // MARK: - Loading Data - func testThatLoadsDataOnceWhenLoadingDataAndLoadingImage() { - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: Test.request) - expect(pipeline).toLoadData(with: Test.request) - } - wait() + @Test func thatLoadsDataOnceWhenLoadingDataAndLoadingImage() async throws { + // When + async let image = pipeline.image(for: Test.request) + async let data = pipeline.data(for: Test.request) + _ = try await (image, data) - XCTAssertEqual(dataLoader.createdTaskCount, 1) + // Then + #expect(dataLoader.createdTaskCount == 1) } // MARK: - Misc - func testProgressIsReported() { + @Test func progressIsReported() async { // Given dataLoader.results[Test.url] = .success( (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) ) - // When/Then - suspendDataLoading(for: pipeline, expectedRequestCount: 3) { + // When + await withTaskGroup(of: Void.self) { group in for _ in 0..<3 { let request = Test.request - - let expectedProgress = expectProgress([(10, 20), (20, 20)]) - - pipeline.loadImage( - with: request, - progress: { _, completed, total in - XCTAssertTrue(Thread.isMainThread) - expectedProgress.received((completed, total)) - }, - completion: { _ in } - ) + group.addTask { + let task = pipeline.imageTask(with: request) + // Then + var expected: [ImageTask.Progress] = [.init(completed: 10, total: 20), .init(completed: 20, total: 20)].reversed() + for await progress in task.progress { + if let value = expected.popLast() { + #expect(value == progress) + } else { + Issue.record() + } + } + } } } - - wait() } - func testDisablingDeduplication() { + @Test func disablingDeduplication() async throws { // Given let pipeline = ImagePipeline { $0.imageCache = nil @@ -474,206 +441,12 @@ class ImagePipelineCoalescingTests: XCTestCase { $0.isTaskCoalescingEnabled = false } - // When/Then - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: Test.request) - expect(pipeline).toLoadImage(with: Test.request) - } - wait { _ in - XCTAssertEqual(self.dataLoader.createdTaskCount, 2) - } - } -} - -class ImagePipelineProcessingDeduplicationTests: XCTestCase { - var dataLoader: MockDataLoader! - var pipeline: ImagePipeline! - var observations = [NSKeyValueObservation]() - - override func setUp() { - super.setUp() - - dataLoader = MockDataLoader() - pipeline = ImagePipeline { - $0.dataLoader = dataLoader - $0.imageCache = nil - } - } - - func testEachProcessingStepIsDeduplicated() { - // Given requests with the same URLs but different processors - let processors = MockProcessorFactory() - let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) - let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "1"), processors.make(id: "2")]) - - // When - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: request1) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) - } - expect(pipeline).toLoadImage(with: request2) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1", "2"]) - } - } - - // Then the processor "1" is only applied once - wait { _ in - XCTAssertEqual(processors.numberOfProcessorsApplied, 2) - } - } - - func testEachFinalProcessedImageIsStoredInMemoryCache() { - let cache = MockImageCache() - var conf = pipeline.configuration - conf.imageCache = cache - pipeline = ImagePipeline(configuration: conf) - - // Given requests with the same URLs but different processors - let processors = MockProcessorFactory() - let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) - 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) { - expect(pipeline).toLoadImage(with: request1) - expect(pipeline).toLoadImage(with: request2) - } - - // Then - wait { _ in - XCTAssertNotNil(cache[request1]) - XCTAssertNotNil(cache[request2]) - XCTAssertNil(cache[ImageRequest(url: Test.url, processors: [processors.make(id: "1"), processors.make(id: "2")])]) - } - } - - func testWhenApplingMultipleImageProcessorsIntermediateMemoryCachedResultsAreUsed() { - let cache = MockImageCache() - var conf = pipeline.configuration - conf.imageCache = cache - pipeline = ImagePipeline(configuration: conf) - - let factory = MockProcessorFactory() - - // Given - cache[ImageRequest(url: Test.url, processors: [factory.make(id: "1"), factory.make(id: "2")])] = Test.container - - // When - let request = ImageRequest(url: Test.url, processors: [factory.make(id: "1"), factory.make(id: "2"), factory.make(id: "3")]) - expect(pipeline).toLoadImage(with: request) { result in - guard let image = result.value?.image else { - return XCTFail("Expected image to be loaded successfully") - } - XCTAssertEqual(image.nk_test_processorIDs, ["3"], "Expected only the last processor to be applied") - } - - // Then - wait { _ in - XCTAssertEqual(self.dataLoader.createdTaskCount, 0, "Expected no data task to be performed") - XCTAssertEqual(factory.numberOfProcessorsApplied, 1, "Expected only one processor to be applied") - } - } - - func testWhenApplingMultipleImageProcessorsIntermediateDataCacheResultsAreUsed() { - // Given - let dataCache = MockDataCache() - dataCache.store[Test.url.absoluteString + "12"] = Test.data - - pipeline = pipeline.reconfigured { - $0.dataCache = dataCache - } - - // When - let factory = MockProcessorFactory() - let request = ImageRequest(url: Test.url, processors: [factory.make(id: "1"), factory.make(id: "2"), factory.make(id: "3")]) - expect(pipeline).toLoadImage(with: request) { result in - guard let image = result.value?.image else { - return XCTFail("Expected image to be loaded successfully") - } - XCTAssertEqual(image.nk_test_processorIDs, ["3"], "Expected only the last processor to be applied") - } - - wait { _ in - XCTAssertEqual(self.dataLoader.createdTaskCount, 0, "Expected no data task to be performed") - XCTAssertEqual(factory.numberOfProcessorsApplied, 1, "Expected only one processor to be applied") - } - } - - func testThatProcessingDeduplicationCanBeDisabled() { - // Given - pipeline = pipeline.reconfigured { - $0.isTaskCoalescingEnabled = false - } - - // Given requests with the same URLs but different processors - let processors = MockProcessorFactory() - let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) - let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "1"), processors.make(id: "2")]) - - // When - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: request1) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) - } - expect(pipeline).toLoadImage(with: request2) { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1", "2"]) - } - } - - // Then the processor "1" is applied twice - wait { _ in - XCTAssertEqual(processors.numberOfProcessorsApplied, 3) - } - } - - // TODO: pipeline.queue.sync {} is no longer enough - func _testThatDataOnlyLoadedOnceWithDifferentCachePolicy() { - // Given - let dataCache = MockDataCache() - pipeline = pipeline.reconfigured { - $0.dataCache = dataCache - } - - // When - func makeRequest(options: ImageRequest.Options) -> ImageRequest { - ImageRequest(urlRequest: URLRequest(url: Test.url), options: options) - } - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: makeRequest(options: [])) - expect(pipeline).toLoadImage(with: makeRequest(options: [.reloadIgnoringCachedData])) - } - - // Then - wait { _ in - XCTAssertEqual(self.dataLoader.createdTaskCount, 1, "Expected only one data task to be performed") - } - } - - func testThatDataOnlyLoadedOnceWithDifferentCachePolicyPassingURL() { - // Given - let dataCache = MockDataCache() - pipeline = pipeline.reconfigured { - $0.dataCache = dataCache - } - - // When - // - One request reloading cache data, another one not - func makeRequest(options: ImageRequest.Options) -> ImageRequest { - ImageRequest(urlRequest: URLRequest(url: Test.url), options: options) - } - - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: makeRequest(options: [])) - expect(pipeline).toLoadImage(with: makeRequest(options: [.reloadIgnoringCachedData])) - } + // When loading images for those requests + async let task1 = pipeline.image(for: Test.url) + async let task2 = pipeline.image(for: Test.url) + _ = try await (task1, task2) // Then - wait { _ in - XCTAssertEqual(self.dataLoader.createdTaskCount, 1, "Expected only one data task to be performed") - } + #expect(dataLoader.createdTaskCount == 2) } } 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/ImagePipelineDataCachePolicyTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCachePolicyTests.swift new file mode 100644 index 000000000..5fd49fd90 --- /dev/null +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCachePolicyTests.swift @@ -0,0 +1,493 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing + +@testable import Nuke + +@ImagePipelineActor +@Suite class ImagePipelineDataCachePolicyTests { + var dataLoader: MockDataLoader! + var dataCache: MockDataCache! + var pipeline: ImagePipeline! + var encoder: MockImageEncoder! + var processorFactory: MockProcessorFactory! + var request: ImageRequest! + + init() async throws { + dataCache = MockDataCache() + dataLoader = MockDataLoader() + let encoder = MockImageEncoder(result: Test.data(name: "fixture-tiny", extension: "jpeg")) + self.encoder = encoder + + pipeline = ImagePipeline { + $0.dataLoader = dataLoader + $0.dataCache = dataCache + $0.imageCache = nil + $0.makeImageEncoder = { _ in encoder } + $0.debugIsSyncImageEncoding = true + } + + processorFactory = MockProcessorFactory() + + request = ImageRequest(url: Test.url, processors: [processorFactory.make(id: "1")]) + } + + // MARK: - Basics + + @Test func processedImageLoadedFromDataCache() async throws { + // Given processed image data stored in data cache + dataLoader.queue.isSuspended = true + dataCache.store[Test.url.absoluteString + "1"] = Test.data + + // When/Then + _ = try await pipeline.image(for: request) + + // Then + #expect(processorFactory.numberOfProcessorsApplied == 0, "Expected no processors to be applied") + } + +#if !os(macOS) + @Test func processedImageIsDecompressed() async throws { + // Given processed image data stored in data cache + dataLoader.queue.isSuspended = true + dataCache.store[Test.url.absoluteString + "1"] = Test.data + + // When + let image = try await pipeline.image(for: request) + + // Then + #expect(ImageDecompression.isDecompressionNeeded(for: image) == nil) + } + + @Test func processedImageIsStoredInMemoryCache() async throws { + // Given processed image data stored in data cache + let cache = MockImageCache() + pipeline = pipeline.reconfigured { + $0.imageCache = cache + } + dataLoader.queue.isSuspended = true + dataCache.store[Test.url.absoluteString + "1"] = Test.data + + // When + _ = try await pipeline.image(for: request) + + // Then decompressed image is stored in disk cache + let container = cache[request] + #expect(container != nil) + + let image = try #require(container?.image) + #expect(ImageDecompression.isDecompressionNeeded(for: image) == nil) + } + + @Test func processedImageNotDecompressedWhenDecompressionDisabled() async throws { + // Given pipeline with decompression disabled + pipeline = pipeline.reconfigured { + $0.isDecompressionEnabled = false + } + + // Given processed image data stored in data cache + dataLoader.queue.isSuspended = true + dataCache.store[Test.url.absoluteString + "1"] = Test.data + + // When + let image = try await pipeline.image(for: request) + + // Then + let isDecompressionNeeded = ImageDecompression.isDecompressionNeeded(for: image) + #expect(isDecompressionNeeded == true, "Expected image to still be marked as non decompressed") + } +#endif + + // MARK: DataCachPolicy.automatic + + @Test func policyAutomaticGivenRequestWithProcessors() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .automatic + } + + // Given request with a processor + let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) + + // When + _ = try await pipeline.image(for: request) + + // Then encoded processed image is stored in disk cache + #expect(encoder.encodeCount == 1) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) + } + + @Test func policyAutomaticGivenRequestWithoutProcessors() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .automatic + } + + // Given request without a processor + let request = ImageRequest(url: Test.url) + + // When + _ = try await pipeline.image(for: request) + + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) + } + + @Test func policyAutomaticGivenTwoRequests() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .automatic + } + + // When + async let task1 = pipeline.image(for: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) + async let task2 = pipeline.image(for: ImageRequest(url: Test.url)) + _ = try await (task1, task2) + + // Then + // encoded processed image is stored in disk cache + // original image data is stored in disk cache + #expect(encoder.encodeCount == 1) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") != nil) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 2) + #expect(dataCache.store.count == 2) + } + + @Test func policyAutomaticGivenOriginalImageInMemoryCache() async throws { + // Given + let imageCache = MockImageCache() + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .automatic + $0.imageCache = imageCache + } + imageCache[ImageRequest(url: Test.url)] = Test.container + + // When + _ = try await pipeline.image(for: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) + + // Then + // encoded processed image is stored in disk cache + #expect(encoder.encodeCount == 1) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) + #expect(dataLoader.createdTaskCount == 0) + } + + // MARK: DataCachPolicy.storeEncodedImages + + @Test func policyStoreEncodedImagesGivenRequestWithProcessors() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeEncodedImages + } + + // Given request with a processor + let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) + + // When + _ = try await pipeline.image(for: request) + + // Then encoded processed image is stored in disk cache + #expect(encoder.encodeCount == 1) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) + } + + @Test func policyStoreEncodedImagesGivenRequestWithoutProcessors() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeEncodedImages + } + + // Given request without a processor + let request = ImageRequest(url: Test.url) + + // When + _ = try await pipeline.image(for: request) + + // Then + #expect(encoder.encodeCount == 1) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) + } + + @Test func policyStoreEncodedImagesGivenTwoRequests() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeEncodedImages + } + + // When + async let task1 = pipeline.image(for: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) + async let task2 = pipeline.image(for: ImageRequest(url: Test.url)) + _ = try await (task1, task2) + + // Then + // encoded processed image is stored in disk cache + // encoded original image is stored in disk cache + #expect(encoder.encodeCount == 2) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") != nil) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 2) + #expect(dataCache.store.count == 2) + } + + // MARK: DataCachPolicy.storeOriginalData + + @Test func policyStoreOriginalDataGivenRequestWithProcessors() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeOriginalData + } + + // Given request with a processor + let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) + + // When + _ = try await pipeline.image(for: request) + + // Then encoded processed image is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) + } + + @Test func policyStoreOriginalDataGivenRequestWithoutProcessors() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeOriginalData + } + + // Given request without a processor + let request = ImageRequest(url: Test.url) + + // When + _ = try await pipeline.image(for: request) + + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) + } + + @Test func policyStoreOriginalDataGivenTwoRequests() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeOriginalData + } + + // When + async let task1 = pipeline.image(for: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) + async let task2 = pipeline.image(for: ImageRequest(url: Test.url)) + _ = try await (task1, task2) + + // Then + // encoded processed image is stored in disk cache + // encoded original image is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) + } + + // MARK: DataCachPolicy.storeAll + + @Test func policyStoreAllGivenRequestWithProcessors() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeAll + } + + // Given request with a processor + let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) + + // When + _ = try await pipeline.image(for: request) + + // Then encoded processed image is stored in disk cache and + // original image data stored in disk cache + #expect(encoder.encodeCount == 1) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") != nil) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 2) + #expect(dataCache.store.count == 2) + } + + @Test func policyStoreAllGivenRequestWithoutProcessors() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeAll + } + + // Given request without a processor + let request = ImageRequest(url: Test.url) + + // When + _ = try await pipeline.image(for: request) + + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) + } + + @Test func policyStoreAllGivenTwoRequests() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeAll + } + + // When + async let task1 = pipeline.image(for: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) + async let task2 = pipeline.image(for: ImageRequest(url: Test.url)) + _ = try await (task1, task2) + + // Then + // encoded processed image is stored in disk cache + // original image data is stored in disk cache + #expect(encoder.encodeCount == 1) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") != nil) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 2) + #expect(dataCache.store.count == 2) + } + + // MARK: Local Resources + + @Test func imagesFromLocalStorageNotCached() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .automatic + } + + // Given request without a processor + let request = ImageRequest(url: Test.url(forResource: "fixture", extension: "jpeg")) + + // When + _ = try await pipeline.image(for: request) + + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.writeCount == 0) + #expect(dataCache.store.count == 0) + } + + @Test func processedImagesFromLocalStorageAreNotCached() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .automatic + } + + // Given request with a processor + let request = ImageRequest(url: Test.url(forResource: "fixture", extension: "jpeg") ,processors: [.resize(width: 100)]) + + // When + _ = try await pipeline.image(for: request) + + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.writeCount == 0) + #expect(dataCache.store.count == 0) + } + + @Test func imagesFromMemoryNotCached() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .automatic + } + + // Given request without a processor + let request = ImageRequest(url: Test.url(forResource: "fixture", extension: "jpeg")) + + // When + _ = try await pipeline.image(for: request) + + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.writeCount == 0) + #expect(dataCache.store.count == 0) + } + + // TODO: this fails because there is too few thread hops + @Test func imagesFromData() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .automatic + } + + // Given request without a processor + let data = Test.data(name: "fixture", extension: "jpeg") + let url = URL(string: "data:image/jpeg;base64,\(data.base64EncodedString())") + let request = ImageRequest(url: url) + + // When + _ = try await pipeline.image(for: request) + + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.writeCount == 0) + #expect(dataCache.store.count == 0) + } + + // MARK: Misc + + @Test func setCustomImageEncoder() async throws { + struct MockImageEncoder: ImageEncoding, @unchecked Sendable { + let closure: (PlatformImage) -> Data? + + func encode(_ image: PlatformImage) -> Data? { + return closure(image) + } + } + + // Given + var isCustomEncoderCalled = false + let encoder = MockImageEncoder { _ in + isCustomEncoderCalled = true + return nil + } + + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .automatic + $0.makeImageEncoder = { _ in + return encoder + } + } + + // When + _ = try await pipeline.image(for: request) + + // Then + #expect(isCustomEncoderCalled) + #expect(self.dataCache.cachedData(for: Test.url.absoluteString + "1") == nil, "Expected processed image data to not be stored") + } + + // MARK: Integration with Thumbnail Feature + + @Test func originalDataStoredWhenThumbnailRequested() async throws { + // Given + let options = ImageRequest.ThumbnailOptions(maxPixelSize: 400) + let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: options]) + + // When + _ = try await pipeline.image(for: request) + + // Then + #expect(dataCache.containsData(for: "http://test.com/example.jpeg")) + } +} diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift index 0e07d435c..04b8ed704 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift @@ -1,757 +1,252 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest +import Foundation +import Testing @testable import Nuke -class ImagePipelineDataCachingTests: XCTestCase { +@ImagePipelineActor +@Suite class ImagePipelineDataCachingTests { var dataLoader: MockDataLoader! var dataCache: MockDataCache! var pipeline: ImagePipeline! var processorFactory: MockProcessorFactory! - - override func setUp() { - super.setUp() - + + init() { dataCache = MockDataCache() dataLoader = MockDataLoader() - + pipeline = ImagePipeline { $0.dataLoader = dataLoader $0.dataCache = dataCache $0.imageCache = nil } } - + // MARK: - Basics - - func testImageIsLoaded() { - // Given + + @Test func imageIsLoaded() async throws { + // Given image in cache dataLoader.queue.isSuspended = true dataCache.store[Test.url.absoluteString] = Test.data - - // When/Then - expect(pipeline).toLoadImage(with: Test.request) - wait() + + // Then image is loaded + _ = try await pipeline.image(for: Test.request) } - - func testDataIsStoredInCache() { + + @Test func dataIsStoredInCache() async throws { // When - expect(pipeline).toLoadImage(with: Test.request) - - // Then - wait { _ in - XCTAssertFalse(self.dataCache.store.isEmpty) - } + _ = try await pipeline.image(for: Test.request) + + // Then data is stored in disk cache + #expect(!dataCache.store.isEmpty) } - - func testThumbnailOptionsDataCacheStoresOriginalDataByDefault() throws { - // GIVEN + + @Test func thumbnailOptionsDataCacheStoresOriginalDataByDefault() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeOriginalData $0.imageCache = MockImageCache() $0.debugIsSyncImageEncoding = true } - // WHEN - let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFit)]) - expect(pipeline).toLoadImage(with: request) - - // THEN - wait() + // When + let request = ImageRequest( + url: Test.url, + userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions( + size: CGSize(width: 400,height: 400 + ), + unit: .pixels, + contentMode: .aspectFit + )] + ) + + // Then image is loaded + _ = try await pipeline.image(for: request) do { // Check memory cache // Image does not exists for the original image - XCTAssertNil(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.memory])) + #expect(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.memory]) == nil) // Image exists for thumbnail - let thumbnail = try XCTUnwrap(pipeline.cache.cachedImage(for: request, caches: [.memory])) - XCTAssertEqual(thumbnail.image.sizeInPixels, CGSize(width: 400, height: 300)) + let thumbnail = try #require(pipeline.cache.cachedImage(for: request, caches: [.memory])) + #expect(thumbnail.image.sizeInPixels == CGSize(width: 400, height: 300)) } do { // Check disk cache // Data exists for the original image - let original = try XCTUnwrap(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.disk])) - XCTAssertEqual(original.image.sizeInPixels, CGSize(width: 640, height: 480)) + let original = try #require(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.disk])) + #expect(original.image.sizeInPixels == CGSize(width: 640, height: 480)) // Data does not exist for thumbnail - XCTAssertNil(pipeline.cache.cachedData(for: request)) + #expect(pipeline.cache.cachedData(for: request) == nil) } } - func testThumbnailOptionsDataCacheStoresOriginalDataWithStoreAllPolicy() throws { - // GIVEN + @Test func thumbnailOptionsDataCacheStoresOriginalDataWithStoreAllPolicy() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeAll $0.imageCache = MockImageCache() $0.debugIsSyncImageEncoding = true } - // WHEN - let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFit)]) - expect(pipeline).toLoadImage(with: request) + // When + let request = ImageRequest( + url: Test.url, + userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions( + size: CGSize(width: 400,height: 400), + unit: .pixels, + contentMode: .aspectFit + )] + ) - // THEN - wait() + // When + _ = try await pipeline.image(for: request) do { // Check memory cache // Image does not exists for the original image - XCTAssertNil(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.memory])) + #expect(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.memory]) == nil) // Image exists for thumbnail - let thumbnail = try XCTUnwrap(pipeline.cache.cachedImage(for: request, caches: [.memory])) - XCTAssertEqual(thumbnail.image.sizeInPixels, CGSize(width: 400, height: 300)) + let thumbnail = try #require(pipeline.cache.cachedImage(for: request, caches: [.memory])) + #expect(thumbnail.image.sizeInPixels == CGSize(width: 400, height: 300)) } do { // Check disk cache // Data exists for the original image - let original = try XCTUnwrap(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.disk])) - XCTAssertEqual(original.image.sizeInPixels, CGSize(width: 640, height: 480)) + let original = try #require(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.disk])) + #expect(original.image.sizeInPixels == CGSize(width: 640, height: 480)) // Data exists for thumbnail - let thumbnail = try XCTUnwrap(pipeline.cache.cachedImage(for: request, caches: [.disk])) - XCTAssertEqual(thumbnail.image.sizeInPixels, CGSize(width: 400, height: 300)) + let thumbnail = try #require(pipeline.cache.cachedImage(for: request, caches: [.disk])) + #expect(thumbnail.image.sizeInPixels == CGSize(width: 400, height: 300)) } } // MARK: - Updating Priority - - func testPriorityUpdated() { + + @Test func priorityUpdated() async { // Given let queue = pipeline.configuration.dataLoadingQueue queue.isSuspended = true - + let request = Test.request - XCTAssertEqual(request.priority, .normal) - - let observer = self.expect(queue).toEnqueueOperationsWithCount(1) - - let task = pipeline.loadImage(with: request) { _ in } - wait() // Wait till the operation is created. - - // When/Then - guard let operation = observer.operations.first else { - return XCTFail("No operations gor registered") - } - expect(operation).toUpdatePriority() + #expect(request.priority == .normal) + + let expectation1 = queue.expectJobAdded() + let task = pipeline.imageTask(with: request) + let job = await expectation1.wait() + + // When task priority is updated + let expectation2 = queue.expectPriorityUpdated(for: job) task.priority = .high - - wait() + let newPriority = await expectation2.wait() + + // The work item is also updated + #expect(newPriority == .high) } - + // MARK: - Cancellation - - func testOperationCancelled() { + + @Test func operationCancelled() async { // Given let queue = pipeline.configuration.dataLoadingQueue queue.isSuspended = true - let observer = self.expect(queue).toEnqueueOperationsWithCount(1) - let task = pipeline.loadImage(with: Test.request) { _ in } - wait() // Wait till the operation is created. - - // When/Then - guard let operation = observer.operations.first else { - return XCTFail("No operations gor registered") - } - expect(operation).toCancel() + + // When + let expectation1 = queue.expectJobAdded() + let task = pipeline.imageTask(with: Test.request) + let job = await expectation1.wait() + + // When + let expectation2 = queue.expectJobCancelled(job) task.cancel() - wait() // Wait till operation is created + + // Then + await expectation2.wait() } - + // MARK: ImageRequest.CachePolicy - - func testReloadIgnoringCachedData() { + + @Test func reloadIgnoringCachedData() async throws { // Given dataCache.store[Test.url.absoluteString] = Test.data - + var request = Test.request request.options = [.reloadIgnoringCachedData] - + // When - expect(pipeline).toLoadImage(with: request) - wait() - + _ = try await pipeline.image(for: request) + // Then - XCTAssertEqual(dataLoader.createdTaskCount, 1) + #expect(dataLoader.createdTaskCount == 1) } - - func testLoadFromCacheOnlyDataCache() { + + @Test func loadFromCacheOnlyDataCache() async throws { // Given dataCache.store[Test.url.absoluteString] = Test.data - + var request = Test.request request.options = [.returnCacheDataDontLoad] - + // When - expect(pipeline).toLoadImage(with: request) - wait() - + _ = try await pipeline.image(for: request) + // Then - XCTAssertEqual(dataLoader.createdTaskCount, 0) + #expect(dataLoader.createdTaskCount == 0) } - - func testLoadFromCacheOnlyMemoryCache() { + + @Test func loadFromCacheOnlyMemoryCache() async throws { // Given let imageCache = MockImageCache() imageCache[Test.request] = ImageContainer(image: Test.image) pipeline = pipeline.reconfigured { $0.imageCache = imageCache } - + var request = Test.request request.options = [.returnCacheDataDontLoad] - + // When - expect(pipeline).toLoadImage(with: request) - wait() - + _ = try await pipeline.image(for: request) + // Then - XCTAssertEqual(dataLoader.createdTaskCount, 0) + #expect(dataLoader.createdTaskCount == 0) } - - func testLoadImageFromCacheOnlyFailsIfNoCache() { - // GIVEN no cached data and download disabled - var request = Test.request - request.options = [.returnCacheDataDontLoad] - - // WHEN - expect(pipeline).toFailRequest(request, with: .dataMissingInCache) - wait() - - // THEN - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - func testLoadDataFromCacheOnlyFailsIfNoCache() { - // GIVEN no cached data and download disabled + + @Test func loadImageFromCacheOnlyFailsIfNoCache() async { + // Given no cached data and download disabled var request = Test.request request.options = [.returnCacheDataDontLoad] - - // WHEN - let output = expect(pipeline).toLoadData(with: request) - wait() - - XCTAssertEqual(output.result?.error, .dataMissingInCache) - - // THEN - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } -} - -class ImagePipelineDataCachePolicyTests: XCTestCase { - var dataLoader: MockDataLoader! - var dataCache: MockDataCache! - var pipeline: ImagePipeline! - var encoder: MockImageEncoder! - var processorFactory: MockProcessorFactory! - var request: ImageRequest! - - override func setUp() { - super.setUp() - - dataCache = MockDataCache() - dataLoader = MockDataLoader() - let encoder = MockImageEncoder(result: Test.data(name: "fixture-tiny", extension: "jpeg")) - self.encoder = encoder - pipeline = ImagePipeline { - $0.dataLoader = dataLoader - $0.dataCache = dataCache - $0.imageCache = nil - $0.makeImageEncoder = { _ in encoder } - $0.debugIsSyncImageEncoding = true - } - - processorFactory = MockProcessorFactory() - - request = ImageRequest(url: Test.url, processors: [processorFactory.make(id: "1")]) - } - - // MARK: - Basics - - func testProcessedImageLoadedFromDataCache() { - // Given processed image data stored in data cache - dataLoader.queue.isSuspended = true - dataCache.store[Test.url.absoluteString + "1"] = Test.data - - // When/Then - expect(pipeline).toLoadImage(with: request) - wait() - - // Then - XCTAssertEqual(processorFactory.numberOfProcessorsApplied, 0, "Expected no processors to be applied") - } - -#if !os(macOS) - func testProcessedImageIsDecompressed() { - // Given processed image data stored in data cache - dataLoader.queue.isSuspended = true - dataCache.store[Test.url.absoluteString + "1"] = Test.data - - // When/Then - expect(pipeline).toLoadImage(with: request) { result in - guard let image = result.value?.image else { - return XCTFail("Expected image to be loaded") - } - - XCTAssertNil(ImageDecompression.isDecompressionNeeded(for: image)) - } - wait() - } - - func testProcessedImageIsStoredInMemoryCache() throws { - // Given processed image data stored in data cache - let cache = MockImageCache() - pipeline = pipeline.reconfigured { - $0.imageCache = cache - } - dataLoader.queue.isSuspended = true - dataCache.store[Test.url.absoluteString + "1"] = Test.data - // When - expect(pipeline).toLoadImage(with: request) - wait() - - // Then decompressed image is stored in disk cache - let container = cache[request] - XCTAssertNotNil(container) - - let image = try XCTUnwrap(container?.image) - XCTAssertNil(ImageDecompression.isDecompressionNeeded(for: image)) - } - - func testProcessedImageNotDecompressedWhenDecompressionDisabled() { - // Given pipeline with decompression disabled - pipeline = pipeline.reconfigured { - $0.isDecompressionEnabled = false - } - - // Given processed image data stored in data cache - dataLoader.queue.isSuspended = true - dataCache.store[Test.url.absoluteString + "1"] = Test.data - - // When/Then - expect(pipeline).toLoadImage(with: request) { result in - guard let image = result.value?.image else { - return XCTFail("Expected image to be loaded") - } - - let isDecompressionNeeded = ImageDecompression.isDecompressionNeeded(for: image) - XCTAssertEqual(isDecompressionNeeded, true, "Expected image to still be marked as non decompressed") + do { + _ = try await pipeline.image(for: request) + Issue.record() + } catch { + #expect(error == .dataMissingInCache) } - wait() - } -#endif - - // MARK: DataCachPolicy.automatic - - func testPolicyAutomaticGivenRequestWithProcessors() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .automatic - } - - // GIVEN request with a processor - let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN encoded processed image is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 1) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) - } - - func testPolicyAutomaticGivenRequestWithoutProcessors() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .automatic - } - - // GIVEN request without a processor - let request = ImageRequest(url: Test.url) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) - } - - func testPolicyAutomaticGivenTwoRequests() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .automatic - } - - // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) - expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url)) - } - wait() - - // THEN - // encoded processed image is stored in disk cache - // original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 1) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 2) - XCTAssertEqual(dataCache.store.count, 2) - } - - func testPolicyAutomaticGivenOriginalImageInMemoryCache() { - // GIVEN - let imageCache = MockImageCache() - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .automatic - $0.imageCache = imageCache - } - imageCache[ImageRequest(url: Test.url)] = Test.container - - // WHEN - expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) - wait() - - // THEN - // encoded processed image is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 1) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - // MARK: DataCachPolicy.storeEncodedImages - - func testPolicyStoreEncodedImagesGivenRequestWithProcessors() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .storeEncodedImages - } - - // GIVEN request with a processor - let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN encoded processed image is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 1) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) - } - - func testPolicyStoreEncodedImagesGivenRequestWithoutProcessors() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .storeEncodedImages - } - - // GIVEN request without a processor - let request = ImageRequest(url: Test.url) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN - XCTAssertEqual(encoder.encodeCount, 1) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) - } - - func testPolicyStoreEncodedImagesGivenTwoRequests() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .storeEncodedImages - } - - // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) - expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url)) - } - wait() - - // THEN - // encoded processed image is stored in disk cache - // encoded original image is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 2) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 2) - XCTAssertEqual(dataCache.store.count, 2) - } - - // MARK: DataCachPolicy.storeOriginalData - - func testPolicyStoreOriginalDataGivenRequestWithProcessors() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .storeOriginalData - } - - // GIVEN request with a processor - let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN encoded processed image is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) - } - - func testPolicyStoreOriginalDataGivenRequestWithoutProcessors() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .storeOriginalData - } - - // GIVEN request without a processor - let request = ImageRequest(url: Test.url) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) - } - - func testPolicyStoreOriginalDataGivenTwoRequests() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .storeOriginalData - } - - // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) - expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url)) - } - wait() - - // THEN - // encoded processed image is stored in disk cache - // encoded original image is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) - } - - // MARK: DataCachPolicy.storeAll - - func testPolicyStoreAllGivenRequestWithProcessors() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .storeAll - } - - // GIVEN request with a processor - let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN encoded processed image is stored in disk cache and - // original image data stored in disk cache - XCTAssertEqual(encoder.encodeCount, 1) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 2) - XCTAssertEqual(dataCache.store.count, 2) - } - - func testPolicyStoreAllGivenRequestWithoutProcessors() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .storeAll - } - - // GIVEN request without a processor - let request = ImageRequest(url: Test.url) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) - } - - func testPolicyStoreAllGivenTwoRequests() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .storeAll - } - - // WHEN - expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) - expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url)) - wait() - - // THEN - // encoded processed image is stored in disk cache - // original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 1) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 2) - XCTAssertEqual(dataCache.store.count, 2) - } - - // MARK: Local Resources - func testImagesFromLocalStorageNotCached() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .automatic - } - - // GIVEN request without a processor - let request = ImageRequest(url: Test.url(forResource: "fixture", extension: "jpeg")) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataCache.store.count, 0) - } - - func testProcessedImagesFromLocalStorageAreNotCached() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .automatic - } - - // GIVEN request with a processor - let request = ImageRequest(url: Test.url(forResource: "fixture", extension: "jpeg") ,processors: [.resize(width: 100)]) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataCache.store.count, 0) - } - - func testImagesFromMemoryNotCached() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .automatic - } - - // GIVEN request without a processor - let request = ImageRequest(url: Test.url(forResource: "fixture", extension: "jpeg")) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataCache.store.count, 0) + // Then + #expect(dataLoader.createdTaskCount == 0) } - func testImagesFromData() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .automatic - } - - // GIVEN request without a processor - let data = Test.data(name: "fixture", extension: "jpeg") - let url = URL(string: "data:image/jpeg;base64,\(data.base64EncodedString())") - let request = ImageRequest(url: url) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataCache.store.count, 0) - } + @Test func loadDataFromCacheOnlyFailsIfNoCache() async throws { + // Given no cached data and download disabled + var request = Test.request + request.options = [.returnCacheDataDontLoad] - // MARK: Misc - - func testSetCustomImageEncoder() { - struct MockImageEncoder: ImageEncoding, @unchecked Sendable { - let closure: (PlatformImage) -> Data? - - func encode(_ image: PlatformImage) -> Data? { - return closure(image) - } - } - - // Given - var isCustomEncoderCalled = false - let encoder = MockImageEncoder { _ in - isCustomEncoderCalled = true - return nil - } - - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .automatic - $0.makeImageEncoder = { _ in - return encoder - } - } - // When - expect(pipeline).toLoadImage(with: request) - - // Then - wait { _ in - XCTAssertTrue(isCustomEncoderCalled) - XCTAssertNil(self.dataCache.cachedData(for: Test.url.absoluteString + "1"), "Expected processed image data to not be stored") + do { + _ = try await pipeline.data(for: request) + Issue.record() + } catch { + #expect(error == .dataMissingInCache) } - } - // MARK: Integration with Thumbnail Feature - - func testOriginalDataStoredWhenThumbnailRequested() { - // GIVEN - let options = ImageRequest.ThumbnailOptions(maxPixelSize: 400) - let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: options]) - - // WHEN - expect(pipeline).toLoadImage(with: request) - wait() - - // THEN - XCTAssertTrue(dataCache.containsData(for: "http://test.com/example.jpeg")) + // Then + #expect(dataLoader.createdTaskCount == 0) } } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDecodingTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDecodingTests.swift index 1c5540088..6871be0f8 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDecodingTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDecodingTests.swift @@ -1,17 +1,18 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation -import XCTest @testable import Nuke -class ImagePipelineDecodingTests: XCTestCase { +@ImagePipelineActor +@Suite class ImagePipelineDecodingTests { var dataLoader: MockDataLoader! var pipeline: ImagePipeline! - override func setUp() { - super.setUp() - + init() { dataLoader = MockDataLoader() pipeline = ImagePipeline { $0.dataLoader = dataLoader @@ -19,7 +20,7 @@ class ImagePipelineDecodingTests: XCTestCase { } } - func testExperimentalDecoder() throws { + @Test func experimentalDecoder() async throws { // Given let decoder = MockExperimentalDecoder() @@ -34,17 +35,13 @@ class ImagePipelineDecodingTests: XCTestCase { } // When - var response: ImageResponse? - expect(pipeline).toLoadImage(with: Test.request, completion: { - response = $0.value - }) - wait() + let response = try await pipeline.imageTask(with: Test.request).response // Then - let container = try XCTUnwrap(response?.container) - XCTAssertNotNil(container.image) - XCTAssertEqual(container.data, dummyData) - XCTAssertEqual(container.userInfo["a"] as? Int, 1) + let container = response.container + #expect(container.image != nil) + #expect(container.data == dummyData) + #expect(container.userInfo["a"] as? Int == 1) } } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDelegateTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDelegateTests.swift index 244561ed8..d65149b27 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDelegateTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDelegateTests.swift @@ -1,19 +1,19 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing -import XCTest @testable import Nuke -class ImagePipelineDelegateTests: XCTestCase { +@Suite class ImagePipelineDelegateTests { private var dataLoader: MockDataLoader! private var dataCache: MockDataCache! private var pipeline: ImagePipeline! private var delegate: MockImagePipelineDelegate! - override func setUp() { - super.setUp() - + init() { dataLoader = MockDataLoader() dataCache = MockDataCache() delegate = MockImagePipelineDelegate() @@ -27,9 +27,10 @@ class ImagePipelineDelegateTests: XCTestCase { } } + @MainActor - func testCustomizingDataCacheKey() throws { - // GIVEN + @Test func customizingDataCacheKey() async throws { + // Given let imageURLSmall = URL(string: "https://example.com/image-01-small.jpeg")! let imageURLMedium = URL(string: "https://example.com/image-01-medium.jpeg")! @@ -37,59 +38,51 @@ class ImagePipelineDelegateTests: XCTestCase { (Test.data, URLResponse(url: imageURLMedium, mimeType: "jpeg", expectedContentLength: Test.data.count, textEncodingName: nil)) ) - // GIVEN image is loaded from medium size URL and saved in cache using imageId "image-01-small" + // Given image is loaded from medium size URL and saved in cache using imageId "image-01-small" let requestA = ImageRequest( url: imageURLMedium, processors: [.resize(width: 44)], userInfo: ["imageId": "image-01-small"] ) - expect(pipeline).toLoadImage(with: requestA) - wait() + _ = try await pipeline.image(for: requestA) - let data = try XCTUnwrap(dataCache.cachedData(for: "image-01-small")) - let image = try XCTUnwrap(PlatformImage(data: data)) - XCTAssertEqual(image.sizeInPixels.width, 44 * Screen.scale) + let data = try #require(dataCache.cachedData(for: "image-01-small")) + let image = try #require(PlatformImage(data: data)) + #expect(image.sizeInPixels.width == 44 * Screen.scale) - // GIVEN a request for a small image + // Given a request for a small image let requestB = ImageRequest( url: imageURLSmall, userInfo: ["imageId": "image-01-small"] ) - // WHEN/THEN the image is returned from the disk cache - expect(pipeline).toLoadImage(with: requestB, completion: { result in - guard let image = result.value?.image else { - return XCTFail() - } - XCTAssertEqual(image.sizeInPixels.width, 44 * Screen.scale) - }) - wait() - XCTAssertEqual(dataLoader.createdTaskCount, 1) + // When + let image2 = try await pipeline.image(for: requestB) + + // Then the image is returned from the disk cache + #expect(image2.sizeInPixels.width == 44 * Screen.scale) + #expect(dataLoader.createdTaskCount == 1) } - func testDataIsStoredInCache() { - // WHEN - expect(pipeline).toLoadImage(with: Test.request) + @Test func dataIsStoredInCache() async throws { + // When + _ = try await pipeline.image(for: Test.request) - // THEN - wait { _ in - XCTAssertFalse(self.dataCache.store.isEmpty) - } + // Then + #expect(!dataCache.store.isEmpty) } - func testDataIsStoredInCacheWhenCacheDisabled() { - // WHEN + @Test func dataIsStoredInCacheWhenCacheDisabled() async throws { + // When delegate.isCacheEnabled = false - expect(pipeline).toLoadImage(with: Test.request) + _ = try await pipeline.image(for: Test.request) - // THEN - wait { _ in - XCTAssertTrue(self.dataCache.store.isEmpty) - } + // Then + #expect(dataCache.store.isEmpty) } } -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/ImagePipelineFormatsTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineFormatsTests.swift index 618d5b65c..f4cc6ac53 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineFormatsTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineFormatsTests.swift @@ -1,17 +1,17 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing -import XCTest @testable import Nuke -class ImagePipelineFormatsTests: XCTestCase { +@Suite struct ImagePipelineFormatsTests { var dataLoader: MockDataLoader! var pipeline: ImagePipeline! - override func setUp() { - super.setUp() - + init() { dataLoader = MockDataLoader() pipeline = ImagePipeline { $0.dataLoader = dataLoader @@ -19,46 +19,36 @@ class ImagePipelineFormatsTests: XCTestCase { } } - func testExtendedColorSpaceSupport() throws { + @Test func extendedColorSpaceSupport() async throws { // Given dataLoader.results[Test.url] = .success( (Test.data(name: "image-p3", extension: "jpg"), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) ) // When - var result: Result? - expect(pipeline).toLoadImage(with: Test.request) { - result = $0 - } - wait() + let image = try await pipeline.image(for: Test.request) // Then - let image = try XCTUnwrap(result?.value?.image) - let cgImage = try XCTUnwrap(image.cgImage) - let colorSpace = try XCTUnwrap(cgImage.colorSpace) + let cgImage = try #require(image.cgImage) + let colorSpace = try #require(cgImage.colorSpace) #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) - XCTAssertTrue(colorSpace.isWideGamutRGB) + #expect(colorSpace.isWideGamutRGB) #elseif os(watchOS) - XCTAssertFalse(colorSpace.isWideGamutRGB) + #expect(!colorSpace.isWideGamutRGB) #endif } - func testGrayscaleSupport() throws { + @Test func grayscaleSupport() async throws { // Given dataLoader.results[Test.url] = .success( (Test.data(name: "grayscale", extension: "jpeg"), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) ) // When - var result: Result? - expect(pipeline).toLoadImage(with: Test.request) { - result = $0 - } - wait() + let image = try await pipeline.image(for: Test.request) // Then - let image = try XCTUnwrap(result?.value?.image) - let cgImage = try XCTUnwrap(image.cgImage) - XCTAssertEqual(cgImage.bitsPerComponent, 8) + let cgImage = try #require(image.cgImage) + #expect(cgImage.bitsPerComponent == 8) } } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineImageCacheTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineImageCacheTests.swift index 558066857..e26a9f9f1 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineImageCacheTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineImageCacheTests.swift @@ -1,19 +1,19 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing -import XCTest @testable import Nuke /// Test how well image pipeline interacts with memory cache. -class ImagePipelineImageCacheTests: XCTestCase { +@Suite struct ImagePipelineImageCacheTests { var dataLoader: MockDataLoader! var cache: MockImageCache! var pipeline: ImagePipeline! - override func setUp() { - super.setUp() - + init() { dataLoader = MockDataLoader() cache = MockImageCache() pipeline = ImagePipeline { @@ -22,51 +22,41 @@ class ImagePipelineImageCacheTests: XCTestCase { } } - func testThatImageIsLoaded() { - expect(pipeline).toLoadImage(with: Test.request) - wait() - } - - // MARK: Caching - - func testCacheWrite() { + @Test func cacheWrite() async throws { // When - expect(pipeline).toLoadImage(with: Test.request) - wait() + _ = try await pipeline.image(for: Test.request) // Then - XCTAssertEqual(dataLoader.createdTaskCount, 1) - XCTAssertNotNil(cache[Test.request]) + #expect(dataLoader.createdTaskCount == 1) + #expect(cache[Test.request] != nil) } - func testCacheRead() { + @Test func cacheRead() async throws { // Given cache[Test.request] = ImageContainer(image: Test.image) // When - expect(pipeline).toLoadImage(with: Test.request) - wait() + _ = try await pipeline.image(for: Test.request) // Then - XCTAssertEqual(dataLoader.createdTaskCount, 0) - XCTAssertNotNil(cache[Test.request]) + #expect(dataLoader.createdTaskCount == 0) + #expect(cache[Test.request] != nil) } - func testCacheWriteDisabled() { + @Test func cacheWriteDisabled() async throws { // Given var request = Test.request request.options.insert(.disableMemoryCacheWrites) // When - expect(pipeline).toLoadImage(with: request) - wait() + _ = try await pipeline.image(for: request) // Then - XCTAssertEqual(dataLoader.createdTaskCount, 1) - XCTAssertNil(cache[Test.request]) + #expect(dataLoader.createdTaskCount == 1) + #expect(cache[Test.request] == nil) } - func testMemoryCacheReadDisabled() { + @Test func memoryCacheReadDisabled() async throws { // Given cache[Test.request] = ImageContainer(image: Test.image) @@ -74,15 +64,14 @@ class ImagePipelineImageCacheTests: XCTestCase { request.options.insert(.disableMemoryCacheReads) // When - expect(pipeline).toLoadImage(with: request) - wait() + _ = try await pipeline.image(for: request) // Then - XCTAssertEqual(dataLoader.createdTaskCount, 1) - XCTAssertNotNil(cache[Test.request]) + #expect(dataLoader.createdTaskCount == 1) + #expect(cache[Test.request] != nil) } - func testReloadIgnoringCachedData() { + @Test func reloadIgnoringCachedData() async throws { // Given cache[Test.request] = ImageContainer(image: Test.image) @@ -90,305 +79,29 @@ class ImagePipelineImageCacheTests: XCTestCase { request.options.insert(.reloadIgnoringCachedData) // When - expect(pipeline).toLoadImage(with: request) - wait() + _ = try await pipeline.image(for: request) // Then - XCTAssertEqual(dataLoader.createdTaskCount, 1) - XCTAssertNotNil(cache[Test.request]) + #expect(dataLoader.createdTaskCount == 1) + #expect(cache[Test.request] != nil) } - func testGeneratedThumbnailDataIsStoredIncache() throws { + @Test func generatedThumbnailDataIsStoredIncache() async throws { // When - let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFit)]) - expect(pipeline).toLoadImage(with: request) - - // Then - wait { _ in - guard let container = self.pipeline.cache[request] else { - return XCTFail() - } - XCTAssertEqual(container.image.sizeInPixels, CGSize(width: 400, height: 300)) - - XCTAssertNil(self.pipeline.cache[ImageRequest(url: Test.url)]) - } - } -} - -/// Make sure that cache layers are checked in the correct order and the -/// minimum necessary number of cache lookups are performed. -class ImagePipelineCacheLayerPriorityTests: XCTestCase { - var pipeline: ImagePipeline! - var dataLoader: MockDataLoader! - var imageCache: MockImageCache! - var dataCache: MockDataCache! - var processorFactory: MockProcessorFactory! - - var request: ImageRequest! - var intermediateRequest: ImageRequest! - var processedImage: ImageContainer! - var intermediateImage: ImageContainer! - var originalRequest: ImageRequest! - var originalImage: ImageContainer! - - override func setUp() { - super.setUp() - - dataCache = MockDataCache() - dataLoader = MockDataLoader() - imageCache = MockImageCache() - processorFactory = MockProcessorFactory() - - pipeline = ImagePipeline { - $0.dataLoader = dataLoader - $0.dataCache = dataCache - $0.imageCache = imageCache - $0.debugIsSyncImageEncoding = true - } - - request = ImageRequest(url: Test.url, processors: [ - processorFactory.make(id: "1"), - processorFactory.make(id: "2") - ]) - - intermediateRequest = ImageRequest(url: Test.url, processors: [ - processorFactory.make(id: "1") - ]) - - originalRequest = ImageRequest(url: Test.url) - - do { - let image = PlatformImage(data: Test.data)! - image.nk_test_processorIDs = ["1", "2"] - processedImage = ImageContainer(image: image) - } - - do { - let image = PlatformImage(data: Test.data)! - image.nk_test_processorIDs = ["1"] - intermediateImage = ImageContainer(image: image) - } - - originalImage = ImageContainer(image: PlatformImage(data: Test.data)!) - } - - func testGivenProcessedImageInMemoryCache() { - // GIVEN - imageCache[request] = processedImage - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertTrue(record.image === processedImage.image) - XCTAssertEqual(record.response?.cacheType, .memory) - - // THEN - XCTAssertEqual(imageCache.readCount, 1) - XCTAssertEqual(imageCache.writeCount, 1) // Initial write - XCTAssertEqual(dataCache.readCount, 0) - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - func testGivenProcessedImageInBothMemoryAndDiskCache() { - // GIVEN - pipeline.cache.storeCachedImage(processedImage, for: request, caches: [.all]) - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertTrue(record.image === processedImage.image) - XCTAssertEqual(record.response?.cacheType, .memory) + let request = ImageRequest( + url: Test.url, + userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions( + size: CGSize(width: 400, height: 400), + unit: .pixels, + contentMode: .aspectFit + )] + ) - // THEN - XCTAssertEqual(imageCache.readCount, 1) - XCTAssertEqual(imageCache.writeCount, 1) // Initial write - XCTAssertEqual(dataCache.readCount, 0) - XCTAssertEqual(dataCache.writeCount, 1) // Initial write - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - func testGivenProcessedImageInDiskCache() { - // GIVEN - pipeline.cache.storeCachedImage(processedImage, for: request, caches: [.disk]) - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertNotNil(record.image) - XCTAssertEqual(record.response?.cacheType, .disk) - - // THEN - XCTAssertEqual(imageCache.readCount, 1) - XCTAssertEqual(imageCache.writeCount, 1) // Initial write - XCTAssertEqual(dataCache.readCount, 1) - XCTAssertEqual(dataCache.writeCount, 1) // Initial write - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - func testGivenProcessedImageInDiskCacheAndIndermediateImageInMemoryCache() { - // GIVEN - pipeline.cache.storeCachedImage(processedImage, for: request, caches: [.disk]) - pipeline.cache.storeCachedImage(intermediateImage, for: intermediateRequest, caches: [.memory]) - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertNotNil(record.image) - XCTAssertEqual(record.response?.cacheType, .disk) - - // THEN - XCTAssertEqual(imageCache.readCount, 1) - XCTAssertEqual(imageCache.writeCount, 2) // Initial write - XCTAssertNotNil(imageCache[request]) - XCTAssertNotNil(imageCache[intermediateRequest]) - XCTAssertEqual(dataCache.readCount, 1) - XCTAssertEqual(dataCache.writeCount, 1) // Initial write - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - func testGivenIndermediateImageInMemoryCache() { - // GIVEN - pipeline.cache.storeCachedImage(intermediateImage, for: intermediateRequest, caches: [.memory]) - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertEqual(record.image?.nk_test_processorIDs, ["1", "2"]) - XCTAssertEqual(record.response?.cacheType, .memory) - - // THEN - XCTAssertEqual(imageCache.readCount, 2) // Processed + intermediate - XCTAssertEqual(imageCache.writeCount, 2) // Initial write - XCTAssertNotNil(imageCache[request]) - XCTAssertNotNil(imageCache[intermediateRequest]) - XCTAssertEqual(dataCache.readCount, 1) // Check original image - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - func testGivenOriginalAndIntermediateImageInMemoryCache() { - // GIVEN - pipeline.cache.storeCachedImage(intermediateImage, for: intermediateRequest, caches: [.memory]) - pipeline.cache.storeCachedImage(originalImage, for: originalRequest, caches: [.memory]) - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertEqual(record.image?.nk_test_processorIDs, ["1", "2"]) - XCTAssertEqual(record.response?.cacheType, .memory) - - // THEN - XCTAssertEqual(imageCache.readCount, 2) // Processed + intermediate - XCTAssertEqual(imageCache.writeCount, 3) // Initial write + write processed - XCTAssertNotNil(imageCache[originalRequest]) - XCTAssertNotNil(imageCache[request]) - XCTAssertNotNil(imageCache[intermediateRequest]) - XCTAssertEqual(dataCache.readCount, 1) // Check original image - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - func testGivenOriginalImageInBothCaches() { - // GIVEN - pipeline.cache.storeCachedImage(originalImage, for: originalRequest, caches: [.all]) - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertEqual(record.image?.nk_test_processorIDs, ["1", "2"]) - XCTAssertEqual(record.response?.cacheType, .memory) + _ = try await pipeline.image(for: request) - // THEN - XCTAssertEqual(imageCache.readCount, 3) // Processed + intermediate + original - XCTAssertEqual(imageCache.writeCount, 2) // Processed + original - XCTAssertNotNil(imageCache[originalRequest]) - XCTAssertNotNil(imageCache[request]) - XCTAssertEqual(dataCache.readCount, 2) // "1", "2" - XCTAssertEqual(dataCache.writeCount, 1) // Initial - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - func testGivenOriginalImageInDiskCache() { - // GIVEN - pipeline.cache.storeCachedImage(originalImage, for: originalRequest, caches: [.disk]) - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertEqual(record.image?.nk_test_processorIDs, ["1", "2"]) - XCTAssertEqual(record.response?.cacheType, .disk) - - // THEN - XCTAssertEqual(imageCache.readCount, 3) // Processed + intermediate + original - XCTAssertEqual(imageCache.writeCount, 1) // Processed - XCTAssertNotNil(imageCache[request]) - XCTAssertEqual(dataCache.readCount, 3) // "1" + "2" + original - XCTAssertEqual(dataCache.writeCount, 1) // Initial - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - func testPolicyStoreEncodedImagesGivenDataAlreadyStored() { - // GIVEN - pipeline = pipeline.reconfigured { - $0.dataCachePolicy = .storeEncodedImages - } - - let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) - pipeline.cache.storeCachedImage(Test.container, for: request, caches: [.disk]) - dataCache.resetCounters() - imageCache.resetCounters() - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertNotNil(record.image) - XCTAssertEqual(record.response?.cacheType, .disk) - - // THEN - XCTAssertEqual(imageCache.readCount, 1) - XCTAssertEqual(imageCache.writeCount, 1) - XCTAssertEqual(dataCache.readCount, 1) - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataLoader.createdTaskCount, 0) - } - - // MARK: ImageRequest.Options - - func testGivenOriginalImageInDiskCacheAndDiskReadsDisabled() { - // GIVEN - pipeline.cache.storeCachedImage(originalImage, for: originalRequest, caches: [.disk]) - - // WHEN - request.options.insert(.disableDiskCacheReads) - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertEqual(record.image?.nk_test_processorIDs, ["1", "2"]) - XCTAssertNil(record.response?.cacheType) - - // THEN - XCTAssertEqual(imageCache.readCount, 3) // Processed + intermediate + original - XCTAssertEqual(imageCache.writeCount, 1) // Processed - XCTAssertNotNil(imageCache[request]) - XCTAssertEqual(dataCache.readCount, 0) // Processed + original - XCTAssertEqual(dataCache.writeCount, 2) // Initial + processed - XCTAssertEqual(dataLoader.createdTaskCount, 1) - } - - func testGivenNoImageDataInDiskCacheAndDiskWritesDisabled() { - // WHEN - request.options.insert(.disableDiskCacheWrites) - let record = expect(pipeline).toLoadImage(with: request) - wait() - XCTAssertEqual(record.image?.nk_test_processorIDs, ["1", "2"]) - XCTAssertNil(record.response?.cacheType) - - // THEN - XCTAssertEqual(imageCache.readCount, 3) // Processed + intermediate + original - XCTAssertEqual(imageCache.writeCount, 1) // Processed - XCTAssertNotNil(imageCache[request]) - XCTAssertEqual(dataCache.readCount, 3) // "1" + "2" + original - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataLoader.createdTaskCount, 1) + // Then + let container = try #require(pipeline.cache[request]) + #expect(container.image.sizeInPixels == CGSize(width: 400, height: 300)) + #expect(pipeline.cache[ImageRequest(url: Test.url)] == nil) } } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift index 101676764..1e648284c 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift @@ -1,19 +1,19 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing -import XCTest @testable import Nuke -class ImagePipelineLoadDataTests: XCTestCase { +@Suite class ImagePipelineLoadDataTests { var dataLoader: MockDataLoader! var dataCache: MockDataCache! var pipeline: ImagePipeline! var encoder: MockImageEncoder! - override func setUp() { - super.setUp() - + init() { dataLoader = MockDataLoader() dataCache = MockDataCache() let encoder = MockImageEncoder(result: Test.data) @@ -28,408 +28,312 @@ 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) - XCTAssertEqual(response.data.count, 22789) - XCTAssertTrue(Thread.isMainThread) - expectation.fulfill() - } - wait() - } - - // MARK: - Progress Reporting - - func testProgressClosureIsCalled() { - // Given - let request = ImageRequest(url: Test.url) - - dataLoader.results[Test.url] = .success( - (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) - ) - - // When - let expectedProgress = expectProgress([(10, 20), (20, 20)]) - - pipeline.loadData( - with: request, - progress: { completed, total in - // Then - XCTAssertTrue(Thread.isMainThread) - expectedProgress.received((completed, total)) - }, - completion: { _ in } - ) - - wait() - } - - func testTaskProgressIsUpdated() { - // Given - let request = ImageRequest(url: Test.url) - - dataLoader.results[Test.url] = .success( - (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) - ) - + @Test func loadDataDataLoaded() async throws { // When - let expectedProgress = expectProgress([(10, 20), (20, 20)]) - - pipeline.loadData( - with: request, - progress: { completed, total in - // Then - XCTAssertTrue(Thread.isMainThread) - expectedProgress.received((completed, total)) - }, - completion: { _ in } - ) - - wait() - } + let response = try await pipeline.data(for: Test.request) - // 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() + // Then + #expect(response.data.count == 22789) } // MARK: - Errors - func testLoadWithInvalidURL() throws { - // GIVEN + @Test func loadWithInvalidURL() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataLoader = DataLoader() } - // WHEN - let record = expect(pipeline).toLoadData(with: ImageRequest(url: URL(string: ""))) - wait() - - // THEN - let result = try XCTUnwrap(record.result) - XCTAssertTrue(result.isFailure) + // When + do { + _ = try await pipeline.data(for: ImageRequest(url: URL(string: ""))) + Issue.record() + } catch { + // Then + if case .dataLoadingFailed = error { + // Expected + } else { + Issue.record() + } + } } -} -// MARK: - ImagePipelineLoadDataTests (ImageRequest.CachePolicy) + // MARK: - ImageRequest.CachePolicy -extension ImagePipelineLoadDataTests { - func testCacheLookupWithDefaultPolicyImageStored() { - // GIVEN + @Test func cacheLookupWithDefaultPolicyImageStored() async throws { + // Given pipeline.cache.storeCachedImage(Test.container, for: Test.request) - // WHEN - let record = expect(pipeline).toLoadData(with: Test.request) - wait() + // When + let response = try await pipeline.data(for: Test.request) - // THEN - XCTAssertEqual(dataCache.readCount, 1) - XCTAssertEqual(dataCache.writeCount, 1) // Initial write - XCTAssertEqual(dataLoader.createdTaskCount, 0) - XCTAssertNotNil(record.data) + // Then + #expect(dataCache.readCount == 1) + #expect(dataCache.writeCount == 1) // Initial write // Initial write + #expect(dataLoader.createdTaskCount == 0) + #expect(!response.data.isEmpty) } - func testCacheLookupWithReloadPolicyImageStored() { - // GIVEN + @Test func cacheLookupWithReloadPolicyImageStored() async throws { + // Given pipeline.cache.storeCachedImage(Test.container, for: Test.request) - // WHEN + // When let request = ImageRequest(url: Test.url, options: [.reloadIgnoringCachedData]) - let record = expect(pipeline).toLoadData(with: request) - wait() - - // THEN - XCTAssertEqual(dataCache.readCount, 0) - XCTAssertEqual(dataCache.writeCount, 2) // Initial write + write after fetch - XCTAssertEqual(dataLoader.createdTaskCount, 1) - XCTAssertNotNil(record.data) - } -} + let response = try await pipeline.data(for: request) -// MARK: - ImagePipelineLoadDataTests (DataCachePolicy) + // Then + #expect(dataCache.readCount == 0) + #expect(dataCache.writeCount == 2) // Initial write + write after fetch // Initial write + write after fetch + #expect(dataLoader.createdTaskCount == 1) + #expect(!response.data.isEmpty) + } -extension ImagePipelineLoadDataTests { - // MARK: DataCachPolicy.automatic + // MARK: - DataCachePolicy - func testPolicyAutomaticGivenRequestWithProcessors() { - // GIVEN + @Test func policyAutomaticGivenRequestWithProcessors() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .automatic } - // GIVEN request with a processor + // Given request with a processor let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) - // WHEN - expect(pipeline).toLoadData(with: request) - wait() + // When + _ = try await pipeline.data(for: request) - // THEN nothing is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataCache.store.count, 0) + // Then nothing is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") == nil) + #expect(dataCache.writeCount == 0) + #expect(dataCache.store.count == 0) } - func testPolicyAutomaticGivenRequestWithoutProcessors() { - // GIVEN + @Test func policyAutomaticGivenRequestWithoutProcessors() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .automatic } - // GIVEN request without a processor + // Given request without a processor let request = ImageRequest(url: Test.url) - // WHEN - expect(pipeline).toLoadData(with: request) - wait() + // When + _ = try await pipeline.data(for: request) - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) } - func testPolicyAutomaticGivenTwoRequests() { - // GIVEN + @ImagePipelineActor // important + @Test func policyAutomaticGivenTwoRequests() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .automatic } - // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadData(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) - expect(pipeline).toLoadData(with: ImageRequest(url: Test.url)) - } - wait() - - // THEN - // only original image is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) + // When + let pipeline = pipeline! + async let task1 = pipeline.data(for: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) + async let task2 = pipeline.data(for: ImageRequest(url: Test.url)) + _ = try await (task1, task2) + + // Then only original image is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") == nil) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) } // MARK: DataCachPolicy.storeOriginalData - func testPolicystoreOriginalDataGivenRequestWithProcessors() { - // GIVEN + @Test func policystoreOriginalDataGivenRequestWithProcessors() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeOriginalData } - // GIVEN request with a processor + // Given request with a processor let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) - // WHEN - expect(pipeline).toLoadData(with: request) - wait() + // When + _ = try await pipeline.data(for: request) - // THEN nothing is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) + // Then nothing is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) } - func testPolicystoreOriginalDataGivenRequestWithoutProcessors() { - // GIVEN + @Test func policystoreOriginalDataGivenRequestWithoutProcessors() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeOriginalData } - // GIVEN request without a processor + // Given request without a processor let request = ImageRequest(url: Test.url) - // WHEN - expect(pipeline).toLoadData(with: request) - wait() + // When + _ = try await pipeline.data(for: request) - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) } - func testPolicystoreOriginalDataGivenTwoRequests() { - // GIVEN + @ImagePipelineActor + @Test func policyStoreOriginalDataGivenTwoRequests() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeOriginalData } - // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadData(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) - expect(pipeline).toLoadData(with: ImageRequest(url: Test.url)) - } - wait() + // When + // TODO: this should subscribe to a single task + let pipeline = pipeline! + async let task1 = pipeline.data(for: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) + async let task2 = pipeline.data(for: ImageRequest(url: Test.url)) + _ = try await (task1, task2) - // THEN + // Then // only original image is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") == nil) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) } // MARK: DataCachPolicy.storeEncodedImages - func testPolicyStoreEncodedImagesGivenRequestWithProcessors() { - // GIVEN + @Test func policyStoreEncodedImagesGivenRequestWithProcessors() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeEncodedImages } - // GIVEN request with a processor + // Given request with a processor let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) - // WHEN - expect(pipeline).toLoadData(with: request) - wait() + // When + _ = try await pipeline.data(for: request) - // THEN nothing is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataCache.store.count, 0) + // Then nothing is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) == nil) + #expect(dataCache.writeCount == 0) + #expect(dataCache.store.count == 0) } - func testPolicyStoreEncodedImagesGivenRequestWithoutProcessors() { - // GIVEN + @Test func policyStoreEncodedImagesGivenRequestWithoutProcessors() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeEncodedImages } - // GIVEN request without a processor + // Given request without a processor let request = ImageRequest(url: Test.url) - // WHEN - expect(pipeline).toLoadData(with: request) - wait() + // When + _ = try await pipeline.data(for: request) - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataCache.store.count, 0) + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) == nil) + #expect(dataCache.writeCount == 0) + #expect(dataCache.store.count == 0) } - func testPolicyStoreEncodedImagesGivenTwoRequests() { - // GIVEN + @ImagePipelineActor + @Test func policyStoreEncodedImagesGivenTwoRequests() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeEncodedImages } - // WHEN - expect(pipeline).toLoadData(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) - expect(pipeline).toLoadData(with: ImageRequest(url: Test.url)) - wait() + // When + let pipeline = pipeline! + async let task1 = pipeline.data(for: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) + async let task2 = pipeline.data(for: ImageRequest(url: Test.url)) + _ = try await (task1, task2) - // THEN + // Then // only original image is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 0) - XCTAssertEqual(dataCache.store.count, 0) + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") == nil) + #expect(dataCache.cachedData(for: Test.url.absoluteString) == nil) + #expect(dataCache.writeCount == 0) + #expect(dataCache.store.count == 0) } // MARK: DataCachPolicy.storeAll - func testPolicyStoreAllGivenRequestWithProcessors() { - // GIVEN + @Test func policyStoreAllGivenRequestWithProcessors() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeAll } - // GIVEN request with a processor + // Given request with a processor let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")]) - // WHEN - expect(pipeline).toLoadData(with: request) - wait() + // When + _ = try await pipeline.data(for: request) - // THEN nothing is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) + // Then nothing is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) } - func testPolicyStoreAllGivenRequestWithoutProcessors() { - // GIVEN + @Test func policyStoreAllGivenRequestWithoutProcessors() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeAll } - // GIVEN request without a processor + // Given request without a processor let request = ImageRequest(url: Test.url) - // WHEN - expect(pipeline).toLoadData(with: request) - wait() + // When + _ = try await pipeline.data(for: request) - // THEN original image data is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) + // Then original image data is stored in disk cache + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) } - func testPolicyStoreAllGivenTwoRequests() { - // GIVEN + @ImagePipelineActor + @Test func policyStoreAllGivenTwoRequests() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataCachePolicy = .storeAll } - // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { - expect(pipeline).toLoadData(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) - expect(pipeline).toLoadData(with: ImageRequest(url: Test.url)) - } - wait() + // When + let pipeline = pipeline! + async let task1 = pipeline.data(for: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) + async let task2 = pipeline.data(for: ImageRequest(url: Test.url)) + _ = try await (task1, task2) - // THEN + // Then // only original image is stored in disk cache - XCTAssertEqual(encoder.encodeCount, 0) - XCTAssertNil(dataCache.cachedData(for: Test.url.absoluteString + "p1")) - XCTAssertNotNil(dataCache.cachedData(for: Test.url.absoluteString)) - XCTAssertEqual(dataCache.writeCount, 1) - XCTAssertEqual(dataCache.store.count, 1) - } -} - -extension XCTestCase { - func suspendDataLoading(for pipeline: ImagePipeline, expectedRequestCount count: Int, _ closure: () -> Void) { - let dataLoader = pipeline.configuration.dataLoader as! MockDataLoader - dataLoader.isSuspended = true - let expectation = self.expectation(description: "registered") - expectation.expectedFulfillmentCount = count - pipeline.onTaskStarted = { _ in - expectation.fulfill() - } - closure() - wait(for: [expectation], timeout: 5) - dataLoader.isSuspended = false + #expect(encoder.encodeCount == 0) + #expect(dataCache.cachedData(for: Test.url.absoluteString + "p1") == nil) + #expect(dataCache.cachedData(for: Test.url.absoluteString) != nil) + #expect(dataCache.writeCount == 1) + #expect(dataCache.store.count == 1) } } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessingDeduplicationTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessingDeduplicationTests.swift new file mode 100644 index 000000000..bbd55446d --- /dev/null +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessingDeduplicationTests.swift @@ -0,0 +1,169 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing + +@testable import Nuke + +@ImagePipelineActor +@Suite class ImagePipelineProcessingDeduplicationTests { + var dataLoader: MockDataLoader! + var pipeline: ImagePipeline! + var observations = [NSKeyValueObservation]() + + init() { + dataLoader = MockDataLoader() + pipeline = ImagePipeline { + $0.dataLoader = dataLoader + $0.imageCache = nil + } + } + + @Test func eachProcessingStepIsDeduplicated() async throws { + // Given requests with the same URLs but different processors + let processors = MockProcessorFactory() + let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) + let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "1"), processors.make(id: "2")]) + + // When + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + let (image1, image2) = try await (task1, task2) + + // Then + #expect(image1.nk_test_processorIDs == ["1"]) + #expect(image2.nk_test_processorIDs == ["1", "2"]) + + // Then the processor "1" is only applied once + #expect(processors.numberOfProcessorsApplied == 2) + } + + @Test func eachFinalProcessedImageIsStoredInMemoryCache() async throws { + let cache = MockImageCache() + var conf = pipeline.configuration + conf.imageCache = cache + pipeline = ImagePipeline(configuration: conf) + + // Given requests with the same URLs but different processors + let processors = MockProcessorFactory() + let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) + let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "1"), processors.make(id: "2"), processors.make(id: "3")]) + + // When + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + _ = try await (task1, task2) + + // Then + #expect(cache[request1] != nil) + #expect(cache[request2] != nil) + #expect(cache[ImageRequest(url: Test.url, processors: [processors.make(id: "1"), processors.make(id: "2")])] == nil) + } + + @Test func whenApplingMultipleImageProcessorsIntermediateMemoryCachedResultsAreUsed() async throws { + let cache = MockImageCache() + var conf = pipeline.configuration + conf.imageCache = cache + pipeline = ImagePipeline(configuration: conf) + + let factory = MockProcessorFactory() + + // Given + cache[ImageRequest(url: Test.url, processors: [factory.make(id: "1"), factory.make(id: "2")])] = Test.container + + // When + let request = ImageRequest(url: Test.url, processors: [factory.make(id: "1"), factory.make(id: "2"), factory.make(id: "3")]) + let image = try await pipeline.image(for: request) + + // Then + #expect(image.nk_test_processorIDs == ["3"], "Expected only the last processor to be applied") + #expect(dataLoader.createdTaskCount == 0, "Expected no data task to be performed") + #expect(factory.numberOfProcessorsApplied == 1, "Expected only one processor to be applied") + } + + @Test func whenApplingMultipleImageProcessorsIntermediateDataCacheResultsAreUsed() async throws { + // Given + let dataCache = MockDataCache() + dataCache.store[Test.url.absoluteString + "12"] = Test.data + + pipeline = pipeline.reconfigured { + $0.dataCache = dataCache + } + + // When + let factory = MockProcessorFactory() + let request = ImageRequest(url: Test.url, processors: [factory.make(id: "1"), factory.make(id: "2"), factory.make(id: "3")]) + let image = try await pipeline.image(for: request) + + // Then + #expect(image.nk_test_processorIDs == ["3"], "Expected only the last processor to be applied") + #expect(dataLoader.createdTaskCount == 0, "Expected no data task to be performed") + #expect(factory.numberOfProcessorsApplied == 1, "Expected only one processor to be applied") + } + + @Test func thatProcessingDeduplicationCanBeDisabled() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.isTaskCoalescingEnabled = false + } + + // Given requests with the same URLs but different processors + let processors = MockProcessorFactory() + let request1 = ImageRequest(url: Test.url, processors: [processors.make(id: "1")]) + let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "1"), processors.make(id: "2")]) + + // When + async let task1 = pipeline.image(for: request1) + async let task2 = pipeline.image(for: request2) + let (image1, image2) = try await (task1, task2) + + // Then + #expect(image1.nk_test_processorIDs == ["1"]) + #expect(image2.nk_test_processorIDs == ["1", "2"]) + + // Then the processor "1" is applied twice + #expect(processors.numberOfProcessorsApplied == 3) + } + + @Test func thatDataOnlyLoadedOnceWithDifferentCachePolicy() async throws { + // Given + let dataCache = MockDataCache() + pipeline = pipeline.reconfigured { + $0.dataCache = dataCache + } + + // When + func makeRequest(options: ImageRequest.Options) -> ImageRequest { + ImageRequest(urlRequest: URLRequest(url: Test.url), options: options) + } + async let task1 = pipeline.image(for: makeRequest(options: [])) + async let task2 = pipeline.image(for: makeRequest(options: [.reloadIgnoringCachedData])) + _ = try await (task1, task2) + + // Then + #expect(dataLoader.createdTaskCount == 1, "Expected only one data task to be performed") + } + + @Test func thatDataOnlyLoadedOnceWithDifferentCachePolicyPassingURL() async throws { + // Given + let dataCache = MockDataCache() + pipeline = pipeline.reconfigured { + $0.dataCache = dataCache + } + + // When + // - One request reloading cache data, another one not + func makeRequest(options: ImageRequest.Options) -> ImageRequest { + ImageRequest(url: Test.url, options: options) + } + async let task1 = pipeline.image(for: makeRequest(options: [])) + async let task2 = pipeline.image(for: makeRequest(options: [.reloadIgnoringCachedData])) + _ = try await (task1, task2) + + + // Then + #expect(dataLoader.createdTaskCount == 1, "Expected only one data task to be performed") + } +} diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessorTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessorTests.swift index 99ca391c9..f8ad06b95 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessorTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessorTests.swift @@ -1,21 +1,21 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing -import XCTest @testable import Nuke #if !os(macOS) import UIKit #endif -class ImagePipelineProcessorTests: XCTestCase { +@Suite struct ImagePipelineProcessorTests { var mockDataLoader: MockDataLoader! var pipeline: ImagePipeline! - override func setUp() { - super.setUp() - + init() { mockDataLoader = MockDataLoader() pipeline = ImagePipeline { $0.dataLoader = mockDataLoader @@ -23,28 +23,22 @@ class ImagePipelineProcessorTests: XCTestCase { } } - override func tearDown() { - super.tearDown() - } - // MARK: - Applying Filters - func testThatImageIsProcessed() { + @Test func thatImageIsProcessed() async throws { // Given let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "processor1")]) // When - expect(pipeline).toLoadImage(with: request) { result in - // Then - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["processor1"]) - } - wait() + let image = try await pipeline.image(for: request) + + // Then + #expect(image.nk_test_processorIDs == ["processor1"]) } // MARK: - Composing Filters - func testApplyingMultipleProcessors() { + @Test func applyingMultipleProcessors() async throws { // Given let request = ImageRequest( url: Test.url, @@ -55,42 +49,35 @@ class ImagePipelineProcessorTests: XCTestCase { ) // When - expect(pipeline).toLoadImage(with: request) { result in - // Then - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["processor1", "processor2"]) - } - wait() + let image = try await pipeline.image(for: request) + + // Then + #expect(image.nk_test_processorIDs == ["processor1", "processor2"]) } - func testPerformingRequestWithoutProcessors() { + @Test func performingRequestWithoutProcessors() async throws { // Given let request = ImageRequest(url: Test.url, processors: []) // When - expect(pipeline).toLoadImage(with: request) { result in - // Then - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs ?? [], []) - } - wait() + let image = try await pipeline.image(for: request) + + // Then + #expect(image.nk_test_processorIDs == []) } // MARK: - Decompression #if !os(macOS) - func testDecompressionSkippedIfProcessorsAreApplied() { + @Test func decompressionSkippedIfProcessorsAreApplied() async throws { // Given let request = ImageRequest(url: Test.url, processors: [ImageProcessors.Anonymous(id: "1", { image in - XCTAssertTrue(ImageDecompression.isDecompressionNeeded(for: image) == true) + #expect(ImageDecompression.isDecompressionNeeded(for: image) == true) return image })]) // When - expect(pipeline).toLoadImage(with: request) { result in - // Then - } - wait() + _ = try await pipeline.image(for: request) } #endif } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineProgressiveDecodingTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineProgressiveDecodingTests.swift index a67fa1007..8276b9946 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineProgressiveDecodingTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineProgressiveDecodingTests.swift @@ -1,25 +1,26 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing -import XCTest @testable import Nuke -class ImagePipelineProgressiveDecodingTests: XCTestCase { +@ImagePipelineActor +@Suite class ImagePipelineProgressiveDecodingTests { private var dataLoader: MockProgressiveDataLoader! private var pipeline: ImagePipeline! private var cache: MockImageCache! private var processorsFactory: MockProcessorFactory! - - override func setUp() { - super.setUp() - + + init() { dataLoader = MockProgressiveDataLoader() ResumableDataStorage.shared.removeAllResponses() - + cache = MockImageCache() processorsFactory = MockProcessorFactory() - + // We make two important assumptions with this setup: // // 1. Image processing is serial which means that all partial images are @@ -29,326 +30,367 @@ class ImagePipelineProgressiveDecodingTests: XCTestCase { // // 2. Each data chunk produced by a data loader always results in a new // scan. The way we split the data guarantees that. - + pipeline = ImagePipeline { $0.dataLoader = dataLoader $0.imageCache = cache $0.isProgressiveDecodingEnabled = true $0.isStoringPreviewsInMemoryCache = true - $0.imageProcessingQueue.maxConcurrentOperationCount = 1 + $0.imageProcessingQueue.maxConcurrentJobCount = 1 } } - - // MARK: - Basics - - // Very basic test, just make sure that partial images get produced and - // that the completion handler is called at the end. - func testProgressiveDecoding() { + + @Test func testProgressDecoding() async throws { // Given - // - An image which supports progressive decoding - // - A pipeline with progressive decoding enabled - - // Then two scans are produced - let expectPartialImageProduced = self.expectation(description: "Partial Image Is Produced") - expectPartialImageProduced.expectedFulfillmentCount = 2 - - // Then the final image is produced - let expectFinalImageProduced = self.expectation(description: "Final Image Is Produced") - + let imageTask = pipeline.imageTask(with: Test.request) + // When - pipeline.loadImage( - with: Test.request, - progress: { response, _, _ in - // This works because each new chunk resulted in a new scan - if let container = response?.container { - // Then image previews are produced - XCTAssertTrue(container.isPreview) - - // Then the preview is stored in memory cache - let cached = self.cache[Test.request] - XCTAssertNotNil(cached) - XCTAssertTrue(cached?.isPreview ?? false) - XCTAssertEqual(cached?.image, container.image) - - expectPartialImageProduced.fulfill() - self.dataLoader.resume() - } - }, - completion: { result in - // Then the final image is produced - switch result { - case let .success(response): - XCTAssertFalse(response.container.isPreview) - case .failure: - XCTFail("Unexpected failure") - } - - // Then the preview is overwritted with the final image in memory cache - let cached = self.cache[Test.request] - XCTAssertNotNil(cached) - XCTAssertFalse(cached?.isPreview ?? false) - XCTAssertEqual(cached?.image, result.value?.image) - - expectFinalImageProduced.fulfill() - } - ) - - wait() + var previewCount = 0 + for try await preview in imageTask.previews { + // Then image previews are produced + #expect(preview.isPreview) + + // Then the preview is stored in memory cache + let cached = try #require(cache[Test.request]) + #expect(cached.isPreview) + #expect(cached.image == preview.image) + + previewCount += 1 + dataLoader.resume() + } + + // Then two previws are received + #expect(previewCount == 2) + + // When + let response = try await imageTask.response + + // Then + #expect(!response.container.isPreview) + + let cached = try #require(cache[Test.request]) + #expect(!cached.isPreview) + #expect(cached.image == response.image) } - - func testThatFailedPartialImagesAreIgnored() { + + @Test func thatFailedPartialImagesAreIgnored() async { // Given class FailingPartialsDecoder: ImageDecoding, @unchecked Sendable { func decode(_ data: Data) throws -> ImageContainer { try ImageDecoders.Default().decode(data) } } - + let registry = ImageDecoderRegistry() - + registry.register { _ in FailingPartialsDecoder() } - + pipeline = pipeline.reconfigured { $0.makeImageDecoder = { registry.decoder(for: $0) } } - - // When/Then - let finalLoaded = self.expectation(description: "Final image loaded") - - pipeline.loadImage( - with: Test.request, - progress: { image, _, _ in - XCTAssertNil(image, "Expected partial images to never be produced") // Partial images never produced. - self.dataLoader.resume() - }, - completion: { result in - XCTAssertTrue(result.isSuccess, "Expected the final image to be produced") - finalLoaded.fulfill() + + // When + let imageTask = pipeline.imageTask(with: Test.request) + + // Then + for await event in imageTask.events { + switch event { + case .progress: + dataLoader.resume() + case .preview: + Issue.record("Expected partial images to never be produced") + case .finished(let result): + if case .failure(.cancelled) = result { + Issue.record("Task was unexpectedly cancelled") + } + #expect(result.isSuccess, "Expected the final image to be produced") } - ) - - wait() + } } - + // MARK: - Image Processing - + #if !os(macOS) - func testThatPartialImagesAreResized() { + @Test func thatPartialImagesAreResized() async { // Given let image = PlatformImage(data: dataLoader.data) - XCTAssertEqual(image?.cgImage?.width, 450) - XCTAssertEqual(image?.cgImage?.height, 300) - + #expect(image?.cgImage?.width == 450) + #expect(image?.cgImage?.height == 300) + let request = ImageRequest( url: Test.url, processors: [ImageProcessors.Resize(size: CGSize(width: 45, height: 30), unit: .pixels)] ) - - // When/Then - expect(pipeline, dataLoader).toProducePartialImages( - for: request, - progress: { response, _, _ in - if let image = response?.image { - XCTAssertEqual(image.cgImage?.width, 45, "Expected progressive image to be resized") - XCTAssertEqual(image.cgImage?.height, 30, "Expected progressive image to be resized") + + // When + let imageTask = pipeline.imageTask(with: request) + for await event in imageTask.events { + switch event { + case .progress: + dataLoader.resume() + case .preview(let response): + // Then previews are resized + #expect(response.isPreview) + #expect(response.image.cgImage?.width == 45) + #expect(response.image.cgImage?.height == 30) + case .finished(let result): + switch result { + case .success(let response): + // Then the final image is also resized + #expect(!response.isPreview) + #expect(response.image.cgImage?.width == 45) + #expect(response.image.cgImage?.height == 30) + case .failure(.cancelled): + Issue.record("Task was unexpectedly cancelled") + case .failure: + Issue.record() } - }, - completion: { result in - XCTAssertTrue(result.isSuccess, "Expected the final image to be produced") - let image = result.value?.image - XCTAssertEqual(image?.cgImage?.width, 45, "Expected the final image to be resized") - XCTAssertEqual(image?.cgImage?.height, 30, "Expected the final image to be resized") + #expect(result.isSuccess, "Expected the final image to be produced") } - ) - - wait() + } } #endif - - func testThatPartialImagesAreProcessed() { + + @Test func thatPartialImagesAreProcessed() async { // Given - let request = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "_image_processor")]) - + let request = ImageRequest(url: Test.url, processors: [ + MockImageProcessor(id: "_image_processor")] + ) + // When/Then - expect(pipeline, dataLoader).toProducePartialImages( - for: request, - progress: { response, _, _ in - if let image = response?.image { - XCTAssertEqual(image.nk_test_processorIDs.count, 1) - XCTAssertEqual(image.nk_test_processorIDs.first, "_image_processor") + let imageTask = pipeline.imageTask(with: request) + for await event in imageTask.events { + switch event { + case .progress: + dataLoader.resume() + case .preview(let response): + // Then previews are resized + #expect(response.isPreview) + #expect(response.image.nk_test_processorIDs == ["_image_processor"]) + case .finished(let result): + switch result { + case .success(let response): + // Then the final image is also resized + #expect(!response.isPreview) + #expect(response.image.nk_test_processorIDs == ["_image_processor"]) + case .failure(.cancelled): + Issue.record("Task was unexpectedly cancelled") + case .failure: + Issue.record() } - }, - completion: { result in - let image = result.value?.image - XCTAssertEqual(image?.nk_test_processorIDs.count, 1) - XCTAssertEqual(image?.nk_test_processorIDs.first, "_image_processor") } - ) - wait() + } } - - func testProgressiveDecodingDisabled() { + + @Test func progressiveDecodingDisabled() async { // Given var configuration = pipeline.configuration configuration.isProgressiveDecodingEnabled = false pipeline = ImagePipeline(configuration: configuration) - + // When/Then - let expectFinalImageProduced = self.expectation(description: "Final Image Is Produced") - pipeline.loadImage( - with: Test.request, - progress: { response, _, _ in - XCTAssertNil(response, "Expected partial images to never be produced") - self.dataLoader.resume() - }, - completion: { result in - XCTAssertTrue(result.isSuccess) - expectFinalImageProduced.fulfill() + let imageTask = pipeline.imageTask(with: Test.request) + for await event in imageTask.events { + switch event { + case .progress: + dataLoader.resume() + case .preview: + Issue.record("No previwes should be produced") + case .finished(let result): + switch result { + case .success(let response): + // Then the final image is also resized + #expect(!response.isPreview) + case .failure(.cancelled): + Issue.record("Task was unexpectedly cancelled") + case .failure: + Issue.record() + } } - ) - wait() + } } - + // MARK: Back Pressure - - func testBackpressureImageDecoding() { - // GIVEN + + @Test func backpressureImageDecoding() async throws { pipeline = pipeline.reconfigured { $0.makeImageDecoder = { _ in MockImageDecoder(name: "a") } } - - let queue = pipeline.configuration.imageDecodingQueue - - // When we receive progressive image data at a higher rate that we can - // process (we suspended the queue in test) we don't try to process - // new scans until we finished processing the first one. - - queue.isSuspended = true - expect(queue).toFinishWithEnqueuedOperationCount(2) // 1 partial, 1 final - - let finalLoaded = self.expectation(description: "Final image produced") - - let request = ImageRequest(url: Test.url, processors: [ImageProcessors.Anonymous(id: "1", { $0 })]) - pipeline.loadImage( - with: request, - progress: { image, _, _ in - if image != nil { - // We don't expect partial to finish, because as soon as - // we create operation to create final image, partial - // operations is going to be finished before even starting - } - self.dataLoader.resume() - }, - completion: { result in - XCTAssertTrue(result.isSuccess) - finalLoaded.fulfill() - } - ) - - wait() - } - - func testBackpressureProcessingImageProcessingOperationCancelled() throws { + // Given - let imageProcessingQueue = pipeline.configuration.imageProcessingQueue - imageProcessingQueue.isSuspended = true - + let decodingQueue = pipeline.configuration.imageDecodingQueue + decodingQueue.isSuspended = true + // When the first chunk is delivered // Then the first processing operation is enqueue - let observer = expect(imageProcessingQueue).toEnqueueOperationsWithCount(1) - - let imageLoadCompleted = NSNotification.Name(rawValue: "ImageLoadCompleted") - - let request = ImageRequest(url: Test.url, processors: [ImageProcessors.Anonymous(id: "1", { $0 })]) - pipeline.loadImage( - with: request, - progress: { _, _, _ in - - }, - completion: { result in - XCTAssertTrue(result.isSuccess) - NotificationCenter.default.post(name: imageLoadCompleted, object: nil) + let expectationDecodingStarted = decodingQueue.expectJobAdded() + let expectationImageLoaded = AsyncExpectation() + + Task { @ImagePipelineActor in + do { + let response = try await pipeline.imageTask(with: Test.request).response + expectationImageLoaded.fulfill(with: response) + } catch { + Issue.record(error) } - ) - wait() - - // When the second chunk is deliverd, the new operation - // is not created + } + + let firstDecodingTask = await expectationDecodingStarted.wait() + + // When the second chunk is delivered dataLoader.serveNextChunk() - let expectation = self.expectation(description: "NoOperationCreated") - expectation.isInverted = true - wait(for: [expectation], timeout: 0.2) - - XCTAssertEqual(imageProcessingQueue.operationCount, 1) - - // When last chunk is delivered, initial processing - // operation is cancelled - let operation = try XCTUnwrap(observer.operations.first) - expect(operation).toCancel() - + + let expectationPreviewDecodingCancelled = decodingQueue.expectJobCancelled(firstDecodingTask) + + // When the last chunk is delivered the dataLoader.serveNextChunk() - wait() - - // Then final image is loaded - expectNotification(imageLoadCompleted) - imageProcessingQueue.isSuspended = false - wait() + + // Then the preview processing task gets cancelled + _ = await expectationPreviewDecodingCancelled.wait() + + // When processing is resumed + decodingQueue.isSuspended = false + + // Then final image is loaded and other expectation are met + let response = await expectationImageLoaded.wait() + #expect(!response.isPreview) } - - // MARK: Memory Cache - - func testIntermediateMemoryCachedResultsAreDelivered() { - // GIVEN intermediate result stored in memory cache - let request = ImageRequest(url: Test.url, processors: [ - processorsFactory.make(id: "1"), - processorsFactory.make(id: "2") - ]) - let intermediateRequest = ImageRequest(url: Test.url, processors: [ - processorsFactory.make(id: "1") - ]) - cache[intermediateRequest] = ImageContainer(image: Test.image, isPreview: true) - - pipeline.configuration.dataLoadingQueue.isSuspended = true // Make sure no data is loaded - - // WHEN/THEN the pipeline find the first preview in the memory cache, - // applies the remaining processors and delivers it - let previewDelivered = self.expectation(description: "previewDelivered") - pipeline.loadImage(with: request) { response, _, _ in - guard let response else { - return XCTFail() + + @Test func backpressureImageProcessing() async throws { + // Given + let processingQueue = pipeline.configuration.imageProcessingQueue + processingQueue.isSuspended = true + + // Given a request with a processor + let request = ImageRequest( + url: Test.url, + processors: [ImageProcessors.Anonymous(id: "1", { $0 })] + ) + + // When the first chunk is delivered + // Then the first processing operation is enqueue + let expectationProcessingStarted = processingQueue.expectJobAdded() + let expectationImageLoaded = AsyncExpectation() + + Task { @ImagePipelineActor in + do { + let response = try await pipeline.imageTask(with: request).response + expectationImageLoaded.fulfill(with: response) + } catch { + Issue.record(error) } - XCTAssertEqual(response.image.nk_test_processorIDs, ["2"]) - XCTAssertTrue(response.container.isPreview) - previewDelivered.fulfill() - } completion: { _ in - // Do nothing } - wait() + + let firstProcessingJob = await expectationProcessingStarted.wait() + + // When the second chunk is delivered + dataLoader.serveNextChunk() + + let expectationPreviewProcessingCancelled = processingQueue.expectJobCancelled(firstProcessingJob) + + // When the last chunk is delivered the + dataLoader.serveNextChunk() + + // Then the preview processing task gets cancelled + _ = await expectationPreviewProcessingCancelled.wait() + + // When processing is resumed + processingQueue.isSuspended = false + + // Then final image is loaded and other expectation are met + let response = await expectationImageLoaded.wait() + #expect(!response.isPreview) } - + // MARK: Scale - + #if os(iOS) || os(visionOS) - func testOverridingImageScaleWithFloat() throws { - // GIVEN + @Test func overridingImageScaleWithFloat() async { + // Given let request = ImageRequest(url: Test.url, userInfo: [.scaleKey: 7.0]) - - // WHEN/THEN the pipeline find the first preview in the memory cache, + + // When/Then the pipeline find the first preview in the memory cache, // applies the remaining processors and delivers it - let previewDelivered = self.expectation(description: "previewDelivered") + let previewDelivered = AsyncExpectation() pipeline.loadImage(with: request) { response, _, _ in guard let response else { return } - XCTAssertTrue(response.container.isPreview) - XCTAssertEqual(response.image.scale, 7) + #expect(response.container.isPreview) + #expect(response.image.scale == 7) previewDelivered.fulfill() } completion: { _ in // Do nothing } - wait() + await previewDelivered.wait() } #endif + + // MARK: - Callbacks + + // Very basic test, just make sure that partial images get produced and + // that the completion handler is called at the end. + @Test func callbacksProgressiveDecoding() async { + // Given + // - An image which supports progressive decoding + // - A pipeline with progressive decoding enabled + + // Then two scans are produced + let expectPartialImageProduced = AsyncExpectation() + let previewCount = Mutex(wrappedValue: 0) + + // Then the final image is produced + let expectFinalImageProduced = AsyncExpectation() + + // When + pipeline.loadImage( + with: Test.request, + progress: { [cache, dataLoader] response, _, _ in + // This works because each new chunk resulted in a new scan + if let container = response?.container { + // Then image previews are produced + #expect(container.isPreview) + + // Then the preview is stored in memory cache + let cached = cache?[Test.request] + #expect(cached != nil) + #expect(cached?.isPreview ?? false) + #expect(cached?.image == container.image) + + let count = previewCount.withLock { + $0 += 1 + return $0 + } + if count == 2 { + expectPartialImageProduced.fulfill() + } else if count > 2 { + Issue.record() + } + dataLoader?.resume() + } + }, + completion: { [cache] result in + // Then the final image is produced + switch result { + case let .success(response): + #expect(!response.container.isPreview) + case .failure: + Issue.record("Unexpected failure") + } + + // Then the preview is overwritted with the final image in memory cache + let cached = cache?[Test.request] + #expect(cached != nil) + #expect(!(cached?.isPreview ?? false)) + #expect(cached?.image == result.value?.image) + + expectFinalImageProduced.fulfill() + } + ) + + await expectPartialImageProduced.wait() + await expectFinalImageProduced.wait() + } } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelinePublisherTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelinePublisherTests.swift deleted file mode 100644 index 2009f3aad..000000000 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelinePublisherTests.swift +++ /dev/null @@ -1,195 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -import Combine -@testable import Nuke - -class ImagePipelinePublisherTests: XCTestCase { - var dataLoader: MockDataLoader! - var imageCache: MockImageCache! - var dataCache: MockDataCache! - var observer: ImagePipelineObserver! - var pipeline: ImagePipeline! - - override func setUp() { - super.setUp() - - dataLoader = MockDataLoader() - imageCache = MockImageCache() - dataCache = MockDataCache() - observer = ImagePipelineObserver() - pipeline = ImagePipeline(delegate: observer) { - $0.dataLoader = dataLoader - $0.imageCache = imageCache - $0.dataCache = dataCache - } - } - - 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 - - // WHEN - let cancellable = pipeline - .imagePublisher(with: Test.request) - .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) - expectNotification(ImagePipelineObserver.didCancelTask, object: observer) - cancellable.cancel() - 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")!) - } - - func testInitWithImageRequest() { - _ = pipeline.imagePublisher(with: ImageRequest(url: URL(string: "https://example.com/image.jpeg"))) - } -} - -class ImagePipelinePublisherProgressiveDecodingTests: XCTestCase { - private var dataLoader: MockProgressiveDataLoader! - private var imageCache: MockImageCache! - private var pipeline: ImagePipeline! - private var cancellable: AnyCancellable? - - override func setUp() { - super.setUp() - - dataLoader = MockProgressiveDataLoader() - imageCache = MockImageCache() - ResumableDataStorage.shared.removeAllResponses() - - pipeline = ImagePipeline { - $0.dataLoader = dataLoader - $0.imageCache = imageCache - $0.isResumableDataEnabled = false - $0.isProgressiveDecodingEnabled = true - $0.isStoringPreviewsInMemoryCache = true - } - } - - func testImagePreviewsAreDelivered() { - let imagesProduced = self.expectation(description: "ImagesProduced") - imagesProduced.expectedFulfillmentCount = 3 // 2 partial, 1 final - var previewsCount = 0 - let completed = self.expectation(description: "Completed") - - // WHEN - let publisher = pipeline.imagePublisher(with: Test.url) - cancellable = publisher.sink(receiveCompletion: { completion in - switch completion { - case .failure: - XCTFail() - case .finished: - completed.fulfill() - } - - }, receiveValue: { response in - imagesProduced.fulfill() - if previewsCount == 2 { - XCTAssertFalse(response.container.isPreview) - } else { - XCTAssertTrue(response.container.isPreview) - previewsCount += 1 - } - self.dataLoader.resume() - }) - wait() - } - - func testImagePreviewsAreDeliveredFromMemoryCacheSynchronously() { - // GIVEN - pipeline.cache[Test.request] = ImageContainer(image: Test.image, isPreview: true) - - let imagesProduced = self.expectation(description: "ImagesProduced") - // 1 preview from sync cache lookup - // 1 preview from async cache lookup (we don't want it really though) - // 2 previews from data loading - // 1 final image - // we also expect resumable data to kick in for real downloads - imagesProduced.expectedFulfillmentCount = 5 - var previewsCount = 0 - var isFirstPreviewProduced = false - let completed = self.expectation(description: "Completed") - - // WHEN - let publisher = pipeline.imagePublisher(with: Test.url) - cancellable = publisher.sink(receiveCompletion: { completion in - switch completion { - case .failure: - XCTFail() - case .finished: - completed.fulfill() - } - - }, receiveValue: { response in - imagesProduced.fulfill() - previewsCount += 1 - if previewsCount == 5 { - XCTAssertFalse(response.container.isPreview) - } else { - XCTAssertTrue(response.container.isPreview) - if previewsCount >= 3 { - self.dataLoader.resume() - } else { - isFirstPreviewProduced = true - } - } - }) - XCTAssertTrue(isFirstPreviewProduced) - wait(200, handler: nil) - } -} diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineResumableDataTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineResumableDataTests.swift index 8fc267d23..408df99c9 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineResumableDataTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineResumableDataTests.swift @@ -1,17 +1,18 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing -import XCTest @testable import Nuke -class ImagePipelineResumableDataTests: XCTestCase { +@ImagePipelineActor +@Suite struct ImagePipelineResumableDataTests { private var dataLoader: _MockResumableDataLoader! private var pipeline: ImagePipeline! - override func setUp() { - super.setUp() - + init() { dataLoader = _MockResumableDataLoader() ResumableDataStorage.shared.removeAllResponses() pipeline = ImagePipeline { @@ -20,45 +21,68 @@ class ImagePipelineResumableDataTests: XCTestCase { } } - func testThatProgressIsReported() { + @Test func thatProgressIsReported() async throws { // Given an initial request failed mid download + var recorded: [ImageTask.Progress] = [] + let request = Test.request - // Expect the progress for the first part of the download to be reported. - let expectedProgressInitial = expectProgress( - [(3799, 22789), (7598, 22789), (11397, 22789)] - ) - expect(pipeline).toFailRequest(Test.request, progress: { _, completed, total in - expectedProgressInitial.received((completed, total)) - }) - wait() - - // Expect progress closure to continue reporting the progress of the - // entire download - let expectedProgersRemaining = expectProgress( - [(15196, 22789), (18995, 22789), (22789, 22789)] - ) - expect(pipeline).toLoadImage(with: Test.request, progress: { _, completed, total in - expectedProgersRemaining.received((completed, total)) - }) - wait() - } + // When + for await progress in pipeline.imageTask(with: request).progress { + recorded.append(progress) + } - func testThatResumableDataIsntSavedIfCancelledWhenDownloadIsCompleted() { + // Then + #expect(recorded == [ + ImageTask.Progress(completed: 3799, total: 22789), + ImageTask.Progress(completed: 7598, total: 22789), + ImageTask.Progress(completed: 11397, total: 22789) + ]) + + // When restarting the request + recorded = [] + for await progress in pipeline.imageTask(with: request).progress { + recorded.append(progress) + } + // Then remaining progress is reported + #expect(recorded == [ + ImageTask.Progress(completed: 15196, total: 22789), + ImageTask.Progress(completed: 18995, total: 22789), + ImageTask.Progress(completed: 22789, total: 22789) + ]) } } -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 +98,7 @@ private class _MockResumableDataLoader: DataLoading, @unchecked Sendable { ] )! - didReceiveData.value(chunk, response) + didReceiveData(chunk, response) } var chunks = chunks @@ -89,9 +113,9 @@ private class _MockResumableDataLoader: DataLoading, @unchecked Sendable { // Check if the client already has some resumable data available. if let range = headers?["Range"], let validator = headers?["If-Range"] { let offset = _groups(regex: "bytes=(\\d*)-", in: range)[0] - XCTAssertNotNil(offset) + #expect(offset != nil) - XCTAssertEqual(validator, eTag, "Expected validator to be equal to ETag") + #expect(validator == eTag, "Expected validator to be equal to ETag") guard validator == eTag else { // Expected ETag return _Task() } @@ -102,7 +126,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 +135,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..8532cef9b 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTaskDelegateTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTaskDelegateTests.swift @@ -1,18 +1,18 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing -import XCTest @testable import Nuke -class ImagePipelineTaskDelegateTests: XCTestCase { +@Suite struct ImagePipelineTaskDelegateTests { private var dataLoader: MockDataLoader! private var pipeline: ImagePipeline! private var delegate: ImagePipelineObserver! - override func setUp() { - super.setUp() - + init() { dataLoader = MockDataLoader() delegate = ImagePipelineObserver() @@ -22,58 +22,50 @@ class ImagePipelineTaskDelegateTests: XCTestCase { } } - func testStartAndCompletedEvents() throws { - var result: Result? - expect(pipeline).toLoadImage(with: Test.request) { result = $0 } - wait() + @Test func startAndCompletedEvents() async throws { + let result = await Task { + try await pipeline.imageTask(with: Test.url).response + }.result.mapError { $0 as! ImageTask.Error } // Then - XCTAssertEqual(delegate.events, [ - ImageTaskEvent.created, - .started, - .progressUpdated(completedUnitCount: 22789, totalUnitCount: 22789), - .completed(result: try XCTUnwrap(result)) + #expect(delegate.events == [ + .progress(.init(completed: 22789, total: 22789)), + .finished(result) ]) } - func testProgressUpdateEvents() throws { - let request = ImageRequest(url: Test.url) + @Test func progressUpdateEvents() async throws { dataLoader.results[Test.url] = .success( (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) ) - var result: Result? - expect(pipeline).toFailRequest(request) { result = $0 } - wait() + let result = await Task { + try await pipeline.imageTask(with: Test.url).response + }.result.mapError { $0 as! ImageTask.Error } // Then - XCTAssertEqual(delegate.events, [ - ImageTaskEvent.created, - .started, - .progressUpdated(completedUnitCount: 10, totalUnitCount: 20), - .progressUpdated(completedUnitCount: 20, totalUnitCount: 20), - .completed(result: try XCTUnwrap(result)) + #expect(delegate.events == [ + .progress(.init(completed: 10, total: 20)), + .progress(.init(completed: 20, total: 20)), + .finished(result) ]) } - func testCancellationEvents() { + @Test func cancellationEvents() async throws { dataLoader.queue.isSuspended = true - expectNotification(MockDataLoader.DidStartTask, object: dataLoader) - let task = pipeline.loadImage(with: Test.request) { _ in - XCTFail() - } - wait() // Wait till operation is created + let expectation1 = AsyncExpectation(notification: MockDataLoader.DidStartTask, object: dataLoader) + let task = pipeline.imageTask(with: Test.request) + await expectation1.wait() - expectNotification(ImagePipelineObserver.didCancelTask, object: delegate) + // When + let expectation2 = AsyncExpectation(notification: ImagePipelineObserver.didCancelTask, object: delegate) task.cancel() - wait() + await expectation2.wait() // Then - XCTAssertEqual(delegate.events, [ - ImageTaskEvent.created, - .started, - .cancelled + #expect(delegate.events == [ + .finished(.failure(.cancelled)) ]) } } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests+Decompression.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests+Decompression.swift new file mode 100644 index 000000000..5f7cb77f8 --- /dev/null +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests+Decompression.swift @@ -0,0 +1,75 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation + +@testable import Nuke + +#if canImport(UIKit) + +extension ImagePipelineTests { + @Test func disablingDecompression() async throws { + // Given + pipeline = pipeline.reconfigured { + $0.isDecompressionEnabled = false + } + + // When + let image = try await pipeline.image(for: Test.url) + + // Then + #expect(true == ImageDecompression.isDecompressionNeeded(for: image)) + } + + @Test func disablingDecompressionForIndividualRequest() async throws { + // Given + let request = ImageRequest(url: Test.url, options: [.skipDecompression]) + + // When + let image = try await pipeline.image(for: request) + + // Then + #expect(true == ImageDecompression.isDecompressionNeeded(for: image)) + } + + @Test func decompressionPerformed() async throws { + // When + let image = try await pipeline.image(for: Test.request) + + // Then + #expect(ImageDecompression.isDecompressionNeeded(for: image) == nil) + } + + @Test func decompressionNotPerformedWhenProcessorWasApplied() async throws { + // Given request with scaling processor + let input = Test.image + pipeline = pipeline.reconfigured { + $0.makeImageDecoder = { _ in MockAnonymousImageDecoder(output: input) } + } + + let request = ImageRequest(url: Test.url, processors: [ + .resize(size: CGSize(width: 40, height: 40)) + ]) + + // When + _ = try await pipeline.image(for: request) + + // Then + #expect(true == ImageDecompression.isDecompressionNeeded(for: input)) + } + + @Test func decompressionPerformedWhenProcessorIsAppliedButDoesNothing() async throws { + // Given request with scaling processor + let request = ImageRequest(url: Test.url, processors: [MockEmptyImageProcessor()]) + + // When + let image = try await pipeline.image(for: request) + + // Then decompression to be performed (processor is applied but it did nothing) + #expect(ImageDecompression.isDecompressionNeeded(for: image) == nil) + } +} + +#endif diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests+Priority.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests+Priority.swift new file mode 100644 index 000000000..c3e748197 --- /dev/null +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests+Priority.swift @@ -0,0 +1,83 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation + +@testable import Nuke + +extension ImagePipelineTests { + @Test func updatedDataLoadingQueuePriority() async { + // Given + let queue = pipeline.configuration.dataLoadingQueue + queue.isSuspended = true + + let request = Test.request + #expect(request.priority == .normal) + + // When + let expectation = queue.expectJobAdded() + let imageTask = pipeline.imageTask(with: request) + Task { + try await imageTask.response + } + let job = await expectation.value + + // Then + #expect(job.priority == .normal) + + let expectation2 = queue.expectPriorityUpdated(for: job) + imageTask.priority = .high + let newPriority = await expectation2.wait() + + #expect(newPriority == .high) + #expect(job.priority == .high) + } + + @Test func updateDecodingPriority() async { + // Given + pipeline = pipeline.reconfigured { + $0.makeImageDecoder = { _ in MockImageDecoder(name: "test") } + } + + let queue = pipeline.configuration.imageDecodingQueue + queue.isSuspended = true + + let request = Test.request + #expect(request.priority == .normal) + + let expectation = queue.expectJobAdded() + let task = pipeline.loadImage(with: request) { _ in } + let job = await expectation.value + + // When + let expectation2 = queue.expectPriorityUpdated(for: job) + task.priority = .high + + // Then + let newPriority = await expectation2.wait() + #expect(newPriority == .high) + } + + @Test func updateProcessingPriority() async { + // Given + let queue = pipeline.configuration.imageProcessingQueue + queue.isSuspended = true + + let request = ImageRequest(url: Test.url, processors: [ImageProcessors.Anonymous(id: "1", { $0 })]) + #expect(request.priority == .normal) + + let expectation = queue.expectJobAdded() + let task = pipeline.loadImage(with: request) { _ in } + let job = await expectation.value + + // When + let expectation2 = queue.expectPriorityUpdated(for: job) + task.priority = .high + + // Then + let newPriority = await expectation2.wait() + #expect(newPriority == .high) + } +} diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift index 38f469b47..c7bacd333 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift @@ -1,481 +1,582 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation -import XCTest -import Combine @testable import Nuke -class ImagePipelineTests: XCTestCase { +@ImagePipelineActor +@Suite class ImagePipelineTests { var dataLoader: MockDataLoader! var pipeline: ImagePipeline! - - override func setUp() { - super.setUp() - + + private var recordedEvents: [ImageTask.Event] = [] + private var recordedResult: Result? + private var recordedProgress: [ImageTask.Progress] = [] + private var recordedPreviews: [ImageResponse] = [] + private var pipelineDelegate = ImagePipelineObserver() + private var imageTask: ImageTask? + + init() { dataLoader = MockDataLoader() - pipeline = ImagePipeline { + pipeline = ImagePipeline(delegate: pipelineDelegate) { $0.dataLoader = dataLoader $0.imageCache = nil } } - - // MARK: - Completion - - func testCompletionCalledAsynchronouslyOnMainThread() { - var isCompleted = false - expect(pipeline).toLoadImage(with: Test.request) { _ in - XCTAssert(Thread.isMainThread) - isCompleted = true - } - XCTAssertFalse(isCompleted) - wait() + + // MARK: - Basics + + @Test func imageIsLoaded() async throws { + // When + let image = try await pipeline.image(for: Test.request) + + // Then + #expect(image.sizeInPixels == CGSize(width: 640, height: 480)) } - - // MARK: - Progress - - func testProgressClosureIsCalled() { + + // MARK: - Task-based API + + @Test func taskBasedImageResponse() async throws { // Given - let request = ImageRequest(url: Test.url) - - dataLoader.results[Test.url] = .success( - (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) - ) - + let task = pipeline.imageTask(with: Test.request) + // When - let expectedProgress = expectProgress([(10, 20), (20, 20)]) - - pipeline.loadImage( - with: request, - progress: { _, completed, total in - // Then - XCTAssertTrue(Thread.isMainThread) - expectedProgress.received((completed, total)) - }, - completion: { _ in } - ) - - wait() + let response = try await task.response + + // Then + #expect(response.image.sizeInPixels == CGSize(width: 640, height: 480)) } - - func testTaskProgressIsUpdated() { + + @Test func taskBasedImage() async throws { // Given - let request = ImageRequest(url: Test.url) - - dataLoader.results[Test.url] = .success( - (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) - ) - + let task = pipeline.imageTask(with: Test.request) + // When - let expectedProgress = expectProgress([(10, 20), (20, 20)]) - - pipeline.loadImage( - with: request, - progress: { _, completed, total in - // Then - XCTAssertTrue(Thread.isMainThread) - expectedProgress.received((completed, total)) - }, - completion: { _ in } - ) - - wait() + let image = try await task.image + + // Then + #expect(image.sizeInPixels == CGSize(width: 640, height: 480)) } - - // 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() + + private var observer: AnyObject? + + // MARK: - Cancellation + + @Test func cancellation() async throws { + dataLoader.queue.isSuspended = true + + let task = Task { + try await pipeline.image(for: Test.url) + } + + observer = NotificationCenter.default.addObserver(forName: MockDataLoader.DidStartTask, object: dataLoader, queue: OperationQueue()) { _ in + task.cancel() + } + + do { + _ = try await task.value + } catch { + #expect(error as? ImageTask.Error == .cancelled) + } } - - // MARK: - Updating Priority - - func testDataLoadingPriorityUpdated() { - // Given - let queue = pipeline.configuration.dataLoadingQueue - queue.isSuspended = true - - let request = Test.request - XCTAssertEqual(request.priority, .normal) - - let observer = expect(queue).toEnqueueOperationsWithCount(1) - - let task = pipeline.loadImage(with: request) { _ in } - wait() // Wait till the operation is created. - - // When/Then - guard let operation = observer.operations.first else { - return XCTFail("Failed to find operation") + + @Test func cancelImmediately() async throws { + dataLoader.queue.isSuspended = true + + let task = Task { + try await pipeline.image(for: Test.url) + } + task.cancel() + + do { + _ = try await task.value + } catch { + #expect(error as? ImageTask.Error == .cancelled) } - expect(operation).toUpdatePriority() - task.priority = .high - - wait() } - - func testDecodingPriorityUpdated() { - // Given - pipeline = pipeline.reconfigured { - $0.makeImageDecoder = { _ in MockImageDecoder(name: "test") } + + @Test func cancelFromProgress() async throws { + dataLoader.queue.isSuspended = true + + let task = Task { + let task = pipeline.imageTask(with: Test.url) + for await value in task.progress { + recordedProgress.append(value) + } } - - let queue = pipeline.configuration.imageDecodingQueue - queue.isSuspended = true - - let request = Test.request - XCTAssertEqual(request.priority, .normal) - - let observer = expect(queue).toEnqueueOperationsWithCount(1) - - let task = pipeline.loadImage(with: request) { _ in } - wait() // Wait till the operation is created. - - // When/Then - guard let operation = observer.operations.first else { - return XCTFail("Failed to find operation") + + task.cancel() + + _ = await task.value + + // Then nothing is recorded because the task is cancelled and + // stop observing the events + #expect(recordedProgress == []) + } + + @Test func observeProgressAndCancelFromOtherTask() async throws { + dataLoader.queue.isSuspended = true + + let task = pipeline.imageTask(with: Test.url) + + let task1 = Task { + for await event in task.progress { + recordedProgress.append(event) + } } - expect(operation).toUpdatePriority() - task.priority = .high - - wait() + + let task2 = Task { + try await task.response + } + + task2.cancel() + + async let result1: () = task1.value + async let result2 = task2.value + + // Then you are able to observe `event` update because + // this task does no get cancelled + do { + _ = try await (result1, result2) + } catch { + #expect(error as? ImageTask.Error == .cancelled) + } + #expect(recordedProgress == []) } - - func testProcessingPriorityUpdated() { - // Given - let queue = pipeline.configuration.imageProcessingQueue - queue.isSuspended = true - - let request = ImageRequest(url: Test.url, processors: [ImageProcessors.Anonymous(id: "1", { $0 })]) - XCTAssertEqual(request.priority, .normal) - - let observer = expect(queue).toEnqueueOperationsWithCount(1) - - let task = pipeline.loadImage(with: request) { _ in } - wait() // Wait till the operation is created. - - // When/Then - guard let operation = observer.operations.first else { - return XCTFail("Failed to find operation") + + @Test func cancelAsyncImageTask() async throws { + dataLoader.queue.isSuspended = true + + let task = pipeline.imageTask(with: Test.url) + observer = NotificationCenter.default.addObserver(forName: MockDataLoader.DidStartTask, object: dataLoader, queue: OperationQueue()) { _ in + task.cancel() + } + + do { + _ = try await task.image + } catch { + #expect(error == .cancelled) } - expect(operation).toUpdatePriority() - task.priority = .high - - wait() } - - // MARK: - Cancellation - - func testDataLoadingOperationCancelled() { + + @Test func dataLoadingOperationCancelled() async { + // Given dataLoader.queue.isSuspended = true - - expectNotification(MockDataLoader.DidStartTask, object: dataLoader) + + let expectation1 = AsyncExpectation(notification: MockDataLoader.DidStartTask, object: dataLoader) let task = pipeline.loadImage(with: Test.request) { _ in - XCTFail() + Issue.record() } - wait() // Wait till operation is created - - expectNotification(MockDataLoader.DidCancelTask, object: dataLoader) + await expectation1.wait() // Wait till operation is created + + // When + let expectation2 = AsyncExpectation(notification: MockDataLoader.DidCancelTask, object: dataLoader) task.cancel() - wait() + + // Then + await expectation2.wait() } - - func testDecodingOperationCancelled() { - // GIVEN + + @Test func decodingOperationCancelled() async { + // Given pipeline = pipeline.reconfigured { $0.makeImageDecoder = { _ in MockImageDecoder(name: "test") } } - + let queue = pipeline.configuration.imageDecodingQueue queue.isSuspended = true - - let observer = self.expect(queue).toEnqueueOperationsWithCount(1) - + + let expectation1 = queue.expectJobAdded() let request = Test.request - - let task = pipeline.loadImage(with: request) { _ in - XCTFail() - } - wait() // Wait till operation is created - - // When/Then - guard let operation = observer.operations.first else { - return XCTFail("Failed to find operation") - } - expect(operation).toCancel() - + let task = pipeline.imageTask(with: request) + let job = await expectation1.wait() + + // When + let expectation2 = queue.expectJobCancelled(job) task.cancel() - - wait() + + // Then + await expectation2.wait() } - - func testProcessingOperationCancelled() { + + @Test func processingOperationCancelled() async { // Given let queue = pipeline.configuration.imageProcessingQueue queue.isSuspended = true - - let observer = self.expect(queue).toEnqueueOperationsWithCount(1) - + let processor = ImageProcessors.Anonymous(id: "1") { - XCTFail() + Issue.record() return $0 } + let expectation1 = queue.expectJobAdded() let request = ImageRequest(url: Test.url, processors: [processor]) - - let task = pipeline.loadImage(with: request) { _ in - XCTFail() + let task = pipeline.imageTask(with: request) + let job = await expectation1.wait() + + // When + let expectation2 = queue.expectJobCancelled(job) + task.cancel() + + // Then + await expectation2.wait() + } + + // MARK: - Load Data + + @Test func loadData() async throws { + // Given + dataLoader.results[Test.url] = .success((Test.data, Test.urlResponse)) + + // When + let (data, response) = try await pipeline.data(for: Test.request) + + // Then + #expect(data.count == 22788) + #expect(response?.url == Test.url) + } + + @Test func loadDataCancelImmediately() async throws { + dataLoader.queue.isSuspended = true + + let task = Task { + try await pipeline.data(for: Test.request) } - wait() // Wait till operation is created - - // When/Then - let operation = observer.operations.first - XCTAssertNotNil(operation) - expect(operation!).toCancel() - task.cancel() - - wait() + + do { + _ = try await task.value + } catch { + #expect(error as? ImageTask.Error == .cancelled) + } } - - // MARK: Decompression - -#if !os(macOS) - - func testDisablingDecompression() async throws { - // GIVEN + + @Test func progressUpdated() async throws { + // Given + dataLoader.results[Test.url] = .success( + (Data(count: 20), URLResponse(url: Test.url, mimeType: "jpeg", expectedContentLength: 20, textEncodingName: nil)) + ) + + // When + do { + let task = pipeline.imageTask(with: Test.url) + for await progress in task.progress { + recordedProgress.append(progress) + } + _ = try await task.image + } catch { + // Do nothing + } + + // Then + #expect(recordedProgress == [ + ImageTask.Progress(completed: 10, total: 20), + ImageTask.Progress(completed: 20, total: 20) + ]) + } + + @Test func progressivePreviews() async throws { + // Given + let dataLoader = MockProgressiveDataLoader() pipeline = pipeline.reconfigured { - $0.isDecompressionEnabled = false + $0.dataLoader = dataLoader + $0.isProgressiveDecodingEnabled = true } - - // WHEN - let image = try await pipeline.image(for: Test.url) - - // THEN - XCTAssertEqual(true, ImageDecompression.isDecompressionNeeded(for: image)) + + // When + let task = pipeline.imageTask(with: Test.url) + Task { + for try await preview in task.previews { + recordedPreviews.append(preview) + dataLoader.resume() + } + } + _ = try await task.image + + // Then + #expect(recordedPreviews.count == 2) + #expect(recordedPreviews.allSatisfy { $0.container.isPreview }) } - - func testDisablingDecompressionForIndividualRequest() async throws { - // GIVEN - let request = ImageRequest(url: Test.url, options: [.skipDecompression]) - - // WHEN + + // MARK: - ImageRequest + + @Test func imageRequestWithAsyncAwaitSuccess() async throws { + // Given + let localURL = Test.url(forResource: "fixture", extension: "jpeg") + + // When + let request = ImageRequest(id: "test", data: { + let (data, _) = try await URLSession.shared.data(for: URLRequest(url: localURL)) + return data + }) + let image = try await pipeline.image(for: request) - - // THEN - XCTAssertEqual(true, ImageDecompression.isDecompressionNeeded(for: image)) + + // Then + #expect(image.sizeInPixels == CGSize(width: 640, height: 480)) } - - func testDecompressionPerformed() async throws { - // WHEN - let image = try await pipeline.image(for: Test.request) + + @Test func imageRequestWithAsyncAwaitFailure() async throws { + // When + let request = ImageRequest(id: "test", data: { + throw URLError(networkUnavailableReason: .cellular) + }) - // THEN - XCTAssertNil(ImageDecompression.isDecompressionNeeded(for: image)) + do { + _ = try await pipeline.image(for: request) + Issue.record() + } catch { + if case let .dataLoadingFailed(error) = error { + #expect((error as? URLError)?.networkUnavailableReason == .cellular) + } else { + Issue.record() + } + } } - - func testDecompressionNotPerformedWhenProcessorWasApplied() async throws { - // GIVEN request with scaling processor - let input = Test.image - pipeline = pipeline.reconfigured { - $0.makeImageDecoder = { _ in MockAnonymousImageDecoder(output: input) } + + // MARK: Common Use Cases + + @Test func lowDataMode() async throws { + // Given + let highQualityImageURL = URL(string: "https://example.com/high-quality-image.jpeg")! + let lowQualityImageURL = URL(string: "https://example.com/low-quality-image.jpeg")! + + dataLoader.results[highQualityImageURL] = .failure(URLError(networkUnavailableReason: .constrained) as NSError) + dataLoader.results[lowQualityImageURL] = .success((Test.data, Test.urlResponse)) + + // When + let pipeline = self.pipeline! + + // Create the default request to fetch the high quality image. + var urlRequest = URLRequest(url: highQualityImageURL) + urlRequest.allowsConstrainedNetworkAccess = false + let request = ImageRequest(urlRequest: urlRequest) + + // When + @Sendable func loadImage() async throws -> PlatformImage { + do { + return try await pipeline.image(for: request) + } catch { + guard let urlError = error.dataLoadingError as? URLError, + urlError.networkUnavailableReason == .constrained else { + throw error + } + return try await pipeline.image(for: lowQualityImageURL) + } } - - let request = ImageRequest(url: Test.url, processors: [ - .resize(size: CGSize(width: 40, height: 40)) - ]) - - // WHEN - _ = try await pipeline.image(for: request) - - // THEN - XCTAssertEqual(true, ImageDecompression.isDecompressionNeeded(for: input)) + + _ = try await loadImage() } - - func testDecompressionPerformedWhenProcessorIsAppliedButDoesNothing() { - // Given request with scaling processor - let request = ImageRequest(url: Test.url, processors: [MockEmptyImageProcessor()]) - - expect(pipeline).toLoadImage(with: request) { result in - guard let image = result.value?.image else { - return XCTFail("Expected image to be loaded") + + // MARK: - ImageTask Integration + + @Test func imageTaskEvents() async throws { + // Given + let dataLoader = MockProgressiveDataLoader() + pipeline = pipeline.reconfigured { + $0.dataLoader = dataLoader + $0.isProgressiveDecodingEnabled = true + } + + // When + let task = pipeline.loadImage(with: Test.request) { _ in } + for await event in task.events { + switch event { + case .preview(let response): + recordedPreviews.append(response) + dataLoader.resume() + case .finished(let result): + recordedResult = result + default: + break } - - // Expect decompression to be performed (processor was applied but it did nothing) - XCTAssertNil(ImageDecompression.isDecompressionNeeded(for: image)) + recordedEvents.append(event) } - wait() + + // Then + try #require(recordedPreviews.count == 2) + + let result = try #require(recordedResult) + + #expect(recordedEvents.filter { + if case .progress = $0 { + return false // There is guarantee if all will arrive + } + return true + } == [ + .preview(recordedPreviews[0]), + .preview(recordedPreviews[1]), + .finished(result) + ]) } - -#endif - - // MARK: - Thumbnail - func testThatThumbnailIsGenerated() { - // GIVEN + // MARK: - Thumbnails + + @Test func thatThumbnailIsGenerated() async throws { + // Given let options = ImageRequest.ThumbnailOptions(maxPixelSize: 400) let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: options]) - - // WHEN - expect(pipeline).toLoadImage(with: request) { result in - // THEN - guard let image = result.value?.image else { - return XCTFail() - } - XCTAssertEqual(image.sizeInPixels, CGSize(width: 400, height: 300)) - } - wait() + + // When + let image = try await pipeline.image(for: request) + #expect(image.sizeInPixels == CGSize(width: 400, height: 300)) } - - func testThumbnailIsGeneratedOnDecodingQueue() { - // GIVEN + + @Test func thumbnailIsGeneratedOnDecodingQueue() async { + // Given let options = ImageRequest.ThumbnailOptions(maxPixelSize: 400) let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: options]) - - // WHEN/THEN - expect(pipeline.configuration.imageDecodingQueue).toEnqueueOperationsWithCount(1) - expect(pipeline).toLoadImage(with: request) - wait() + + // When + let expectation = pipeline.configuration.imageDecodingQueue.expectJobAdded() + _ = pipeline.imageTask(with: request) + + // Then work item is created on an expected queue + await expectation.wait() } - + #if os(iOS) || os(visionOS) - func testThumnbailIsntDecompressed() { + @Test func thumnbailIsntDecompressed() async throws { + // Given a suspended queue so no work can be performed pipeline.configuration.imageDecompressingQueue.isSuspended = true - - // GIVEN + + // When let options = ImageRequest.ThumbnailOptions(maxPixelSize: 400) let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: options]) - - // WHEN/THEN - expect(pipeline).toLoadImage(with: request) - wait() + + // Then image is loaded without decompression + _ = try await pipeline.image(for: request) } #endif - + // MARK: - CacheKey - - func testCacheKeyForRequest() { + + @Test func cacheKeyForRequest() { let request = Test.request - XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.com/example.jpeg") + #expect(pipeline.cache.makeDataCacheKey(for: request) == "http://test.com/example.jpeg") } - - func testCacheKeyForRequestWithProcessors() { + + @Test func cacheKeyForRequestWithProcessors() { var request = Test.request request.processors = [ImageProcessors.Anonymous(id: "1", { $0 })] - XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.com/example.jpeg1") + #expect(pipeline.cache.makeDataCacheKey(for: request) == "http://test.com/example.jpeg1") } - - func testCacheKeyForRequestWithThumbnail() { + + @Test func cacheKeyForRequestWithThumbnail() { let options = ImageRequest.ThumbnailOptions(maxPixelSize: 400) let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: options]) - XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.com/example.jpegcom.github/kean/nuke/thumbnail?maxPixelSize=400.0,options=truetruetruetrue") + #expect(pipeline.cache.makeDataCacheKey(for: request) == "http://test.com/example.jpegcom.github/kean/nuke/thumbnail?maxPixelSize=400.0,options=truetruetruetrue") } - func testCacheKeyForRequestWithThumbnailFlexibleSize() { + @Test func cacheKeyForRequestWithThumbnailFlexibleSize() { let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFit) let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: options]) - XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.com/example.jpegcom.github/kean/nuke/thumbnail?width=400.0,height=400.0,contentMode=.aspectFit,options=truetruetruetrue") + #expect(pipeline.cache.makeDataCacheKey(for: request) == "http://test.com/example.jpegcom.github/kean/nuke/thumbnail?width=400.0,height=400.0,contentMode=.aspectFit,options=truetruetruetrue") } - + // MARK: - Invalidate - - func testWhenInvalidatedTasksAreCancelled() { + + @Test func whenInvalidatedTasksAreCancelled() async { + // Given dataLoader.queue.isSuspended = true - - expectNotification(MockDataLoader.DidStartTask, object: dataLoader) - pipeline.loadImage(with: Test.request) { _ in - XCTFail() - } - wait() // Wait till operation is created - - expectNotification(MockDataLoader.DidCancelTask, object: dataLoader) + + let expectation1 = AsyncExpectation(notification: MockDataLoader.DidStartTask, object: dataLoader) + pipeline.imageTask(with: Test.request) + await expectation1.wait() + + // When + let expectation2 = AsyncExpectation(notification: MockDataLoader.DidCancelTask, object: dataLoader) pipeline.invalidate() - wait() + + // Then + await expectation2.wait() } - - func testThatInvalidatedTasksFailWithError() async throws { - // WHEN + + @Test func thatInvalidatedTasksFailWithError() async throws { + // When pipeline.invalidate() - - // THEN + + // Then do { _ = try await pipeline.image(for: Test.request) - XCTFail() + Issue.record() } catch { - XCTAssertEqual(error as? ImagePipeline.Error, .pipelineInvalidated) + #expect(error == .pipelineInvalidated) } } - - // MARK: Error Handling - - func testDataLoadingFailedErrorReturned() { + + // MARK: - Error Handling + + @Test func dataLoadingFailedErrorReturned() async throws { // Given let dataLoader = MockDataLoader() let pipeline = ImagePipeline { $0.dataLoader = dataLoader $0.imageCache = nil } - + let expectedError = NSError(domain: "t", code: 23, userInfo: nil) dataLoader.results[Test.url] = .failure(expectedError) - - // When/Then - expect(pipeline).toFailRequest(Test.request, with: .dataLoadingFailed(error: expectedError)) - wait() + + // When + do { + _ = try await pipeline.image(for: Test.request) + Issue.record("Unexpected success") + } catch { + // Then + #expect(error == .dataLoadingFailed(error: expectedError)) + } } - - func testDataLoaderReturnsEmptyData() { + + @Test func dataLoaderReturnsEmptyData() async throws { // Given let dataLoader = MockDataLoader() let pipeline = ImagePipeline { $0.dataLoader = dataLoader $0.imageCache = nil } - + dataLoader.results[Test.url] = .success((Data(), Test.urlResponse)) - - // When/Then - expect(pipeline).toFailRequest(Test.request, with: .dataIsEmpty) - wait() + + // When + do { + _ = try await pipeline.image(for: Test.request) + Issue.record("Unexpected success") + } catch { + // Then + #expect(error == .dataIsEmpty) + } } - - func testDecoderNotRegistered() { + + @Test func decoderNotRegistered() async throws { // Given let pipeline = ImagePipeline { $0.dataLoader = MockDataLoader() - $0.makeImageDecoder = { _ in - nil - } + $0.makeImageDecoder = { _ in nil } $0.imageCache = nil } - - expect(pipeline).toFailRequest(Test.request) { result in - guard let error = result.error else { - return XCTFail("Expected error") - } + + // When + do { + _ = try await pipeline.image(for: Test.request) + Issue.record("Unexpected success") + } catch { + // Then guard case let .decoderNotRegistered(context) = error else { - return XCTFail("Expected .decoderNotRegistered") + Issue.record("Expected .decoderNotRegistered") + return } - XCTAssertEqual(context.request.url, Test.request.url) - XCTAssertEqual(context.data.count, 22789) - XCTAssertTrue(context.isCompleted) - XCTAssertEqual(context.urlResponse?.url, Test.url) + + #expect(context.request.url == Test.request.url) + #expect(context.data.count == 22789) + #expect(context.isCompleted) + #expect(context.urlResponse?.url == Test.url) } - wait() } - - func testDecodingFailedErrorReturned() async { + + @Test func decodingFailedErrorReturned() async throws { // Given let decoder = MockFailingDecoder() let pipeline = ImagePipeline { @@ -483,151 +584,150 @@ class ImagePipelineTests: XCTestCase { $0.makeImageDecoder = { _ in decoder } $0.imageCache = nil } - - // When/Then + + // When do { _ = try await pipeline.image(for: Test.request) - XCTFail("Expected failure") + Issue.record("Unexpected success") } catch { - if case let .decodingFailed(failedDecoder, context, error) = error as? ImagePipeline.Error { - XCTAssertTrue((failedDecoder as? MockFailingDecoder) === decoder) - - XCTAssertEqual(context.request.url, Test.request.url) - XCTAssertEqual(context.data, Test.data) - XCTAssertTrue(context.isCompleted) - XCTAssertEqual(context.urlResponse?.url, Test.url) - - XCTAssertEqual(error as? MockError, MockError(description: "decoder-failed")) + // Then + if case let .decodingFailed(failedDecoder, context, error) = error { + #expect((failedDecoder as? MockFailingDecoder) === decoder) + + #expect(context.request.url == Test.request.url) + #expect(context.data == Test.data) + #expect(context.isCompleted) + #expect(context.urlResponse?.url == Test.url) + + #expect(error as? MockError == MockError(description: "decoder-failed")) } else { - XCTFail("Unexpected error: \(error)") + Issue.record("Unexpected error: \(error)") } } } - - func testProcessingFailedErrorReturned() { - // GIVEN + + @Test func processingFailedErrorReturned() async throws { + // Given let pipeline = ImagePipeline { $0.dataLoader = MockDataLoader() } - + let request = ImageRequest(url: Test.url, processors: [MockFailingProcessor()]) - - // WHEN/THEN - expect(pipeline).toFailRequest(request) { result in - guard case .failure(let error) = result, - case let .processingFailed(processor, context, error) = error else { - return XCTFail() + + // When/Then + do { + _ = try await pipeline.image(for: request) + Issue.record("Unexpected success") + } catch { + // Then + if case let .processingFailed(processor, context, error) = error { + #expect(processor is MockFailingProcessor) + + #expect(context.request.url == Test.url) + #expect(context.response.container.image.sizeInPixels == CGSize(width: 640, height: 480)) + #expect(context.response.cacheType == nil) + #expect(context.isCompleted == true) + + #expect(error as? ImageProcessingError == .unknown) + } else { + Issue.record("Unexpected error: \(error)") } - - XCTAssertTrue(processor is MockFailingProcessor) - - XCTAssertEqual(context.request.url, Test.url) - XCTAssertEqual(context.response.container.image.sizeInPixels, CGSize(width: 640, height: 480)) - XCTAssertEqual(context.response.cacheType, nil) - XCTAssertEqual(context.isCompleted, true) - - XCTAssertEqual(error as? ImageProcessingError, .unknown) - } - wait() - } - - func testImageContainerUserInfo() { // Just to make sure we have 100% coverage - // WHEN - let container = ImageContainer(image: Test.image, type: nil, isPreview: false, data: nil, userInfo: [.init("a"): 1]) - - // THEN - XCTAssertEqual(container.userInfo["a"] as? Int, 1) + } } - - func testErrorDescription() { - XCTAssertFalse(ImagePipeline.Error.dataLoadingFailed(error: URLError(.unknown)).description.isEmpty) // Just padding here - - XCTAssertFalse(ImagePipeline.Error.decodingFailed(decoder: MockImageDecoder(name: "test"), context: .mock, error: MockError(description: "decoding-failed")).description.isEmpty) // Just padding - + + @Test func errorDescription() { + let dataLoadingError = ImageTask.Error.dataLoadingFailed(error: Foundation.URLError(.unknown)) + #expect(!dataLoadingError.description.isEmpty) // Just padding here + + #expect(!ImageTask.Error.decodingFailed(decoder: MockImageDecoder(name: "test"), context: .mock, error: MockError(description: "decoding-failed")).description.isEmpty) // Just padding // Just padding + let processor = ImageProcessors.Resize(width: 100, unit: .pixels) - let error = ImagePipeline.Error.processingFailed(processor: processor, context: .mock, error: MockError(description: "processing-failed")) + let error = ImageTask.Error.processingFailed(processor: processor, context: .mock, error: MockError(description: "processing-failed")) let expected = "Failed to process the image using processor Resize(size: (100.0, 9999.0) pixels, contentMode: .aspectFit, crop: false, upscale: false). Underlying error: MockError(description: \"processing-failed\")." - XCTAssertEqual(error.description, expected) - XCTAssertEqual("\(error)", expected) - - XCTAssertNil(error.dataLoadingError) + #expect(error.description == expected) + #expect("\(error)" == expected) + + #expect(error.dataLoadingError == nil) } - - // MARK: Skip Data Loading Queue Option - - func testSkipDataLoadingQueuePerRequestWithURL() throws { - // Given - let queue = pipeline.configuration.dataLoadingQueue - queue.isSuspended = true - - let request = ImageRequest(url: Test.url, options: [ - .skipDataLoadingQueue - ]) - - // Then image is still loaded - expect(pipeline).toLoadImage(with: request) - wait() + + // MARK: - Misc + + @Test func imageContainerUserInfo() { // Just to make sure we have 100% coverage + // When + let container = ImageContainer(image: Test.image, type: nil, isPreview: false, data: nil, userInfo: [.init("a"): 1]) + + // Then + #expect(container.userInfo["a"] as? Int == 1) } - - func testSkipDataLoadingQueuePerRequestWithPublisher() throws { + + @Test func skipDataLoadingQueuePerRequestWithURL() async throws { // Given let queue = pipeline.configuration.dataLoadingQueue queue.isSuspended = true - - let request = ImageRequest(id: "a", dataPublisher: Just(Test.data), options: [ + + let request = ImageRequest(url: Test.url, options: [ .skipDataLoadingQueue ]) - + // Then image is still loaded - expect(pipeline).toLoadImage(with: request) - wait() + _ = try await pipeline.image(for: request) } - - // MARK: Misc - - func testLoadWithStringLiteral() async throws { + + @Test func loadWithStringLiteral() async throws { let image = try await pipeline.image(for: "https://example.com/image.jpeg") - XCTAssertNotEqual(image.size, .zero) + #expect(image.size != .zero) } - func testLoadWithInvalidURL() throws { - // GIVEN + @Test func loadWithInvalidURL() async throws { + // Given pipeline = pipeline.reconfigured { $0.dataLoader = DataLoader() } - - // WHEN + + // When for _ in 0...10 { - expect(pipeline).toFailRequest(ImageRequest(url: URL(string: ""))) - wait() + do { + _ = try await pipeline.image(for: ImageRequest(url: URL(string: ""))) + Issue.record("Unexpected success") + } catch { + // Expected + } } } - + #if !os(macOS) - func testOverridingImageScale() throws { - // GIVEN + @Test func overridingImageScale() async throws { + // Given let request = ImageRequest(url: Test.url, userInfo: [.scaleKey: 7]) - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - - // THEN - let image = try XCTUnwrap(record.image) - XCTAssertEqual(image.scale, 7) + + // When + let image = try await pipeline.image(for: request) + + // Then + #expect(image.scale == 7) } - - func testOverridingImageScaleWithFloat() throws { - // GIVEN + + @Test func overridingImageScaleWithFloat() async throws { + // Given let request = ImageRequest(url: Test.url, userInfo: [.scaleKey: 7.0]) - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - - // THEN - let image = try XCTUnwrap(record.image) - XCTAssertEqual(image.scale, 7) + + // When + let image = try await pipeline.image(for: request) + + // Then + #expect(image.scale == 7) } #endif } + +/// We have to mock it because there is no way to construct native `URLError` +/// with a `networkUnavailableReason`. +private struct URLError: Swift.Error { + var networkUnavailableReason: NetworkUnavailableReason? + + enum NetworkUnavailableReason { + case cellular + case expensive + case constrained + } +} diff --git a/Tests/NukeTests/ImagePrefetcherTests.swift b/Tests/NukeTests/ImagePrefetcherTests.swift index 8856f3111..63af26b7c 100644 --- a/Tests/NukeTests/ImagePrefetcherTests.swift +++ b/Tests/NukeTests/ImagePrefetcherTests.swift @@ -1,25 +1,25 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest +import Testing +import Combine @testable import Nuke -final class ImagePrefetcherTests: XCTestCase { - private var pipeline: ImagePipeline! - private var dataLoader: MockDataLoader! - private var dataCache: MockDataCache! - private var imageCache: MockImageCache! - private var observer: ImagePipelineObserver! +/// - warning: This test suite is no isolated to `ImagePipelineActor` because +/// `ImagePrefetcher` is designed to be used from the main queue and not expose +/// its internal actor-isolated APIs. +@Suite struct ImagePrefetcherTests { private var prefetcher: ImagePrefetcher! + private var pipeline: ImagePipeline! - override func setUp() { - super.setUp() + private let dataLoader = MockDataLoader() + private let dataCache = MockDataCache() + private var imageCache = MockImageCache() + private let observer = ImagePipelineObserver() + private var cancellables: [AnyCancellable] = [] - dataLoader = MockDataLoader() - dataCache = MockDataCache() - imageCache = MockImageCache() - observer = ImagePipelineObserver() + init() { pipeline = ImagePipeline(delegate: observer) { $0.dataLoader = dataLoader $0.imageCache = imageCache @@ -28,265 +28,176 @@ final class ImagePrefetcherTests: XCTestCase { prefetcher = ImagePrefetcher(pipeline: pipeline) } - override func tearDown() { - super.tearDown() - - observer = nil - } - // MARK: Basics /// Start prefetching for the request and then request an image separarely. - func testBasicScenario() { - dataLoader.isSuspended = true - - expect(prefetcher.queue).toEnqueueOperationsWithCount(1) + @Test func basicScenario() async throws { prefetcher.startPrefetching(with: [Test.request]) - wait() - - expect(pipeline).toLoadImage(with: Test.request) - pipeline.queue.async { [dataLoader] in - dataLoader?.isSuspended = false - } - wait() + _ = try await pipeline.image(for: Test.request) - // THEN - XCTAssertEqual(dataLoader.createdTaskCount, 1) - XCTAssertEqual(observer.startedTaskCount, 2) + // Then + #expect(dataLoader.createdTaskCount == 1) + #expect(observer.createdTaskCount == 2) } // MARK: Start Prefetching - func testStartPrefetching() { - expectPrefetcherToComplete() - - // WHEN + @Test func startPrefetching() async { + // When prefetcher.startPrefetching(with: [Test.url]) + await prefetcher.wait() - wait() - - // THEN image saved in both caches - XCTAssertNotNil(pipeline.cache[Test.request]) - XCTAssertNotNil(pipeline.cache.cachedData(for: Test.request)) + // Then image saved in both caches + #expect(pipeline.cache[Test.request] != nil) + #expect(pipeline.cache.cachedData(for: Test.request) != nil) } - func testStartPrefetchingWithTwoEquivalentURLs() { - dataLoader.isSuspended = true - expectPrefetcherToComplete() - - // WHEN + @Test func startPrefetchingWithTwoEquivalentURLs() async { + // When prefetcher.startPrefetching(with: [Test.url]) prefetcher.startPrefetching(with: [Test.url]) + await prefetcher.wait() - pipeline.queue.async { [dataLoader] in - dataLoader?.isSuspended = false - } - 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) + // Then only one task is started + #expect(observer.createdTaskCount == 1) } // MARK: Stop Prefetching - func testStopPrefetching() { + @Test func stopPrefetching() async { dataLoader.isSuspended = true - // WHEN - let url = Test.url - expectNotification(ImagePipelineObserver.didStartTask, object: observer) - prefetcher.startPrefetching(with: [url]) - wait() + let created = AsyncExpectation(notification: ImagePipelineObserver.didCreateTask, object: observer) + prefetcher.startPrefetching(with: [Test.url]) + await created.wait() - expectNotification(ImagePipelineObserver.didCancelTask, object: observer) - prefetcher.stopPrefetching(with: [url]) - wait() + let started = AsyncExpectation(notification: ImagePipelineObserver.didCancelTask, object: observer) + prefetcher.stopPrefetching(with: [Test.url]) + await started.wait() } // MARK: Destination - func testStartPrefetchingDestinationDisk() { - // GIVEN - pipeline = pipeline.reconfigured { + @Test func startPrefetchingDestinationDisk() async { + // Given + let pipeline = pipeline.reconfigured { $0.makeImageDecoder = { _ in - XCTFail("Expect image not to be decoded") + Issue.record("Expect image not to be decoded") return nil } } - prefetcher = ImagePrefetcher(pipeline: pipeline, destination: .diskCache) - - expectPrefetcherToComplete() + let prefetcher = ImagePrefetcher(pipeline: pipeline, destination: .diskCache) - // WHEN + // When prefetcher.startPrefetching(with: [Test.url]) + await prefetcher.wait() - wait() - - // THEN image saved in both caches - XCTAssertNil(pipeline.cache[Test.request]) - XCTAssertNotNil(pipeline.cache.cachedData(for: Test.request)) + // Then image saved in both caches + #expect(pipeline.cache[Test.request] == nil) + #expect(pipeline.cache.cachedData(for: Test.request) != nil) } // MARK: Pause - func testPausingPrefetcher() { - // WHEN + @Test func pausingPrefetcher() async { + // When prefetcher.isPaused = true prefetcher.startPrefetching(with: [Test.url]) - let expectation = self.expectation(description: "TimePassed") - pipeline.queue.asyncAfter(deadline: .now() + .milliseconds(10)) { - expectation.fulfill() - } - wait() + try? await Task.sleep(nanoseconds: 3 * 1_000_000) - // THEN - XCTAssertEqual(observer.startedTaskCount, 0) + // Then + #expect(observer.createdTaskCount == 0) } // MARK: Priority - func testDefaultPrioritySetToLow() { - // WHEN start prefetching with URL - pipeline.configuration.dataLoadingQueue.isSuspended = true - let observer = expect(pipeline.configuration.dataLoadingQueue).toEnqueueOperationsWithCount(1) + @ImagePipelineActor + @Test func defaultPrioritySetToLow() async { + // When start prefetching with URL + dataLoader.isSuspended = true + + let expectation = pipeline.configuration.dataLoadingQueue.expectJobAdded() prefetcher.startPrefetching(with: [Test.url]) - wait() + let job = await expectation.value - // THEN priority is set to .low - guard let operation = observer.operations.first else { - return XCTFail("Failed to find operation") - } - XCTAssertEqual(operation.queuePriority, .low) + // Then priority is set to .low + #expect(job.priority == .low) // Cleanup prefetcher.stopPrefetching() } - func testDefaultPriorityAffectsRequests() { - // WHEN start prefetching with ImageRequest + @ImagePipelineActor + @Test func defaultPriorityAffectsRequests() async { + // When start prefetching with ImageRequest pipeline.configuration.dataLoadingQueue.isSuspended = true - let observer = expect(pipeline.configuration.dataLoadingQueue).toEnqueueOperationsWithCount(1) + + let expectation = pipeline.configuration.dataLoadingQueue.expectJobAdded() let request = Test.request - XCTAssertEqual(request.priority, .normal) // Default is .normal + #expect(request.priority == .normal) // Default is .normal // Default is .normal prefetcher.startPrefetching(with: [request]) - wait() + let job = await expectation.value - // THEN priority is set to .low - guard let operation = observer.operations.first else { - return XCTFail("Failed to find operation") - } - XCTAssertEqual(operation.queuePriority, .low) + // Then priority is set to .low + #expect(job.priority == .low) } - func testLowerPriorityThanDefaultNotAffected() { - // WHEN start prefetching with ImageRequest with .veryLow priority + @ImagePipelineActor + @Test func lowerPriorityThanDefaultNotAffected() async { + // When start prefetching with ImageRequest with .veryLow priority pipeline.configuration.dataLoadingQueue.isSuspended = true - let observer = expect(pipeline.configuration.dataLoadingQueue).toEnqueueOperationsWithCount(1) + + let expectation = pipeline.configuration.dataLoadingQueue.expectJobAdded() var request = Test.request request.priority = .veryLow prefetcher.startPrefetching(with: [request]) - wait() + let job = await expectation.value - // THEN priority is set to .low (prefetcher priority) - guard let operation = observer.operations.first else { - return XCTFail("Failed to find operation") - } - XCTAssertEqual(operation.queuePriority, .low) + // Then priority is set to .low (prefetcher priority) + #expect(job.priority == .low) } - func testChangePriority() { - // GIVEN + @ImagePipelineActor + @Test func changePriority() async { + // Given prefetcher.priority = .veryHigh - // WHEN + // When pipeline.configuration.dataLoadingQueue.isSuspended = true - let observer = expect(pipeline.configuration.dataLoadingQueue).toEnqueueOperationsWithCount(1) - prefetcher.startPrefetching(with: [Test.url]) - wait() - - // THEN - guard let operation = observer.operations.first else { - return XCTFail("Failed to find operation") - } - XCTAssertEqual(operation.queuePriority, .veryHigh) - } - - func testChangePriorityOfOutstandingTasks() { - // WHEN - pipeline.configuration.dataLoadingQueue.isSuspended = true - let observer = expect(pipeline.configuration.dataLoadingQueue).toEnqueueOperationsWithCount(1) - prefetcher.startPrefetching(with: [Test.url]) - wait() - guard let operation = observer.operations.first else { - return XCTFail("Failed to find operation") - } - - // WHEN/THEN - expect(operation).toUpdatePriority(from: .low, to: .veryLow) - prefetcher.priority = .veryLow - wait() - } - - // MARK: DidComplete - - func testDidCompleteIsCalled() { - let expectation = self.expectation(description: "PrefecherDidComplete") - prefetcher.didComplete = { @MainActor @Sendable in - expectation.fulfill() - } - + let expectation = pipeline.configuration.dataLoadingQueue.expectJobAdded() prefetcher.startPrefetching(with: [Test.url]) - wait() - } - - func testDidCompleteIsCalledWhenImageCached() { - let expectation = self.expectation(description: "PrefecherDidComplete") - prefetcher.didComplete = { @MainActor @Sendable in - expectation.fulfill() - } - - imageCache[Test.request] = Test.container + let job = await expectation.value - prefetcher.startPrefetching(with: [Test.request]) - wait() + // Then + #expect(job.priority == .veryHigh) } // MARK: Misc - func testThatAllPrefetchingRequestsAreStoppedWhenPrefetcherIsDeallocated() { - pipeline.configuration.dataLoadingQueue.isSuspended = true + @Test func thatAllPrefetchingRequestsAreStoppedWhenPrefetcherIsDeallocated() async { + let cancelled = AsyncExpectation(notification: ImagePipelineObserver.didCancelTask, object: observer) + func functionThatLeavesScope() async { + let prefetcher = ImagePrefetcher(pipeline: pipeline) + dataLoader.isSuspended = true - let request = Test.request - expectNotification(ImagePipelineObserver.didStartTask, object: observer) - prefetcher.startPrefetching(with: [request]) - wait() - - expectNotification(ImagePipelineObserver.didCancelTask, object: observer) - autoreleasepool { - prefetcher = nil + let created = AsyncExpectation(notification: ImagePipelineObserver.didCreateTask, object: observer) + prefetcher.startPrefetching(with: [Test.request]) + await created.wait() } - wait() + await functionThatLeavesScope() + await cancelled.wait() } +} - func expectPrefetcherToComplete() { - let expectation = self.expectation(description: "PrefecherDidComplete") - prefetcher.didComplete = { @MainActor @Sendable in - expectation.fulfill() - } +private extension ImagePrefetcher { + /// - warning: For testing purposes only. + func wait() async { + await impl.queue.wait() } } + +@ImagePipelineActor +extension JobQueue.JobHandle { + var priority: JobPriority { job.priority } +} diff --git a/Tests/NukeTests/ImageProcessorsTests/AnonymousTests.swift b/Tests/NukeTests/ImageProcessorsTests/AnonymousTests.swift index 5be058ed3..28c78a1ea 100644 --- a/Tests/NukeTests/ImageProcessorsTests/AnonymousTests.swift +++ b/Tests/NukeTests/ImageProcessorsTests/AnonymousTests.swift @@ -1,39 +1,33 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest -@testable import Nuke +import Testing +import Foundation -#if !os(macOS) - import UIKit -#endif +@testable import Nuke -class ImageProcessorsAnonymousTests: XCTestCase { +@Suite struct ImageProcessorsAnonymousTests { - func testAnonymousProcessorsHaveDifferentIdentifiers() { - XCTAssertEqual( - ImageProcessors.Anonymous(id: "1", { $0 }).identifier, - ImageProcessors.Anonymous(id: "1", { $0 }).identifier + @Test func anonymousProcessorsHaveDifferentIdentifiers() { + #expect( + ImageProcessors.Anonymous(id: "1", { $0 }).identifier == ImageProcessors.Anonymous(id: "1", { $0 }).identifier ) - XCTAssertNotEqual( - ImageProcessors.Anonymous(id: "1", { $0 }).identifier, - ImageProcessors.Anonymous(id: "2", { $0 }).identifier + #expect( + ImageProcessors.Anonymous(id: "1", { $0 }).identifier != ImageProcessors.Anonymous(id: "2", { $0 }).identifier ) } - func testAnonymousProcessorsHaveDifferentHashableIdentifiers() { - XCTAssertEqual( - ImageProcessors.Anonymous(id: "1", { $0 }).hashableIdentifier, - ImageProcessors.Anonymous(id: "1", { $0 }).hashableIdentifier + @Test func anonymousProcessorsHaveDifferentHashableIdentifiers() { + #expect( + ImageProcessors.Anonymous(id: "1", { $0 }).hashableIdentifier == ImageProcessors.Anonymous(id: "1", { $0 }).hashableIdentifier ) - XCTAssertNotEqual( - ImageProcessors.Anonymous(id: "1", { $0 }).hashableIdentifier, - ImageProcessors.Anonymous(id: "2", { $0 }).hashableIdentifier + #expect( + ImageProcessors.Anonymous(id: "1", { $0 }).hashableIdentifier != ImageProcessors.Anonymous(id: "2", { $0 }).hashableIdentifier ) } - func testAnonymousProcessorIsApplied() throws { + @Test func anonymousProcessorIsApplied() throws { // Given let processor = ImageProcessors.Anonymous(id: "1") { $0.nk_test_processorIDs = ["1"] @@ -41,9 +35,9 @@ class ImageProcessorsAnonymousTests: XCTestCase { } // When - let image = try XCTUnwrap(processor.process(Test.image)) + let image = try #require(processor.process(Test.image)) // Then - XCTAssertEqual(image.nk_test_processorIDs, ["1"]) + #expect(image.nk_test_processorIDs == ["1"]) } } diff --git a/Tests/NukeTests/ImageProcessorsTests/CircleTests.swift b/Tests/NukeTests/ImageProcessorsTests/CircleTests.swift index b31ed0d34..3f2aa5f54 100644 --- a/Tests/NukeTests/ImageProcessorsTests/CircleTests.swift +++ b/Tests/NukeTests/ImageProcessorsTests/CircleTests.swift @@ -1,8 +1,8 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest +import Testing @testable import Nuke #if !os(macOS) @@ -10,7 +10,7 @@ import XCTest #endif #if os(iOS) || os(tvOS) || os(visionOS) -class ImageProcessorsCircleTests: XCTestCase { +@Suite struct ImageProcessorsCircleTests { func _testThatImageIsCroppedToSquareAutomatically() throws { // Given @@ -18,11 +18,11 @@ class ImageProcessorsCircleTests: XCTestCase { let processor = ImageProcessors.Circle() // When - let output = try XCTUnwrap(processor.process(input), "Failed to process an image") + let output = try #require(processor.process(input), "Failed to process an image") // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 150, height: 150)) - XCTAssertEqualImages(output, Test.image(named: "s-circle.png")) + #expect(output.sizeInPixels == CGSize(width: 150, height: 150)) + #expect(isEqual(output, Test.image(named: "s-circle.png"))) } func _testThatBorderIsAdded() throws { @@ -32,111 +32,103 @@ class ImageProcessorsCircleTests: XCTestCase { let processor = ImageProcessors.Circle(border: border) // When - let output = try XCTUnwrap(processor.process(input), "Failed to process an image") + let output = try #require(processor.process(input), "Failed to process an image") // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 150, height: 150)) - XCTAssertEqualImages(output, Test.image(named: "s-circle-border.png")) + #expect(output.sizeInPixels == CGSize(width: 150, height: 150)) + #expect(isEqual(output, Test.image(named: "s-circle-border.png"))) } - func testExtendedColorSpaceSupport() throws { + @Test func extendedColorSpaceSupport() throws { // Given let input = Test.image(named: "image-p3", extension: "jpg") let border = ImageProcessingOptions.Border(color: .red, width: 4, unit: .pixels) let processor = ImageProcessors.Circle(border: border) // When - let output = try XCTUnwrap(processor.process(input), "Failed to process an image") + let output = try #require(processor.process(input), "Failed to process an image") // Then image is resized but isn't cropped - let colorSpace = try XCTUnwrap(output.cgImage?.colorSpace) - XCTAssertTrue(colorSpace.isWideGamutRGB) + let colorSpace = try #require(output.cgImage?.colorSpace) + #expect(colorSpace.isWideGamutRGB) } + @Test + @MainActor - func testIdentifierEqual() throws { - XCTAssertEqual( - ImageProcessors.Circle().identifier, - ImageProcessors.Circle().identifier + func identifierEqual() throws { + #expect( + ImageProcessors.Circle().identifier == ImageProcessors.Circle().identifier ) - XCTAssertEqual( - ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).identifier, - ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).identifier + #expect( + ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).identifier == ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).identifier ) - XCTAssertEqual( - ImageProcessors.Circle(border: .init(color: .red, width: 4, unit: .pixels)).identifier, - ImageProcessors.Circle(border: .init(color: .red, width: 4 / Screen.scale, unit: .points)).identifier + #expect( + ImageProcessors.Circle(border: .init(color: .red, width: 4, unit: .pixels)).identifier == ImageProcessors.Circle(border: .init(color: .red, width: 4 / Screen.scale, unit: .points)).identifier ) } - func testIdentifierNotEqual() throws { - XCTAssertNotEqual( - ImageProcessors.Circle().identifier, - ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).identifier + @Test func identifierNotEqual() throws { + #expect( + ImageProcessors.Circle().identifier != ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).identifier ) - XCTAssertNotEqual( - ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).identifier, - ImageProcessors.Circle(border: .init(color: .red, width: 4, unit: .pixels)).identifier + #expect( + ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).identifier != ImageProcessors.Circle(border: .init(color: .red, width: 4, unit: .pixels)).identifier ) - XCTAssertNotEqual( - ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).identifier, - ImageProcessors.Circle(border: .init(color: .blue, width: 2, unit: .pixels)).identifier + #expect( + ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).identifier != ImageProcessors.Circle(border: .init(color: .blue, width: 2, unit: .pixels)).identifier ) } + @Test + @MainActor - func testHashableIdentifierEqual() throws { - XCTAssertEqual( - ImageProcessors.Circle().hashableIdentifier, - ImageProcessors.Circle().hashableIdentifier + func hashableIdentifierEqual() throws { + #expect( + ImageProcessors.Circle().hashableIdentifier == ImageProcessors.Circle().hashableIdentifier ) - XCTAssertEqual( - ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).hashableIdentifier, - ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).hashableIdentifier + #expect( + ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).hashableIdentifier == ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).hashableIdentifier ) - XCTAssertEqual( - ImageProcessors.Circle(border: .init(color: .red, width: 4, unit: .pixels)).hashableIdentifier, - ImageProcessors.Circle(border: .init(color: .red, width: 4 / Screen.scale, unit: .points)).hashableIdentifier + #expect( + ImageProcessors.Circle(border: .init(color: .red, width: 4, unit: .pixels)).hashableIdentifier == ImageProcessors.Circle(border: .init(color: .red, width: 4 / Screen.scale, unit: .points)).hashableIdentifier ) } - func testHashableNotEqual() throws { - XCTAssertNotEqual( - ImageProcessors.Circle().identifier, - ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).hashableIdentifier + @Test func hashableNotEqual() throws { + #expect( + ImageProcessors.Circle().hashableIdentifier != ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).hashableIdentifier ) - XCTAssertNotEqual( - ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).hashableIdentifier, - ImageProcessors.Circle(border: .init(color: .red, width: 4, unit: .pixels)).hashableIdentifier + #expect( + ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).hashableIdentifier != ImageProcessors.Circle(border: .init(color: .red, width: 4, unit: .pixels)).hashableIdentifier ) - XCTAssertNotEqual( - ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).hashableIdentifier, - ImageProcessors.Circle(border: .init(color: .blue, width: 2, unit: .pixels)).hashableIdentifier + #expect( + ImageProcessors.Circle(border: .init(color: .red, width: 2, unit: .pixels)).hashableIdentifier != ImageProcessors.Circle(border: .init(color: .blue, width: 2, unit: .pixels)).hashableIdentifier ) } - func testDescription() { + @Test func description() { // Given let processor = ImageProcessors.Circle(border: .init(color: .blue, width: 2, unit: .pixels)) // Then - XCTAssertEqual(processor.description, "Circle(border: Border(color: #0000FF, width: 2.0 pixels))") + #expect(processor.description == "Circle(border: Border(color: #0000FF, width: 2.0 pixels))") } - func testDescriptionWithoutBorder() { + @Test func descriptionWithoutBorder() { // Given let processor = ImageProcessors.Circle() // Then - XCTAssertEqual(processor.description, "Circle(border: nil)") + #expect(processor.description == "Circle(border: nil)") } - func testColorToHex() { + @Test func colorToHex() { // Given let color = UIColor.red // Then - XCTAssertEqual(color.hex, "#FF0000") + #expect(color.hex == "#FF0000") } } #endif diff --git a/Tests/NukeTests/ImageProcessorsTests/CompositionTests.swift b/Tests/NukeTests/ImageProcessorsTests/CompositionTests.swift index cc0828c06..16e593518 100644 --- a/Tests/NukeTests/ImageProcessorsTests/CompositionTests.swift +++ b/Tests/NukeTests/ImageProcessorsTests/CompositionTests.swift @@ -1,105 +1,103 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest +import Testing @testable import Nuke #if !os(macOS) import UIKit #endif -// MARK: - ImageProcessors.Composition +@Suite struct ImageProcessorsCompositionTest { -class ImageProcessorsCompositionTest: XCTestCase { - - func testAppliesAllProcessors() throws { - // GIVEN + @Test func appliesAllProcessors() throws { + // Given let processor = ImageProcessors.Composition([ MockImageProcessor(id: "1"), MockImageProcessor(id: "2")] ) - // WHEN - let image = try XCTUnwrap(processor.process(Test.image)) + // When + let image = try #require(processor.process(Test.image)) - // THEN - XCTAssertEqual(image.nk_test_processorIDs, ["1", "2"]) + // Then + #expect(image.nk_test_processorIDs == ["1", "2"]) } - func testAppliesAllProcessorsWithContext() throws { - // GIVEN + @Test func appliesAllProcessorsWithContext() throws { + // Given let processor = ImageProcessors.Composition([ MockImageProcessor(id: "1"), MockImageProcessor(id: "2")] ) - // WHEN + // When let context = ImageProcessingContext(request: Test.request, response: ImageResponse(container: Test.container, request: Test.request), isCompleted: true) let output = try processor.process(Test.container, context: context) - // THEN - XCTAssertEqual(output.image.nk_test_processorIDs, ["1", "2"]) + // Then + #expect(output.image.nk_test_processorIDs == ["1", "2"]) } - func testIdenfitiers() { + @Test func identifiers() { // Given different processors let lhs = ImageProcessors.Composition([MockImageProcessor(id: "1")]) let rhs = ImageProcessors.Composition([MockImageProcessor(id: "2")]) // Then - XCTAssertNotEqual(lhs, rhs) - XCTAssertNotEqual(lhs.identifier, rhs.identifier) - XCTAssertNotEqual(lhs.hashableIdentifier, rhs.hashableIdentifier) + #expect(lhs != rhs) + #expect(lhs.identifier != rhs.identifier) + #expect(lhs.hashableIdentifier != rhs.hashableIdentifier) } - func testIdentifiersDifferentProcessorCount() { + @Test func identifiersDifferentProcessorCount() { // Given processors with different processor count let lhs = ImageProcessors.Composition([MockImageProcessor(id: "1")]) let rhs = ImageProcessors.Composition([MockImageProcessor(id: "1"), MockImageProcessor(id: "2")]) // Then - XCTAssertNotEqual(lhs, rhs) - XCTAssertNotEqual(lhs.identifier, rhs.identifier) - XCTAssertNotEqual(lhs.hashableIdentifier, rhs.hashableIdentifier) + #expect(lhs != rhs) + #expect(lhs.identifier != rhs.identifier) + #expect(lhs.hashableIdentifier != rhs.hashableIdentifier) } - func testIdenfitiersEqualProcessors() { + @Test func identifiersEqualProcessors() { // Given processors with equal processors let lhs = ImageProcessors.Composition([MockImageProcessor(id: "1"), MockImageProcessor(id: "2")]) let rhs = ImageProcessors.Composition([MockImageProcessor(id: "1"), MockImageProcessor(id: "2")]) // Then - XCTAssertEqual(lhs, rhs) - XCTAssertEqual(lhs.hashValue, rhs.hashValue) - XCTAssertEqual(lhs.identifier, rhs.identifier) - XCTAssertEqual(lhs.hashableIdentifier, rhs.hashableIdentifier) + #expect(lhs == rhs) + #expect(lhs.hashValue == rhs.hashValue) + #expect(lhs.identifier == rhs.identifier) + #expect(lhs.hashableIdentifier == rhs.hashableIdentifier) } - func testIdentifiersWithSameProcessorsButInDifferentOrder() { + @Test func identifiersWithSameProcessorsButInDifferentOrder() { // Given processors with equal processors but in different order let lhs = ImageProcessors.Composition([MockImageProcessor(id: "2"), MockImageProcessor(id: "1")]) let rhs = ImageProcessors.Composition([MockImageProcessor(id: "1"), MockImageProcessor(id: "2")]) // Then - XCTAssertNotEqual(lhs, rhs) - XCTAssertNotEqual(lhs.identifier, rhs.identifier) - XCTAssertNotEqual(lhs.hashableIdentifier, rhs.hashableIdentifier) + #expect(lhs != rhs) + #expect(lhs.identifier != rhs.identifier) + #expect(lhs.hashableIdentifier != rhs.hashableIdentifier) } - func testIdenfitiersEmptyProcessors() { + @Test func identifiersEmptyProcessors() { // Given empty processors let lhs = ImageProcessors.Composition([]) let rhs = ImageProcessors.Composition([]) // Then - XCTAssertEqual(lhs, rhs) - XCTAssertEqual(lhs.hashValue, rhs.hashValue) - XCTAssertEqual(lhs.identifier, rhs.identifier) - XCTAssertEqual(lhs.hashableIdentifier, rhs.hashableIdentifier) + #expect(lhs == rhs) + #expect(lhs.hashValue == rhs.hashValue) + #expect(lhs.identifier == rhs.identifier) + #expect(lhs.hashableIdentifier == rhs.hashableIdentifier) } - func testThatIdentifiesAreFlattened() { + @Test func thatIdentifiesAreFlattened() { let lhs = ImageProcessors.Composition([ ImageProcessors.Composition([MockImageProcessor(id: "1"), MockImageProcessor(id: "2")]), ImageProcessors.Composition([MockImageProcessor(id: "3"), MockImageProcessor(id: "4")])] @@ -110,16 +108,16 @@ class ImageProcessorsCompositionTest: XCTestCase { ) // Then - XCTAssertEqual(lhs.identifier, rhs.identifier) + #expect(lhs.identifier == rhs.identifier) } - func testDescription() { - // GIVEN + @Test func description() { + // Given let processor = ImageProcessors.Composition([ ImageProcessors.Circle() ]) - // THEN - XCTAssertEqual("\(processor)", "Composition(processors: [Circle(border: nil)])") + // Then + #expect("\(processor)" == "Composition(processors: [Circle(border: nil)])") } } diff --git a/Tests/NukeTests/ImageProcessorsTests/CoreImageFilterTests.swift b/Tests/NukeTests/ImageProcessorsTests/CoreImageFilterTests.swift index 9d8d6ee3a..277b5ac33 100644 --- a/Tests/NukeTests/ImageProcessorsTests/CoreImageFilterTests.swift +++ b/Tests/NukeTests/ImageProcessorsTests/CoreImageFilterTests.swift @@ -1,8 +1,8 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest +import Testing @testable import Nuke #if !os(macOS) @@ -11,109 +11,113 @@ import UIKit #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) -class ImageProcessorsCoreImageFilterTests: XCTestCase { - func testApplySepia() throws { - // GIVEN +@Suite struct ImageProcessorsCoreImageFilterTests { + @Test func applySepia() throws { + // Given let input = Test.image(named: "fixture-tiny.jpeg") let processor = ImageProcessors.CoreImageFilter(name: "CISepiaTone") - - // WHEN - let output = try XCTUnwrap(processor.process(input)) - - // THEN - XCTAssertNotNil(output) - + + // When + let output = try #require(processor.process(input)) + + // Then + #expect(output != nil) + // TODO: The comparison doesn't work for some reason - // XCTAssertEqualImages(output, Test.image(named: "s-sepia.png")) + // #expect(isEqual(output, Test.image(named: "s-sepia.png"))) } - - func testApplySepiaWithParameters() throws { - // GIVEN + + @Test func applySepiaWithParameters() throws { + // Given let input = Test.image(named: "fixture-tiny.jpeg") let processor = ImageProcessors.CoreImageFilter(name: "CISepiaTone", parameters: ["inputIntensity": 0.5], identifier: "CISepiaTone-75") - - // WHEN - let output = try XCTUnwrap(processor.process(input)) - - // THEN - XCTAssertNotNil(output) - + + // When + let output = try #require(processor.process(input)) + + // Then + #expect(output != nil) + // TODO: The comparison doesn't work for some reason - // XCTAssertEqualImages(output, Test.image(named: "s-sepia-less-intense.png")) + // #expect(isEqual(output, Test.image(named: "s-sepia-less-intense.png"))) } - - func testApplyFilterWithInvalidName() throws { - // GIVEN + + @Test func applyFilterWithInvalidName() throws { + // Given let input = Test.image(named: "fixture-tiny.jpeg") let processor = ImageProcessors.CoreImageFilter(name: "yo", parameters: ["inputIntensity": 0.5], identifier: "CISepiaTone-75") - - // THEN - XCTAssertThrowsError(try processor.processThrowing(input)) { error in + + // Then + #expect(performing: { + try processor.processThrowing(input) + }, throws: { error in guard let error = error as? ImageProcessors.CoreImageFilter.Error else { - return XCTFail("Unexpected error type: \(error)") + return false } switch error { case let .failedToCreateFilter(name, parameters): - XCTAssertEqual(name, "yo") - XCTAssertNotNil(parameters["inputIntensity"]) + #expect(name == "yo") + #expect(parameters["inputIntensity"] != nil) + return true default: - XCTFail("Unexpected error type: \(error)") + return false } - } + }) } - + #if os(iOS) || os(tvOS) || os(visionOS) - func testApplyFilterToCIImage() throws { - // GIVEN image backed by CIImage + @Test func applyFilterToCIImage() throws { + // Given image backed by CIImage let input = PlatformImage(ciImage: CIImage(cgImage: Test.image.cgImage!)) let processor = ImageProcessors.CoreImageFilter(name: "CISepiaTone", parameters: ["inputIntensity": 0.5], identifier: "CISepiaTone-75") - - // WHEN - let output = try XCTUnwrap(processor.process(input)) - - // THEN - XCTAssertNotNil(output) + + // When + let output = try #require(processor.process(input)) + + // Then + #expect(output != nil) } #endif - - func testApplyFilterBackedByNothing() throws { - // GIVEN empty image + + @Test func applyFilterBackedByNothing() throws { + // Given empty image let input = PlatformImage() let processor = ImageProcessors.CoreImageFilter(name: "CISepiaTone", parameters: ["inputIntensity": 0.5], identifier: "CISepiaTone-75") - - // THEN - XCTAssertThrowsError(try processor.processThrowing(input)) { error in + + #expect(performing: { + try processor.processThrowing(input) + }, throws: { error in guard let error = error as? ImageProcessors.CoreImageFilter.Error else { - return XCTFail("Unexpected error type: \(error)") + return false } switch error { case .inputImageIsEmpty: - break // Do nothing + return true default: - XCTFail("Unexpected error type: \(error)") + return false } - } + }) } - - func testDescription() { - // GIVEN + + @Test func description() { + // Given let processor = ImageProcessors.CoreImageFilter(name: "CISepiaTone", parameters: ["inputIntensity": 0.5], identifier: "CISepiaTone-75") - - // THEN - XCTAssertEqual("\(processor)", "CoreImageFilter(name: CISepiaTone, parameters: [\"inputIntensity\": 0.5])") + + // Then + #expect("\(processor)" == "CoreImageFilter(name: CISepiaTone, parameters: [\"inputIntensity\": 0.5])") } - func testApplyCustomFilter() throws { - // GIVEN + @Test func applyCustomFilter() throws { + // Given let input = Test.image(named: "fixture-tiny.jpeg") - let filter = try XCTUnwrap(CIFilter(name: "CISepiaTone", parameters: nil)) + let filter = try #require(CIFilter(name: "CISepiaTone", parameters: nil)) let processor = ImageProcessors.CoreImageFilter(filter, identifier: "test") - // WHEN - let output = try XCTUnwrap(processor.process(input)) + // When + let output = try #require(processor.process(input)) - // THEN - XCTAssertNotNil(output) + // Then + #expect(output != nil) } } diff --git a/Tests/NukeTests/ImageProcessorsTests/DecompressionTests.swift b/Tests/NukeTests/ImageProcessorsTests/DecompressionTests.swift index 996d30839..6417d9c0d 100644 --- a/Tests/NukeTests/ImageProcessorsTests/DecompressionTests.swift +++ b/Tests/NukeTests/ImageProcessorsTests/DecompressionTests.swift @@ -1,13 +1,15 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation -import XCTest @testable import Nuke -class ImageDecompressionTests: XCTestCase { +@Suite struct ImageDecompressionTests { - func testDecompressionNotNeededFlagSet() throws { + @Test func decompressionNotNeededFlagSet() throws { // Given let input = Test.image ImageDecompression.setDecompressionNeeded(true, for: input) @@ -16,14 +18,14 @@ class ImageDecompressionTests: XCTestCase { let output = ImageDecompression.decompress(image: input) // Then - XCTAssertFalse(ImageDecompression.isDecompressionNeeded(for: output) ?? false) + #expect(ImageDecompression.isDecompressionNeeded(for: output) == nil) } - func testGrayscalePreserved() throws { + @Test func grayscalePreserved() throws { // Given let input = Test.image(named: "grayscale", extension: "jpeg") - XCTAssertEqual(input.cgImage?.bitsPerComponent, 8) - XCTAssertEqual(input.cgImage?.bitsPerPixel, 8) + #expect(input.cgImage?.bitsPerComponent == 8) + #expect(input.cgImage?.bitsPerPixel == 8) // When let output = ImageDecompression.decompress(image: input, isUsingPrepareForDisplay: true) @@ -34,15 +36,15 @@ class ImageDecompressionTests: XCTestCase { // supported by CGContext. Thus we are switching to a different format. #if os(iOS) || os(tvOS) || os(visionOS) if #available(iOS 15.0, tvOS 15.0, *) { - XCTAssertEqual(output.cgImage?.bitsPerPixel, 8) // Yay, preparingForDisplay supports it - XCTAssertEqual(output.cgImage?.bitsPerComponent, 8) + #expect(output.cgImage?.bitsPerPixel == 8) // Yay, preparingForDisplay supports it // Yay, preparingForDisplay supports it + #expect(output.cgImage?.bitsPerComponent == 8) } else { - XCTAssertEqual(output.cgImage?.bitsPerPixel, 8) - XCTAssertEqual(output.cgImage?.bitsPerComponent, 8) + #expect(output.cgImage?.bitsPerPixel == 8) + #expect(output.cgImage?.bitsPerComponent == 8) } #else - XCTAssertEqual(output.cgImage?.bitsPerPixel, 8) - XCTAssertEqual(output.cgImage?.bitsPerComponent, 8) + #expect(output.cgImage?.bitsPerPixel == 8) + #expect(output.cgImage?.bitsPerComponent == 8) #endif } } diff --git a/Tests/NukeTests/ImageProcessorsTests/GaussianBlurTests.swift b/Tests/NukeTests/ImageProcessorsTests/GaussianBlurTests.swift index 7d85925a7..10834c2b9 100644 --- a/Tests/NukeTests/ImageProcessorsTests/GaussianBlurTests.swift +++ b/Tests/NukeTests/ImageProcessorsTests/GaussianBlurTests.swift @@ -1,73 +1,67 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest -@testable import Nuke +import Testing +import Foundation -#if !os(macOS) - import UIKit -#endif +@testable import Nuke #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) -class ImageProcessorsGaussianBlurTest: XCTestCase { - func testApplyBlur() { +@Suite struct ImageProcessorsGaussianBlurTest { + @Test func applyBlur() { // Given let image = Test.image let processor = ImageProcessors.GaussianBlur() - XCTAssertFalse(processor.description.isEmpty) // Bumping that test coverage + #expect(!processor.description.isEmpty) // Bumping that test coverage // Bumping that test coverage // When - XCTAssertNotNil(processor.process(image)) + #expect(processor.process(image) != nil) } - func testApplyBlurProducesImagesBackedByCoreGraphics() { + @Test func applyBlurProducesImagesBackedByCoreGraphics() { // Given let image = Test.image let processor = ImageProcessors.GaussianBlur() // When - XCTAssertNotNil(processor.process(image)) + #expect(processor.process(image) != nil) } - func testApplyBlurProducesTransparentImages() throws { + @Test func applyBlurProducesTransparentImages() throws { // Given let image = Test.image let processor = ImageProcessors.GaussianBlur() // When - let processed = try XCTUnwrap(processor.process(image)) + let processed = try #require(processor.process(image)) // Then - XCTAssertEqual(processed.cgImage?.isOpaque, false) + #expect(processed.cgImage?.isOpaque == false) } - func testImagesWithSameRadiusHasSameIdentifiers() { - XCTAssertEqual( - ImageProcessors.GaussianBlur(radius: 2).identifier, - ImageProcessors.GaussianBlur(radius: 2).identifier + @Test func imagesWithSameRadiusHasSameIdentifiers() { + #expect( + ImageProcessors.GaussianBlur(radius: 2).identifier == ImageProcessors.GaussianBlur(radius: 2).identifier ) } - func testImagesWithDifferentRadiusHasDifferentIdentifiers() { - XCTAssertNotEqual( - ImageProcessors.GaussianBlur(radius: 2).identifier, - ImageProcessors.GaussianBlur(radius: 3).identifier + @Test func imagesWithDifferentRadiusHasDifferentIdentifiers() { + #expect( + ImageProcessors.GaussianBlur(radius: 2).identifier != ImageProcessors.GaussianBlur(radius: 3).identifier ) } - func testImagesWithSameRadiusHasSameHashableIdentifiers() { - XCTAssertEqual( - ImageProcessors.GaussianBlur(radius: 2).hashableIdentifier, - ImageProcessors.GaussianBlur(radius: 2).hashableIdentifier + @Test func imagesWithSameRadiusHasSameHashableIdentifiers() { + #expect( + ImageProcessors.GaussianBlur(radius: 2).hashableIdentifier == ImageProcessors.GaussianBlur(radius: 2).hashableIdentifier ) } - func testImagesWithDifferentRadiusHasDifferentHashableIdentifiers() { - XCTAssertNotEqual( - ImageProcessors.GaussianBlur(radius: 2).hashableIdentifier, - ImageProcessors.GaussianBlur(radius: 3).hashableIdentifier + @Test func imagesWithDifferentRadiusHasDifferentHashableIdentifiers() { + #expect( + ImageProcessors.GaussianBlur(radius: 2).hashableIdentifier != ImageProcessors.GaussianBlur(radius: 3).hashableIdentifier ) } } diff --git a/Tests/NukeTests/ImageProcessorsTests/ImageDownsampleTests.swift b/Tests/NukeTests/ImageProcessorsTests/ImageDownsampleTests.swift deleted file mode 100644 index 5fb71ce89..000000000 --- a/Tests/NukeTests/ImageProcessorsTests/ImageDownsampleTests.swift +++ /dev/null @@ -1,103 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -@testable import Nuke - -#if !os(macOS) - import UIKit -#endif - -class ImageThumbnailTest: XCTestCase { - - func testThatImageIsResized() throws { - // WHEN - let options = ImageRequest.ThumbnailOptions(maxPixelSize: 400) - let output = try XCTUnwrap(options.makeThumbnail(with: Test.data)) - - // THEN - XCTAssertEqual(output.sizeInPixels, CGSize(width: 400, height: 300)) - } - - func testThatImageIsResizedToFill() throws { - // Given - let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFill) - - // When - let output = try XCTUnwrap(options.makeThumbnail(with: Test.data)) - - // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 533, height: 400)) - } - - func testThatImageIsResizedToFillPNG() throws { - // Given - let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 180, height: 180), unit: .pixels, contentMode: .aspectFill) - - // When - // Input: 640 × 360 - let output = try XCTUnwrap(makeThumbnail(data: Test.data(name: "fixture", extension: "png"), options: options)) - - // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 320, height: 180)) - } - - func testThatImageIsResizedToFit() throws { - // Given - let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFit) - - // When - let output = try XCTUnwrap(options.makeThumbnail(with: Test.data)) - - // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 400, height: 300)) - } - - func testThatImageIsResizedToFitPNG() throws { - // Given - let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 160, height: 160), unit: .pixels, contentMode: .aspectFit) - - // When - // Input: 640 × 360 - let output = try XCTUnwrap(options.makeThumbnail(with: Test.data(name: "fixture", extension: "png"))) - - // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 160, height: 90)) - } - -#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) - func testResizeImageWithOrientationRight() throws { - // Given an image with `right` orientation. From the user perspective, - // the image a landscape image with s size 640x480px. The raw pixel - // data, on the other hand, is 480x640px. - let input = try XCTUnwrap(Test.data(name: "right-orientation", extension: "jpeg")) - XCTAssertEqual(PlatformImage(data: input)?.imageOrientation, .right) - - // When we resize the image to fit 320x480px frame, we expect the processor - // to take image orientation into the account and produce a 320x240px. - let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 320, height: 1000), unit: .pixels, contentMode: .aspectFit) - let output = try XCTUnwrap(options.makeThumbnail(with: input)) - - // Then the output has orientation of the original image - XCTAssertEqual(output.imageOrientation, .right) - - //verify size of the image in points and pixels (using scale) - XCTAssertEqual(output.sizeInPixels, CGSize(width: 320, height: 240)) - } - - func testResizeImageWithOrientationUp() throws { - let input = try XCTUnwrap(Test.data(name: "baseline", extension: "jpeg")) - XCTAssertEqual(PlatformImage(data: input)?.imageOrientation, .up) - - let options = ImageRequest.ThumbnailOptions(maxPixelSize: 300) - let output = try XCTUnwrap(options.makeThumbnail(with: input)) - - // Then the output has orientation of the original image - XCTAssertEqual(output.imageOrientation, .up) - - //verify size of the image in points and pixels (using scale) - XCTAssertEqual(output.sizeInPixels, CGSize(width: 300, height: 200)) - } -#endif -} diff --git a/Tests/NukeTests/ImageProcessorsTests/ImageProcessorsProtocolExtensionsTests.swift b/Tests/NukeTests/ImageProcessorsTests/ImageProcessorsProtocolExtensionsTests.swift index cbf292cf1..1ca2bc8d9 100644 --- a/Tests/NukeTests/ImageProcessorsTests/ImageProcessorsProtocolExtensionsTests.swift +++ b/Tests/NukeTests/ImageProcessorsTests/ImageProcessorsProtocolExtensionsTests.swift @@ -1,108 +1,110 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest -import Nuke +import Foundation +import Testing -class ImageProcessorsProtocolExtensionsTests: XCTestCase { - - func testPassingProcessorsUsingProtocolExtensionsResize() throws { +@testable import Nuke + +@Suite struct ImageProcessorsProtocolExtensionsTests { + + @Test func passingProcessorsUsingProtocolExtensionsResize() throws { let size = CGSize(width: 100, height: 100) let processor = ImageProcessors.Resize(size: size) - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.resize(size: size)])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.resize(size: size)])) + + #expect(request.processors.first?.identifier == processor.identifier) } - - func testPassingProcessorsUsingProtocolExtensionsResizeWidthOnly() throws { + + @Test func passingProcessorsUsingProtocolExtensionsResizeWidthOnly() throws { let processor = ImageProcessors.Resize(width: 100) - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.resize(width: 100)])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.resize(width: 100)])) + + #expect(request.processors.first?.identifier == processor.identifier) } - - func testPassingProcessorsUsingProtocolExtensionsResizeHeightOnly() throws { + + @Test func passingProcessorsUsingProtocolExtensionsResizeHeightOnly() throws { let processor = ImageProcessors.Resize(height: 100) - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.resize(height: 100)])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.resize(height: 100)])) + + #expect(request.processors.first?.identifier == processor.identifier) } - - func testPassingProcessorsUsingProtocolExtensionsCircleEmpty() throws { + + @Test func passingProcessorsUsingProtocolExtensionsCircleEmpty() throws { let processor = ImageProcessors.Circle() - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.circle()])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.circle()])) + + #expect(request.processors.first?.identifier == processor.identifier) } - - func testPassingProcessorsUsingProtocolExtensionsCircle() throws { + + @Test func passingProcessorsUsingProtocolExtensionsCircle() throws { let border = ImageProcessingOptions.Border.init(color: .red) let processor = ImageProcessors.Circle(border: border) - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.circle(border: border)])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.circle(border: border)])) + + #expect(request.processors.first?.identifier == processor.identifier) } - - func testPassingProcessorsUsingProtocolExtensionsRoundedCorners() throws { + + @Test func passingProcessorsUsingProtocolExtensionsRoundedCorners() throws { let radius: CGFloat = 10 let processor = ImageProcessors.RoundedCorners(radius: radius) - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.roundedCorners(radius: radius)])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.roundedCorners(radius: radius)])) + + #expect(request.processors.first?.identifier == processor.identifier) } - - func testPassingProcessorsUsingProtocolExtensionsAnonymous() throws { + + @Test func passingProcessorsUsingProtocolExtensionsAnonymous() throws { let id = UUID().uuidString let closure: (@Sendable (PlatformImage) -> PlatformImage?) = { _ in nil } let processor = ImageProcessors.Anonymous(id: id, closure) - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.process(id: id, closure)])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.process(id: id, closure)])) + + #expect(request.processors.first?.identifier == processor.identifier) } - + #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) - func testPassingProcessorsUsingProtocolExtensionsCoreImageFilterWithNameOnly() throws { + @Test func passingProcessorsUsingProtocolExtensionsCoreImageFilterWithNameOnly() throws { let name = "CISepiaTone" let processor = ImageProcessors.CoreImageFilter(name: name) - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.coreImageFilter(name: name)])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.coreImageFilter(name: name)])) + + #expect(request.processors.first?.identifier == processor.identifier) } - - func testPassingProcessorsUsingProtocolExtensionsCoreImageFilter() throws { + + @Test func passingProcessorsUsingProtocolExtensionsCoreImageFilter() throws { let name = "CISepiaTone" let id = UUID().uuidString let processor = ImageProcessors.CoreImageFilter(name: name, parameters: [:], identifier: id) - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.coreImageFilter(name: name, parameters: [:], identifier: id)])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.coreImageFilter(name: name, parameters: [:], identifier: id)])) + + #expect(request.processors.first?.identifier == processor.identifier) } - - func testPassingProcessorsUsingProtocolExtensionsGaussianBlurEmpty() throws { + + @Test func passingProcessorsUsingProtocolExtensionsGaussianBlurEmpty() throws { let processor = ImageProcessors.GaussianBlur() - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.gaussianBlur()])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.gaussianBlur()])) + + #expect(request.processors.first?.identifier == processor.identifier) } - - func testPassingProcessorsUsingProtocolExtensionsGaussianBlur() throws { + + @Test func passingProcessorsUsingProtocolExtensionsGaussianBlur() throws { let radius = 10 let processor = ImageProcessors.GaussianBlur(radius: radius) - - let request = try XCTUnwrap(ImageRequest(url: nil, processors: [.gaussianBlur(radius: radius)])) - - XCTAssertEqual(request.processors.first?.identifier, processor.identifier) + + let request = try #require(ImageRequest(url: nil, processors: [.gaussianBlur(radius: radius)])) + + #expect(request.processors.first?.identifier == processor.identifier) } #endif } diff --git a/Tests/NukeTests/ImageProcessorsTests/ImageThumbnailTest.swift b/Tests/NukeTests/ImageProcessorsTests/ImageThumbnailTest.swift new file mode 100644 index 000000000..56151bfb6 --- /dev/null +++ b/Tests/NukeTests/ImageProcessorsTests/ImageThumbnailTest.swift @@ -0,0 +1,101 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation + +@testable import Nuke + +@Suite struct ImageThumbnailTest { + + @Test func thatImageIsResized() throws { + // When + let options = ImageRequest.ThumbnailOptions(maxPixelSize: 400) + let output = try #require(options.makeThumbnail(with: Test.data)) + + // Then + #expect(output.sizeInPixels == CGSize(width: 400, height: 300)) + } + + @Test func thatImageIsResizedToFill() throws { + // Given + let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFill) + + // When + let output = try #require(options.makeThumbnail(with: Test.data)) + + // Then + #expect(output.sizeInPixels == CGSize(width: 533, height: 400)) + } + + @Test func thatImageIsResizedToFillPNG() throws { + // Given + let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 180, height: 180), unit: .pixels, contentMode: .aspectFill) + + // When + // Input: 640 × 360 + let output = try #require(makeThumbnail(data: Test.data(name: "fixture", extension: "png"), options: options)) + + // Then + #expect(output.sizeInPixels == CGSize(width: 320, height: 180)) + } + + @Test func thatImageIsResizedToFit() throws { + // Given + let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFit) + + // When + let output = try #require(options.makeThumbnail(with: Test.data)) + + // Then + #expect(output.sizeInPixels == CGSize(width: 400, height: 300)) + } + + @Test func thatImageIsResizedToFitPNG() throws { + // Given + let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 160, height: 160), unit: .pixels, contentMode: .aspectFit) + + // When + // Input: 640 × 360 + let output = try #require(options.makeThumbnail(with: Test.data(name: "fixture", extension: "png"))) + + // Then + #expect(output.sizeInPixels == CGSize(width: 160, height: 90)) + } + +#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + @Test func resizeImageWithOrientationRight() throws { + // Given an image with `right` orientation. From the user perspective, + // the image a landscape image with s size 640x480px. The raw pixel + // data, on the other hand, is 480x640px. + let input = try #require(Test.data(name: "right-orientation", extension: "jpeg")) + #expect(PlatformImage(data: input)?.imageOrientation == .right) + + // When we resize the image to fit 320x480px frame, we expect the processor + // to take image orientation into the account and produce a 320x240px. + let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 320, height: 1000), unit: .pixels, contentMode: .aspectFit) + let output = try #require(options.makeThumbnail(with: input)) + + // Then the output has orientation of the original image + #expect(output.imageOrientation == .right) + + //verify size of the image in points and pixels (using scale) + #expect(output.sizeInPixels == CGSize(width: 320, height: 240)) + } + + @Test func resizeImageWithOrientationUp() throws { + let input = try #require(Test.data(name: "baseline", extension: "jpeg")) + #expect(PlatformImage(data: input)?.imageOrientation == .up) + + let options = ImageRequest.ThumbnailOptions(maxPixelSize: 300) + let output = try #require(options.makeThumbnail(with: input)) + + // Then the output has orientation of the original image + #expect(output.imageOrientation == .up) + + //verify size of the image in points and pixels (using scale) + #expect(output.sizeInPixels == CGSize(width: 300, height: 200)) + } +#endif +} diff --git a/Tests/NukeTests/ImageProcessorsTests/ResizeTests.swift b/Tests/NukeTests/ImageProcessorsTests/ResizeTests.swift index 444aae7b7..af08cdc6b 100644 --- a/Tests/NukeTests/ImageProcessorsTests/ResizeTests.swift +++ b/Tests/NukeTests/ImageProcessorsTests/ResizeTests.swift @@ -1,295 +1,280 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Testing -import XCTest @testable import Nuke -#if !os(macOS) -import UIKit -#endif +@Suite struct ImageProcessorsResizeTests { -class ImageProcessorsResizeTests: XCTestCase { - - func testThatImageIsResizedToFill() throws { + @Test func thatImageIsResizedToFill() throws { // Given let processor = ImageProcessors.Resize(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFill) - + // When - let output = try XCTUnwrap(processor.process(Test.image), "Failed to process an image") - + let output = try #require(processor.process(Test.image), "Failed to process an image") + // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 533, height: 400)) + #expect(output.sizeInPixels == CGSize(width: 533, height: 400)) } - - func testThatImageIsntUpscaledByDefault() throws { + + @Test func thatImageIsntUpscaledByDefault() throws { // Given let processor = ImageProcessors.Resize(size: CGSize(width: 960, height: 960), unit: .pixels, contentMode: .aspectFill) - + // When - let output = try XCTUnwrap(processor.process(Test.image), "Failed to process an image") - + let output = try #require(processor.process(Test.image), "Failed to process an image") + // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 640, height: 480)) + #expect(output.sizeInPixels == CGSize(width: 640, height: 480)) } - - func testResizeToFitHeight() throws { + + @Test func resizeToFitHeight() throws { // Given let processor = ImageProcessors.Resize(height: 300, unit: .pixels) - + // When - let output = try XCTUnwrap(processor.process(Test.image), "Failed to process an image") - + let output = try #require(processor.process(Test.image), "Failed to process an image") + // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 400, height: 300)) + #expect(output.sizeInPixels == CGSize(width: 400, height: 300)) } - - func testResizeToFitWidth() throws { + + @Test func resizeToFitWidth() throws { // Given let processor = ImageProcessors.Resize(width: 400, unit: .pixels) - + // When - let output = try XCTUnwrap(processor.process(Test.image), "Failed to process an image") - + let output = try #require(processor.process(Test.image), "Failed to process an image") + // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 400, height: 300)) + #expect(output.sizeInPixels == CGSize(width: 400, height: 300)) } - - func testThatImageIsUpscaledIfOptionIsEnabled() throws { + + @Test func thatImageIsUpscaledIfOptionIsEnabled() throws { // Given let processor = ImageProcessors.Resize(size: CGSize(width: 960, height: 960), unit: .pixels, contentMode: .aspectFill, upscale: true) - + // When - let output = try XCTUnwrap(processor.process(Test.image), "Failed to process an image") - + let output = try #require(processor.process(Test.image), "Failed to process an image") + // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 1280, height: 960)) + #expect(output.sizeInPixels == CGSize(width: 1280, height: 960)) } - - func testThatContentModeCanBeChangeToAspectFit() throws { + + @Test func thatContentModeCanBeChangeToAspectFit() throws { // Given let processor = ImageProcessors.Resize(size: CGSize(width: 480, height: 480), unit: .pixels, contentMode: .aspectFit) - + // When - let output = try XCTUnwrap(processor.process(Test.image), "Failed to process an image") - + let output = try #require(processor.process(Test.image), "Failed to process an image") + // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 480, height: 360)) + #expect(output.sizeInPixels == CGSize(width: 480, height: 360)) } - - func testThatImageIsCropped() throws { + + @Test func thatImageIsCropped() throws { // Given let processor = ImageProcessors.Resize(size: CGSize(width: 400, height: 400), unit: .pixels, crop: true) - + // When - let output = try XCTUnwrap(processor.process(Test.image), "Failed to process an image") - + let output = try #require(processor.process(Test.image), "Failed to process an image") + // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 400, height: 400)) + #expect(output.sizeInPixels == CGSize(width: 400, height: 400)) } - - func testThatImageIsntCroppedWithAspectFitMode() throws { + + @Test func thatImageIsntCroppedWithAspectFitMode() throws { // Given let processor = ImageProcessors.Resize(size: CGSize(width: 480, height: 480), unit: .pixels, contentMode: .aspectFit, crop: true) - + // When - let output = try XCTUnwrap(processor.process(Test.image), "Failed to process an image") - + let output = try #require(processor.process(Test.image), "Failed to process an image") + // Then image is resized but isn't cropped - XCTAssertEqual(output.sizeInPixels, CGSize(width: 480, height: 360)) + #expect(output.sizeInPixels == CGSize(width: 480, height: 360)) } - - func testExtendedColorSpaceSupport() throws { + + @Test func extendedColorSpaceSupport() throws { // Given let processor = ImageProcessors.Resize(size: CGSize(width: 480, height: 480), unit: .pixels, contentMode: .aspectFit, crop: true) - + // When - let output = try XCTUnwrap(processor.process(Test.image(named: "image-p3", extension: "jpg")), "Failed to process an image") - + let output = try #require(processor.process(Test.image(named: "image-p3", extension: "jpg")), "Failed to process an image") + // Then image is resized but isn't cropped - XCTAssertEqual(output.sizeInPixels, CGSize(width: 480, height: 320)) - let colorSpace = try XCTUnwrap(output.cgImage?.colorSpace) + #expect(output.sizeInPixels == CGSize(width: 480, height: 320)) + let colorSpace = try #require(output.cgImage?.colorSpace) #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) - XCTAssertTrue(colorSpace.isWideGamutRGB) + #expect(colorSpace.isWideGamutRGB) #elseif os(watchOS) - XCTAssertFalse(colorSpace.isWideGamutRGB) + #expect(!colorSpace.isWideGamutRGB) #endif } - + #if os(macOS) + @Test @MainActor - func testResizeImageWithOrientationLeft() throws { + func resizeImageWithOrientationLeft() throws { // Given an image with `left` orientation. From the user perspective, // the image a landscape image with s size 640x480px. The raw pixel // data, on the other hand, is 480x640px. macOS, however, automatically // changes image orientaiton to `up` so that you don't have to worry about it - let input = try XCTUnwrap(Test.image(named: "right-orientation.jpeg")) - + let input = try #require(Test.image(named: "right-orientation.jpeg")) + // When we resize the image to fit 320x480px frame, we expect the processor // to take image orientation into the account and produce a 320x240px. let processor = ImageProcessors.Resize(size: CGSize(width: 320, height: 1000), unit: .pixels, contentMode: .aspectFit) - let output = try XCTUnwrap(processor.process(input), "Failed to process an image") - + let output = try #require(processor.process(input), "Failed to process an image") + // Then the image orientation is still `.left` - XCTAssertEqual(output.sizeInPixels, CGSize(width: 320, height: 240)) - + #expect(output.sizeInPixels == CGSize(width: 320, height: 240)) + // Then the image is resized according to orientation - XCTAssertEqual(output.size, CGSize(width: 320 / Screen.scale, height: 240 / Screen.scale)) + #expect(output.size == CGSize(width: 320 / Screen.scale, height: 240 / Screen.scale)) } #endif - + #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) - func testResizeImageWithOrientationLeft() throws { + @Test func resizeImageWithOrientationLeft() throws { // Given an image with `right` orientation. From the user perspective, // the image a landscape image with s size 640x480px. The raw pixel // data, on the other hand, is 480x640px. - let input = try XCTUnwrap(Test.image(named: "right-orientation.jpeg")) - XCTAssertEqual(input.imageOrientation, .right) - + let input = try #require(Test.image(named: "right-orientation.jpeg")) + #expect(input.imageOrientation == .right) + // When we resize the image to fit 320x480px frame, we expect the processor // to take image orientation into the account and produce a 320x240px. let processor = ImageProcessors.Resize(size: CGSize(width: 320, height: 1000), unit: .pixels, contentMode: .aspectFit) - let output = try XCTUnwrap(processor.process(input), "Failed to process an image") - + let output = try #require(processor.process(input), "Failed to process an image") + // Then the image orientation is still `.right` - XCTAssertEqual(output.sizeInPixels, CGSize(width: 240, height: 320)) - XCTAssertEqual(output.imageOrientation, .right) + #expect(output.sizeInPixels == CGSize(width: 240, height: 320)) + #expect(output.imageOrientation == .right) // Then the image is resized according to orientation - XCTAssertEqual(output.size, CGSize(width: 320, height: 240)) + #expect(output.size == CGSize(width: 320, height: 240)) } - func testResizeAndCropWithOrientationLeft() throws { + @Test func resizeAndCropWithOrientationLeft() throws { // Given an image with `right` orientation. From the user perspective, // the image a landscape image with s size 640x480px. The raw pixel // data, on the other hand, is 480x640px. - let input = try XCTUnwrap(Test.image(named: "right-orientation.jpeg")) - XCTAssertEqual(input.imageOrientation, .right) - + let input = try #require(Test.image(named: "right-orientation.jpeg")) + #expect(input.imageOrientation == .right) + // When let processor = ImageProcessors.Resize(size: CGSize(width: 320, height: 80), unit: .pixels, contentMode: .aspectFill, crop: true) - let output = try XCTUnwrap(processor.process(input), "Failed to process an image") - + let output = try #require(processor.process(input), "Failed to process an image") + // Then - XCTAssertEqual(output.sizeInPixels, CGSize(width: 80, height: 320)) - XCTAssertEqual(output.imageOrientation, .right) + #expect(output.sizeInPixels == CGSize(width: 80, height: 320)) + #expect(output.imageOrientation == .right) // Then - XCTAssertEqual(output.size, CGSize(width: 320, height: 80)) + #expect(output.size == CGSize(width: 320, height: 80)) } #endif - + #if os(macOS) - + #endif - + #if os(iOS) || os(tvOS) || os(visionOS) - func testThatScalePreserved() throws { + @Test func thatScalePreserved() throws { // Given let processor = ImageProcessors.Resize(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFill) - + // When - let image = try XCTUnwrap(processor.process(Test.image), "Failed to process an image") - + let image = try #require(processor.process(Test.image), "Failed to process an image") + // Then - XCTAssertEqual(image.scale, Test.image.scale) + #expect(image.scale == Test.image.scale) } #endif - + + @Test + @MainActor - func testThatIdentifiersAreEqualWithSameParameters() { - XCTAssertEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).identifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).identifier + func thatIdentifiersAreEqualWithSameParameters() { + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).identifier == ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).identifier ) - XCTAssertEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), unit: .pixels).identifier, - ImageProcessors.Resize(size: CGSize(width: 30 / Screen.scale, height: 30 / Screen.scale), unit: .points).identifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), unit: .pixels).identifier == ImageProcessors.Resize(size: CGSize(width: 30 / Screen.scale, height: 30 / Screen.scale), unit: .points).identifier ) - XCTAssertEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).identifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).identifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).identifier == ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).identifier ) - XCTAssertEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).identifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).identifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).identifier == ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).identifier ) - XCTAssertEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).identifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).identifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).identifier == ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).identifier ) } - - func testThatIdentifiersAreNotEqualWithDifferentParameters() { - XCTAssertNotEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).identifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 40)).identifier + + @Test func thatIdentifiersAreNotEqualWithDifferentParameters() { + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).identifier != ImageProcessors.Resize(size: CGSize(width: 30, height: 40)).identifier ) - XCTAssertNotEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).identifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: false).identifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).identifier != ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: false).identifier ) - XCTAssertNotEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).identifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: false).identifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).identifier != ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: false).identifier ) - XCTAssertNotEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).identifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFill).identifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).identifier != ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFill).identifier ) } - + + @Test + @MainActor - func testThatHashableIdentifiersAreEqualWithSameParameters() { - XCTAssertEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).hashableIdentifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).hashableIdentifier + func thatHashableIdentifiersAreEqualWithSameParameters() { + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).hashableIdentifier == ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).hashableIdentifier ) - XCTAssertEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), unit: .pixels).hashableIdentifier, - ImageProcessors.Resize(size: CGSize(width: 30 / Screen.scale, height: 30 / Screen.scale), unit: .points).hashableIdentifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), unit: .pixels).hashableIdentifier == ImageProcessors.Resize(size: CGSize(width: 30 / Screen.scale, height: 30 / Screen.scale), unit: .points).hashableIdentifier ) - XCTAssertEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).hashableIdentifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).hashableIdentifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).hashableIdentifier == ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).hashableIdentifier ) - XCTAssertEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).hashableIdentifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).hashableIdentifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).hashableIdentifier == ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).hashableIdentifier ) - XCTAssertEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).hashableIdentifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).hashableIdentifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).hashableIdentifier == ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).hashableIdentifier ) } - - func testThatHashableIdentifiersAreNotEqualWithDifferentParameters() { - XCTAssertNotEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).hashableIdentifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 40)).hashableIdentifier + + @Test func thatHashableIdentifiersAreNotEqualWithDifferentParameters() { + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30)).hashableIdentifier != ImageProcessors.Resize(size: CGSize(width: 30, height: 40)).hashableIdentifier ) - XCTAssertNotEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).hashableIdentifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: false).hashableIdentifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: true).hashableIdentifier != ImageProcessors.Resize(size: CGSize(width: 30, height: 30), crop: false).hashableIdentifier ) - XCTAssertNotEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).hashableIdentifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: false).hashableIdentifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: true).hashableIdentifier != ImageProcessors.Resize(size: CGSize(width: 30, height: 30), upscale: false).hashableIdentifier ) - XCTAssertNotEqual( - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).hashableIdentifier, - ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFill).hashableIdentifier + #expect( + ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFit).hashableIdentifier != ImageProcessors.Resize(size: CGSize(width: 30, height: 30), contentMode: .aspectFill).hashableIdentifier ) } - - func testDescription() { + + @Test func description() { // Given let processor = ImageProcessors.Resize(size: CGSize(width: 30, height: 30), unit: .pixels, contentMode: .aspectFit) - + // Then - XCTAssertEqual(processor.description, "Resize(size: (30.0, 30.0) pixels, contentMode: .aspectFit, crop: false, upscale: false)") + #expect(processor.description == "Resize(size: (30.0, 30.0) pixels, contentMode: .aspectFit, crop: false, upscale: false)") } - + // Just make sure these initializers are still available. - func testInitailizer() { + @Test func initializer() { _ = ImageProcessors.Resize(height: 10) _ = ImageProcessors.Resize(width: 10) _ = ImageProcessors.Resize(width: 10, upscale: true) @@ -297,69 +282,65 @@ class ImageProcessorsResizeTests: XCTestCase { } } -class CoreGraphicsExtensionsTests: XCTestCase { - func testScaleToFill() { - XCTAssertEqual(1, CGSize(width: 10, height: 10).scaleToFill(CGSize(width: 10, height: 10))) - XCTAssertEqual(0.5, CGSize(width: 20, height: 20).scaleToFill(CGSize(width: 10, height: 10))) - XCTAssertEqual(2, CGSize(width: 5, height: 5).scaleToFill(CGSize(width: 10, height: 10))) - - XCTAssertEqual(1, CGSize(width: 20, height: 10).scaleToFill(CGSize(width: 10, height: 10))) - XCTAssertEqual(1, CGSize(width: 10, height: 20).scaleToFill(CGSize(width: 10, height: 10))) - XCTAssertEqual(0.5, CGSize(width: 30, height: 20).scaleToFill(CGSize(width: 10, height: 10))) - XCTAssertEqual(0.5, CGSize(width: 20, height: 30).scaleToFill(CGSize(width: 10, height: 10))) - - XCTAssertEqual(2, CGSize(width: 5, height: 10).scaleToFill(CGSize(width: 10, height: 10))) - XCTAssertEqual(2, CGSize(width: 10, height: 5).scaleToFill(CGSize(width: 10, height: 10))) - XCTAssertEqual(2, CGSize(width: 5, height: 8).scaleToFill(CGSize(width: 10, height: 10))) - XCTAssertEqual(2, CGSize(width: 8, height: 5).scaleToFill(CGSize(width: 10, height: 10))) - - XCTAssertEqual(2, CGSize(width: 30, height: 10).scaleToFill(CGSize(width: 10, height: 20))) - XCTAssertEqual(2, CGSize(width: 10, height: 30).scaleToFill(CGSize(width: 20, height: 10))) +@Suite + +struct CoreGraphicsExtensionsTests { + @Test func scaleToFill() { + #expect(1 == CGSize(width: 10, height: 10).scaleToFill(CGSize(width: 10, height: 10))) + #expect(0.5 == CGSize(width: 20, height: 20).scaleToFill(CGSize(width: 10, height: 10))) + #expect(2 == CGSize(width: 5, height: 5).scaleToFill(CGSize(width: 10, height: 10))) + + #expect(1 == CGSize(width: 20, height: 10).scaleToFill(CGSize(width: 10, height: 10))) + #expect(1 == CGSize(width: 10, height: 20).scaleToFill(CGSize(width: 10, height: 10))) + #expect(0.5 == CGSize(width: 30, height: 20).scaleToFill(CGSize(width: 10, height: 10))) + #expect(0.5 == CGSize(width: 20, height: 30).scaleToFill(CGSize(width: 10, height: 10))) + + #expect(2 == CGSize(width: 5, height: 10).scaleToFill(CGSize(width: 10, height: 10))) + #expect(2 == CGSize(width: 10, height: 5).scaleToFill(CGSize(width: 10, height: 10))) + #expect(2 == CGSize(width: 5, height: 8).scaleToFill(CGSize(width: 10, height: 10))) + #expect(2 == CGSize(width: 8, height: 5).scaleToFill(CGSize(width: 10, height: 10))) + + #expect(2 == CGSize(width: 30, height: 10).scaleToFill(CGSize(width: 10, height: 20))) + #expect(2 == CGSize(width: 10, height: 30).scaleToFill(CGSize(width: 20, height: 10))) } - - func testScaleToFit() { - XCTAssertEqual(1, CGSize(width: 10, height: 10).scaleToFit(CGSize(width: 10, height: 10))) - XCTAssertEqual(0.5, CGSize(width: 20, height: 20).scaleToFit(CGSize(width: 10, height: 10))) - XCTAssertEqual(2, CGSize(width: 5, height: 5).scaleToFit(CGSize(width: 10, height: 10))) - - XCTAssertEqual(0.5, CGSize(width: 20, height: 10).scaleToFit(CGSize(width: 10, height: 10))) - XCTAssertEqual(0.5, CGSize(width: 10, height: 20).scaleToFit(CGSize(width: 10, height: 10))) - XCTAssertEqual(0.25, CGSize(width: 40, height: 20).scaleToFit(CGSize(width: 10, height: 10))) - XCTAssertEqual(0.25, CGSize(width: 20, height: 40).scaleToFit(CGSize(width: 10, height: 10))) - - XCTAssertEqual(1, CGSize(width: 5, height: 10).scaleToFit(CGSize(width: 10, height: 10))) - XCTAssertEqual(1, CGSize(width: 10, height: 5).scaleToFit(CGSize(width: 10, height: 10))) - XCTAssertEqual(2, CGSize(width: 2, height: 5).scaleToFit(CGSize(width: 10, height: 10))) - XCTAssertEqual(2, CGSize(width: 5, height: 2).scaleToFit(CGSize(width: 10, height: 10))) - - XCTAssertEqual(0.25, CGSize(width: 40, height: 10).scaleToFit(CGSize(width: 10, height: 20))) - XCTAssertEqual(0.25, CGSize(width: 10, height: 40).scaleToFit(CGSize(width: 20, height: 10))) + + @Test func scaleToFit() { + #expect(1 == CGSize(width: 10, height: 10).scaleToFit(CGSize(width: 10, height: 10))) + #expect(0.5 == CGSize(width: 20, height: 20).scaleToFit(CGSize(width: 10, height: 10))) + #expect(2 == CGSize(width: 5, height: 5).scaleToFit(CGSize(width: 10, height: 10))) + + #expect(0.5 == CGSize(width: 20, height: 10).scaleToFit(CGSize(width: 10, height: 10))) + #expect(0.5 == CGSize(width: 10, height: 20).scaleToFit(CGSize(width: 10, height: 10))) + #expect(0.25 == CGSize(width: 40, height: 20).scaleToFit(CGSize(width: 10, height: 10))) + #expect(0.25 == CGSize(width: 20, height: 40).scaleToFit(CGSize(width: 10, height: 10))) + + #expect(1 == CGSize(width: 5, height: 10).scaleToFit(CGSize(width: 10, height: 10))) + #expect(1 == CGSize(width: 10, height: 5).scaleToFit(CGSize(width: 10, height: 10))) + #expect(2 == CGSize(width: 2, height: 5).scaleToFit(CGSize(width: 10, height: 10))) + #expect(2 == CGSize(width: 5, height: 2).scaleToFit(CGSize(width: 10, height: 10))) + + #expect(0.25 == CGSize(width: 40, height: 10).scaleToFit(CGSize(width: 10, height: 20))) + #expect(0.25 == CGSize(width: 10, height: 40).scaleToFit(CGSize(width: 20, height: 10))) } - - func testCenteredInRectWithSize() { - XCTAssertEqual( - CGSize(width: 10, height: 10).centeredInRectWithSize(CGSize(width: 10, height: 10)), - CGRect(x: 0, y: 0, width: 10, height: 10) + + @Test func centeredInRectWithSize() { + #expect( + CGSize(width: 10, height: 10).centeredInRectWithSize(CGSize(width: 10, height: 10)) == CGRect(x: 0, y: 0, width: 10, height: 10) ) - XCTAssertEqual( - CGSize(width: 20, height: 20).centeredInRectWithSize(CGSize(width: 10, height: 10)), - CGRect(x: -5, y: -5, width: 20, height: 20) + #expect( + CGSize(width: 20, height: 20).centeredInRectWithSize(CGSize(width: 10, height: 10)) == CGRect(x: -5, y: -5, width: 20, height: 20) ) - XCTAssertEqual( - CGSize(width: 20, height: 10).centeredInRectWithSize(CGSize(width: 10, height: 10)), - CGRect(x: -5, y: 0, width: 20, height: 10) + #expect( + CGSize(width: 20, height: 10).centeredInRectWithSize(CGSize(width: 10, height: 10)) == CGRect(x: -5, y: 0, width: 20, height: 10) ) - XCTAssertEqual( - CGSize(width: 10, height: 20).centeredInRectWithSize(CGSize(width: 10, height: 10)), - CGRect(x: 0, y: -5, width: 10, height: 20) + #expect( + CGSize(width: 10, height: 20).centeredInRectWithSize(CGSize(width: 10, height: 10)) == CGRect(x: 0, y: -5, width: 10, height: 20) ) - XCTAssertEqual( - CGSize(width: 10, height: 20).centeredInRectWithSize(CGSize(width: 10, height: 20)), - CGRect(x: 0, y: 0, width: 10, height: 20) + #expect( + CGSize(width: 10, height: 20).centeredInRectWithSize(CGSize(width: 10, height: 20)) == CGRect(x: 0, y: 0, width: 10, height: 20) ) - XCTAssertEqual( - CGSize(width: 10, height: 40).centeredInRectWithSize(CGSize(width: 10, height: 20)), - CGRect(x: 0, y: -10, width: 10, height: 40) + #expect( + CGSize(width: 10, height: 40).centeredInRectWithSize(CGSize(width: 10, height: 20)) == CGRect(x: 0, y: -10, width: 10, height: 40) ) } } @@ -368,7 +349,7 @@ private extension CGSize { func scaleToFill(_ targetSize: CGSize) -> CGFloat { getScale(targetSize: targetSize, contentMode: .aspectFill) } - + func scaleToFit(_ targetSize: CGSize) -> CGFloat { getScale(targetSize: targetSize, contentMode: .aspectFit) } diff --git a/Tests/NukeTests/ImageProcessorsTests/RoundedCornersTests.swift b/Tests/NukeTests/ImageProcessorsTests/RoundedCornersTests.swift index 437aafadf..9bc97fd6c 100644 --- a/Tests/NukeTests/ImageProcessorsTests/RoundedCornersTests.swift +++ b/Tests/NukeTests/ImageProcessorsTests/RoundedCornersTests.swift @@ -1,15 +1,13 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). -import XCTest -@testable import Nuke +import Testing +import Foundation -#if !os(macOS) - import UIKit -#endif +@testable import Nuke -class ImageProcessorsRoundedCornersTests: XCTestCase { +@Suite struct ImageProcessorsRoundedCornersTests { func _testThatCornerRadiusIsAdded() throws { // Given @@ -17,12 +15,12 @@ class ImageProcessorsRoundedCornersTests: XCTestCase { let processor = ImageProcessors.RoundedCorners(radius: 12, unit: .pixels) // When - let output = try XCTUnwrap(processor.process(input), "Failed to process an image") + let output = try #require(processor.process(input), "Failed to process an image") // Then let expected = Test.image(named: "s-rounded-corners.png") - XCTAssertEqualImages(output, expected) - XCTAssertEqual(output.sizeInPixels, CGSize(width: 200, height: 150)) + #expect(isEqual(output, expected)) + #expect(output.sizeInPixels == CGSize(width: 200, height: 150)) } func _testThatBorderIsAdded() throws { @@ -32,129 +30,121 @@ class ImageProcessorsRoundedCornersTests: XCTestCase { let processor = ImageProcessors.RoundedCorners(radius: 12, unit: .pixels, border: border) // When - let output = try XCTUnwrap(processor.process(input), "Failed to process an image") + let output = try #require(processor.process(input), "Failed to process an image") // Then let expected = Test.image(named: "s-rounded-corners-border.png") - XCTAssertEqualImages(output, expected) + #expect(isEqual(output, expected)) } - func testExtendedColorSpaceSupport() throws { + @Test func extendedColorSpaceSupport() throws { // Given let input = Test.image(named: "image-p3", extension: "jpg") let processor = ImageProcessors.RoundedCorners(radius: 12, unit: .pixels) // When - let output = try XCTUnwrap(processor.process(input), "Failed to process an image") + let output = try #require(processor.process(input), "Failed to process an image") // Then image is resized but isn't cropped - let colorSpace = try XCTUnwrap(output.cgImage?.colorSpace) + let colorSpace = try #require(output.cgImage?.colorSpace) #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) - XCTAssertTrue(colorSpace.isWideGamutRGB) + #expect(colorSpace.isWideGamutRGB) #elseif os(watchOS) - XCTAssertFalse(colorSpace.isWideGamutRGB) + #expect(!colorSpace.isWideGamutRGB) #endif } + @Test + @MainActor - func testEqualIdentifiers() { - XCTAssertEqual( - ImageProcessors.RoundedCorners(radius: 16).identifier, - ImageProcessors.RoundedCorners(radius: 16).identifier + func equalIdentifiers() { + #expect( + ImageProcessors.RoundedCorners(radius: 16).identifier == ImageProcessors.RoundedCorners(radius: 16).identifier ) - XCTAssertEqual( - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels).identifier, - ImageProcessors.RoundedCorners(radius: 16 / Screen.scale, unit: .points).identifier + #expect( + ImageProcessors.RoundedCorners(radius: 16, unit: .pixels).identifier == ImageProcessors.RoundedCorners(radius: 16 / Screen.scale, unit: .points).identifier ) - XCTAssertEqual( - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).identifier, - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).identifier + #expect( + ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).identifier == ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).identifier ) } + @Test + @MainActor - func testNotEqualIdentifiers() { - XCTAssertNotEqual( - ImageProcessors.RoundedCorners(radius: 16).identifier, - ImageProcessors.RoundedCorners(radius: 8).identifier + func notEqualIdentifiers() { + #expect( + ImageProcessors.RoundedCorners(radius: 16).identifier != ImageProcessors.RoundedCorners(radius: 8).identifier ) if Screen.scale == 1 { - XCTAssertEqual( - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).identifier, - ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).identifier + #expect( + ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).identifier == ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).identifier ) - XCTAssertNotEqual( - ImageProcessors.RoundedCorners(radius: 32, unit: .pixels, border: .init(color: .red)).identifier, - ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).identifier + #expect( + ImageProcessors.RoundedCorners(radius: 32, unit: .pixels, border: .init(color: .red)).identifier != ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).identifier ) } else { - XCTAssertNotEqual( - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).identifier, - ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).identifier + #expect( + ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).identifier != ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).identifier ) } - XCTAssertNotEqual( - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).identifier, - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .blue)).identifier + #expect( + ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).identifier != ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .blue)).identifier ) } + @Test + @MainActor - func testEqualHashableIdentifiers() { - XCTAssertEqual( - ImageProcessors.RoundedCorners(radius: 16).hashableIdentifier, - ImageProcessors.RoundedCorners(radius: 16).hashableIdentifier + func equalHashableIdentifiers() { + #expect( + ImageProcessors.RoundedCorners(radius: 16).hashableIdentifier == ImageProcessors.RoundedCorners(radius: 16).hashableIdentifier ) - XCTAssertEqual( - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels).hashableIdentifier, - ImageProcessors.RoundedCorners(radius: 16 / Screen.scale, unit: .points).hashableIdentifier + #expect( + ImageProcessors.RoundedCorners(radius: 16, unit: .pixels).hashableIdentifier == ImageProcessors.RoundedCorners(radius: 16 / Screen.scale, unit: .points).hashableIdentifier ) - XCTAssertEqual( - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).hashableIdentifier, - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).hashableIdentifier + #expect( + ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).hashableIdentifier == ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).hashableIdentifier ) } + @Test + @MainActor - func testNotEqualHashableIdentifiers() { - XCTAssertNotEqual( - ImageProcessors.RoundedCorners(radius: 16).hashableIdentifier, - ImageProcessors.RoundedCorners(radius: 8).hashableIdentifier + func notEqualHashableIdentifiers() { + #expect( + ImageProcessors.RoundedCorners(radius: 16).hashableIdentifier != ImageProcessors.RoundedCorners(radius: 8).hashableIdentifier ) if Screen.scale == 1 { - XCTAssertEqual( - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).hashableIdentifier, - ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).hashableIdentifier + #expect( + ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).hashableIdentifier == ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).hashableIdentifier ) - XCTAssertNotEqual( - ImageProcessors.RoundedCorners(radius: 32, unit: .pixels, border: .init(color: .red)).hashableIdentifier, - ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).hashableIdentifier + #expect( + ImageProcessors.RoundedCorners(radius: 32, unit: .pixels, border: .init(color: .red)).hashableIdentifier != ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).hashableIdentifier ) } else { - XCTAssertNotEqual( - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).hashableIdentifier, - ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).hashableIdentifier + #expect( + ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).hashableIdentifier != ImageProcessors.RoundedCorners(radius: 16, unit: .points, border: .init(color: .red)).hashableIdentifier ) } - XCTAssertNotEqual( - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).hashableIdentifier, - ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .blue)).hashableIdentifier + #expect( + ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red)).hashableIdentifier != ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .blue)).hashableIdentifier ) } - func testDescription() { + @Test func description() { // Given let processor = ImageProcessors.RoundedCorners(radius: 16, unit: .pixels) // Then - XCTAssertEqual(processor.description, "RoundedCorners(radius: 16.0 pixels, border: nil)") + #expect(processor.description == "RoundedCorners(radius: 16.0 pixels, border: nil)") } - func testDescriptionWithBorder() { + @Test func descriptionWithBorder() { // Given let processor = ImageProcessors.RoundedCorners(radius: 16, unit: .pixels, border: .init(color: .red, width: 2, unit: .pixels)) // Then - XCTAssertEqual(processor.description, "RoundedCorners(radius: 16.0 pixels, border: Border(color: #FF0000, width: 2.0 pixels))") + #expect(processor.description == "RoundedCorners(radius: 16.0 pixels, border: Border(color: #FF0000, width: 2.0 pixels))") } } diff --git a/Tests/NukeTests/ImageRequestTests.swift b/Tests/NukeTests/ImageRequestTests.swift index 48e65302c..0611dd124 100644 --- a/Tests/NukeTests/ImageRequestTests.swift +++ b/Tests/NukeTests/ImageRequestTests.swift @@ -1,13 +1,14 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 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,185 +16,185 @@ 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() { - // GIVEN + @Test func copyOnWrite() { + // Given var request = ImageRequest(url: URL(string: "http://test.com/1.png")) request.options.insert(.disableMemoryCacheReads) request.userInfo["key"] = "3" request.processors = [MockImageProcessor(id: "4")] request.priority = .high - // WHEN + // When var copy = request // Request makes a copy at this point under the hood. 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) + // Then + #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() { - // WHEN + @Test func userInfoKey() { + // When let request = ImageRequest(url: Test.url, userInfo: [.init("a"): 1]) - // THEN - XCTAssertNotNil(request.userInfo["a"]) + // Then + #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/JobQueueTests.swift b/Tests/NukeTests/JobQueueTests.swift new file mode 100644 index 000000000..9bbfa942c --- /dev/null +++ b/Tests/NukeTests/JobQueueTests.swift @@ -0,0 +1,243 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation +@testable import Nuke + +@Suite @ImagePipelineActor struct JobQueueTests { + let queue = JobQueue(maxConcurrentJobCount: 1) + + // MARK: Basics + + // Make sure that you submit N tasks where N is greater than `maxConcurrentJobCount`, + // all tasks get executed. + @Test func basics() async { + await confirmation(expectedCount: 4) { confirmation in + await withTaskGroup(of: Void.self) { group in + for _ in Array(0..<4) { + group.addTask { @Sendable @ImagePipelineActor in + await withUnsafeContinuation { continuation in + let job = TestJob { + try? await Task.sleep(nanoseconds: 100) + } + job.queue = queue + job.subscribe { event in + confirmation() + continuation.resume() + } + } + } + } + } + } + } + + @Test func executionOrder() async { + // When + queue.isSuspended = true + + var completed: [Int] = [] + + queue.add({ completed.append(1) }).subscribe { _ in } + queue.add({ completed.append(2) }).subscribe { _ in } + queue.add({ completed.append(3) }).subscribe { _ in } + + queue.isSuspended = false + + // Then items are executed in the order they were added (FIFO) + await queue.wait() + #expect(completed == [1, 2, 3]) + } + + // MARK: Cancellation + + @Test func cancelPendingWork() async { + queue.isSuspended = true + + var isFirstTaskExecuted = false + let job = queue.add { + isFirstTaskExecuted = true + } + + job.simulateCancel() + + #expect(!isFirstTaskExecuted) + + queue.isSuspended = false + + await confirmation { confirmation in + await withUnsafeContinuation { continuation in + queue.add { + confirmation() + continuation.resume() + }.subscribe({ _ in }) + } + } + } + + @Test func cancelInFlightWork() async { + @ImagePipelineActor final class Context { + var continuation: UnsafeContinuation? + var subscription: JobSubscription? + } + let context = Context() + context.subscription = queue.add { + await withTaskCancellationHandler { + await withUnsafeContinuation { + context.continuation = $0 + Task { @ImagePipelineActor in + #expect(context.subscription != nil) + context.subscription?.unsubscribe() + } + } + } onCancel: { + Task { @ImagePipelineActor in + context.continuation?.resume() + } + } + }.subscribe({ _ in })?.subscription + } + + // MARK: Priority + + @Test func executionBasedOnPriority() async { + queue.isSuspended = true + + var completed: [Int] = [] + + queue.add { + completed.append(1) + }.subscribe(priority: .low) { _ in } + + queue.add { + completed.append(2) + }.subscribe(priority: .high, { _ in }) + + queue.add { + completed.append(3) + }.subscribe(priority: .normal, { _ in }) + + queue.isSuspended = false + + await queue.wait() + + #expect(completed == [2, 3, 1]) + } + + @Test func changePriorityOfScheduldItem() async { + // Given a queue with priorities [2, 3, 1] + queue.isSuspended = true + + var completed: [Int] = [] + + let subscriber1 = queue.add { + completed.append(1) + }.subscribe(priority: .low) { _ in } + + queue.add { + completed.append(2) + }.subscribe(priority: .high, { _ in }) + + queue.add { + completed.append(3) + }.subscribe(priority: .normal, { _ in }) + + // When item with .low priority (1) changes priority to .high (raising) + subscriber1?.setPriority(.high) + + // Then item 1 is prepended to high priority queue and executes after item 2 + queue.isSuspended = false + await queue.wait() + #expect(completed == [2, 1, 3]) + } + + @Test func loweringPriorityAppendsToQueue() async { + // Given a queue with high priority items [1, 2] and normal priority [3] + queue.isSuspended = true + + var completed: [Int] = [] + + queue.add { + completed.append(1) + }.subscribe(priority: .high) { _ in } + + queue.add { + completed.append(2) + }.subscribe(priority: .normal) { _ in } + + let subscriber3 = queue.add { + completed.append(3) + }.subscribe(priority: .high) { _ in } + + // When item 3's priority is lowered from .high to .normal + subscriber3?.setPriority(.normal) + + // Then item 3 is prepended to normal priority queue since it + // started with a higher priority + queue.isSuspended = false + await queue.wait() + #expect(completed == [1, 3, 2]) + } +} + +extension JobQueue { + @discardableResult + func add(_ closure: @ImagePipelineActor @Sendable @escaping () async throws(ImageTask.Error) -> Value) -> TestJob { + let job = TestJob(closure: closure) + job.queue = self + return job + } + + func wait() async { + var count = executingJobs.count + scheduledJobs.map(\.count).reduce(0, +) + guard count > 0 else { + return + } + let expectation = AsyncExpectation() + onEvent = { + if case .disposed = $0 { + count -= 1 + if count == 0 { + expectation.fulfill() + } + } + } + return await expectation.wait() + } +} + +extension Job { + func simulateCancel() { + subscribe({ _ in })?.unsubscribe() + } +} + +final class TestJob: Job { + let closure: @ImagePipelineActor @Sendable () async throws(ImageTask.Error) -> Value + + private var task: Task? + + /// Initialize the task with the given closure to be executed in the background. + init(closure: @ImagePipelineActor @Sendable @escaping () async throws(ImageTask.Error) -> Value) { + self.closure = closure + super.init() + } + + override func start() { + task = Task { + do { + let value = try await self.closure() + self.send(value: value, isCompleted: true) + } catch { + // swiftlint:disable:next force_cast + self.send(error: error as! ImageTask.Error) + } + } + } + + override func onCancel() { + task?.cancel() + } +} diff --git a/Tests/NukeTests/JobTests.swift b/Tests/NukeTests/JobTests.swift new file mode 100644 index 000000000..a53781a04 --- /dev/null +++ b/Tests/NukeTests/JobTests.swift @@ -0,0 +1,515 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation +@testable import Nuke + +@ImagePipelineActor +@Suite struct JobTests { + var queue = JobQueue() + + init() { + queue.isSuspended = true + } + + // MARK: - Starter + + @Test func starterCalledOnFirstSubscription() { + // Given + var startCount = 0 + _ = SimpleJob(starter: { _ in + startCount += 1 + }) + + // Then + #expect(startCount == 0) + } + + @Test func starterCalledWhenSubscriptionIsAdded() { + // Given + var startCount = 0 + let job = SimpleJob(starter: { _ in + startCount += 1 + }) + + // When first subscription is added + _ = job.subscribe { _ in } + + // Then started is called + #expect(startCount == 1) + } + + @Test func starterOnlyCalledOnce() { + // Given + var startCount = 0 + let job = SimpleJob(starter: { _ in + startCount += 1 + }) + + // When two subscriptions are added + _ = job.subscribe { _ in } + _ = job.subscribe { _ in } + + // Then started is only called once + #expect(startCount == 1) + } + + @Test func starterIsDeallocated() { + // Given + class Foo { + } + + weak var weakFoo: Foo? + + let job: Job = autoreleasepool { // Just in case + let foo = Foo() + weakFoo = foo + return SimpleJob(starter: { _ in + _ = foo // Retain foo + }) + } + + #expect(weakFoo != nil, "Foo is retained by starter") + + // When first subscription is added and starter is called + _ = job.subscribe { _ in } + + // Then + #expect(weakFoo == nil, "Started wasn't deallocated") + } + + // MARK: - Subscribe + + @Test func whenSubscriptionAddedEventsAreForwarded() { + // Given + let job = SimpleJob(starter: { + $0.send(progress: JobProgress(completed: 1, total: 2)) + $0.send(value: 1) + $0.send(progress: JobProgress(completed: 2, total: 2)) + $0.send(value: 2, isCompleted: true) + }) + + // When + var recordedEvents = [Job.Event]() + _ = job.subscribe { event in + recordedEvents.append(event) + } + + // Then + #expect(recordedEvents == [ + .progress(JobProgress(completed: 1, total: 2)), + .value(1, isCompleted: false), + .progress(JobProgress(completed: 2, total: 2)), + .value(2, isCompleted: true) + ]) + } + + @Test func bothSubscriptionsReceiveEvents() { + // Given + let job = Job() + + // When there are two subscriptions + var eventCount = 0 + + _ = job.subscribe { event in + #expect(event == .value(1, isCompleted: false)) + eventCount += 1 } + _ = job.subscribe { event in + #expect(event == .value(1, isCompleted: false)) + eventCount += 1 + } + + job.send(value: 1) + + // Then + #expect(eventCount == 2) + } + + @Test func cantSubscribeToAlreadyCancelledTask() { + // Given + let job = SimpleJob(starter: { _ in }) + let subscription = job.subscribe { _ in } + + // When + subscription?.unsubscribe() + + // Then + #expect(job.subscribe { _ in } == nil) + } + + @Test func cantSubscribeToAlreadySucceededTask() { + // Given + let job = Job() + _ = job.subscribe { _ in } + + // When + job.send(value: 1, isCompleted: true) + + // Then + #expect(job.subscribe { _ in } == nil) + } + + @Test func cantSubscribeToAlreadyFailedTasks() { + // Given + let job = Job() + _ = job.subscribe { _ in } + + // When + job.send(error: .dataIsEmpty) + + // Then + #expect(job.subscribe { _ in } == nil) + } + + @Test func subscribeToTaskWithSynchronousCompletionReturnsNil() async { + // Given + let job = SimpleJob { job in + job.send(value: 0, isCompleted: true) + } + + // When/Then + await withUnsafeContinuation { continuation in + let subscription = job.subscribe { _ in + continuation.resume() + } + #expect(subscription == nil) + } + } + + // MARK: - Ubsubscribe + + @Test func whenSubscriptionIsRemovedNoEventsAreSent() { + // Given + let job = Job() + var recordedEvents = [Job.Event]() + let subscription = job.subscribe { recordedEvents.append($0) } + + // When + subscription?.unsubscribe() + job.send(value: 1) + + // Then + #expect(recordedEvents.isEmpty, "Expect no events to be received by observer after subscription is removed") + } + + @Test func whenSubscriptionIsRemovedTaskBecomesDisposed() { + // Given + let job = Job() + let subscription = job.subscribe { _ in } + + // When + subscription?.unsubscribe() + + // Then + #expect(job.isDisposed, "Expect job to be marked as disposed") + } + + // TODO: reimplement these tests + +// @Test func whenSubscriptionIsRemovedOperationIsCancelled() async { +// // When +// let operation = queue.add {} +// let job = SimpleJob(starter: { $0.operation = operation }) +// let subscription = job.subscribe { _ in } +// +// // When +// let expectation = queue.expectJobCancelled(operation) +// subscription?.unsubscribe() +// +// // Then +// await expectation.wait() +// } +// +// @Test func whenSubscriptionIsRemovedDependencyIsCancelled() async { +// // Given +// let operation = queue.add {} +// let dependency = SimpleJob(starter: { $0.operation = operation }) +// let job = SimpleJob(starter: { +// $0.dependency = dependency.subscribe { _ in }?.subscription +// }) +// let subscription = job.subscribe { _ in } +// +// // When +// let expectation = queue.expectJobCancelled(operation) +// subscription?.unsubscribe() +// +// // Then +// await expectation.wait() +// } +// +// @Test func whenOneOfTwoSubscriptionsAreRemovedTaskNotCancelled() async { +// // Given +// let compleded = AsyncExpectation() +// let operation = queue.add { +// compleded.fulfill() +// } +// let job = SimpleJob(starter: { $0.operation = operation }) +// let subscription1 = job.subscribe { _ in } +// _ = job.subscribe { _ in } +// +// // When +// subscription1?.unsubscribe() +// Task { @ImagePipelineActor in +// queue.isSuspended = false +// } +// +// // Then +// await compleded.wait() +// } +// +// @Test func whenTwoOfTwoSubscriptionsAreRemovedTaskIsCancelled() async { +// // Given +// let operation = queue.add {} +// let job = SimpleJob(starter: { $0.operation = operation }) +// let subscription1 = job.subscribe { _ in } +// let subscription2 = job.subscribe { _ in } +// +// // When +// let expectation = queue.expectJobCancelled(operation) +// subscription1?.unsubscribe() +// subscription2?.unsubscribe() +// +// // Then +// await expectation.wait() +// } +// +// // MARK: - Priority +// +// @Test func whenPriorityIsUpdatedOperationPriorityAlsoUpdated() async { +// // Given +// let operation = queue.add {} +// let job = SimpleJob(starter: { $0.operation = operation }) +// let subscription = job.subscribe { _ in } +// +// // When +// let expecation = queue.expectPriorityUpdated(for: operation) +// subscription?.setPriority(.high) +// +// // Then +// let priority = await expecation.value +// #expect(priority == .high) +// } +// +// @Test func priorityCanBeLowered() async { +// // Given +// let operation = queue.add {} +// let job = SimpleJob(starter: { $0.operation = operation }) +// let subscription = job.subscribe { _ in } +// +// // When +// let expecation = queue.expectPriorityUpdated(for: operation) +// subscription?.setPriority(.low) +// +// // Then +// let priority = await expecation.value +// #expect(priority == .low) +// } +// +// @Test func priorityEqualMaximumPriorityOfAllSubscriptions() async { +// // Given +// let operation = queue.add {} +// let job = SimpleJob(starter: { $0.operation = operation }) +// let subscription1 = job.subscribe { _ in } +// let subscription2 = job.subscribe { _ in } +// +// // When +// let expecation = queue.expectPriorityUpdated(for: operation) +// subscription1?.setPriority(.low) +// subscription2?.setPriority(.high) +// +// // Then +// #expect(await expecation.value == .high) +// } +// +// @Test func subscriptionIsRemovedPriorityIsUpdated() async { +// // Given +// let operation = queue.add {} +// let job = SimpleJob(starter: { $0.operation = operation }) +// let subscription1 = job.subscribe { _ in } +// let subscription2 = job.subscribe { _ in } +// +// subscription1?.setPriority(.low) +// subscription2?.setPriority(.high) +// +// // When +// let expecation = queue.expectPriorityUpdated(for: operation) +// subscription2?.unsubscribe() +// +// // Then +// #expect(await expecation.value == .low) +// } +// +// @Test func whenSubscriptionLowersPriorityButExistingSubscriptionHasHigherPriporty() async { +// // Given +// let operation = queue.add {} +// let job = SimpleJob(starter: { $0.operation = operation }) +// let subscription1 = job.subscribe { _ in } +// let subscription2 = job.subscribe { _ in } +// +// // When +// let expecation = queue.expectPriorityUpdated(for: operation) +// subscription2?.setPriority(.high) +// subscription1?.setPriority(.low) +// +// // Then order of updating sub +// #expect(await expecation.value == .high) +// } +// +// @Test func priorityOfDependencyUpdated() async { +// // Given +// let operation = queue.add {} +// let dependency = SimpleJob(starter: { $0.operation = operation }) +// let job = SimpleJob(starter: { +// $0.dependency = dependency.subscribe { _ in }?.subscription +// }) +// let subscription = job.subscribe { _ in } +// +// // When +// let expecation = queue.expectPriorityUpdated(for: operation) +// subscription?.setPriority(.high) +// +// // Then +// #expect(await expecation.value == .high) +// } + + // MARK: - Dispose + + @Test func executingTaskIsntDisposed() { + // Given + let job = Job() + var isDisposeCalled = false + job.onDisposed = { isDisposeCalled = true } + _ = job.subscribe { _ in } + + // When + job.send(value: 1) // Casually sending value + + // Then + #expect(!isDisposeCalled) + #expect(!job.isDisposed) + } + + @Test func taskIsDisposedWhenCancelled() { + // Given + let job = SimpleJob(starter: { _ in }) + var isDisposeCalled = false + job.onDisposed = { isDisposeCalled = true } + let subscription = job.subscribe { _ in } + + // When + subscription?.unsubscribe() + + // Then + #expect(isDisposeCalled) + #expect(job.isDisposed) + } + + @Test func taskIsDisposedWhenCompletedWithSuccess() { + // Given + let job = Job() + var isDisposeCalled = false + job.onDisposed = { isDisposeCalled = true } + _ = job.subscribe { _ in } + + // When + job.send(value: 1, isCompleted: true) + + // Then + #expect(isDisposeCalled) + #expect(job.isDisposed) + } + + @Test func taskIsDisposedWhenCompletedWithFailure() { + // Given + let job = Job() + var isDisposeCalled = false + job.onDisposed = { isDisposeCalled = true } + _ = job.subscribe { _ in } + + // When + job.send(error: .cancelled) + + // Then + #expect(isDisposeCalled) + #expect(job.isDisposed) + } +} + +// MARK: - Helpers + +private final class SimpleJob: Job, @unchecked Sendable { + private var starter: ((SimpleJob) -> Void)? + + /// Initializes the job with the `starter`. + /// - parameter starter: The closure which gets called as soon as the first + /// subscription is added to the job. Only gets called once and is immediately + /// deallocated after it is called. + init(starter: ((SimpleJob) -> Void)? = nil) { + self.starter = starter + super.init() + } + + override func start() { + starter?(self) + starter = nil + } +} + +extension Job { + @discardableResult + func subscribe(priority: JobPriority = .normal, _ closure: @ImagePipelineActor @Sendable @escaping (Event) -> Void) -> JobSubscriptionHandle? { + let subscriber = AnonymousJobSubscriber(closure: closure) + subscriber.priority = priority + guard let subcription = subscribe(subscriber) else { + return nil + } + return JobSubscriptionHandle(subscriber: subscriber, subscription: subcription) + } +} + +/// For convenience. +@ImagePipelineActor +struct JobSubscriptionHandle { + let subscriber: AnonymousJobSubscriber + let subscription: JobSubscription + + func setPriority(_ priority: JobPriority) { + subscriber.priority = priority + subscription.didChangePriority(priority) + } + + func unsubscribe() { + subscription.unsubscribe() + } +} + +final class AnonymousJobSubscriber: JobSubscriber, Sendable { + var priority: JobPriority = .normal + + let closure: @ImagePipelineActor @Sendable (Job.Event) -> Void + + init(closure: @ImagePipelineActor @Sendable @escaping (Job.Event) -> Void) { + self.closure = closure + } + + func receive(_ event: Job.Event) { + closure(event) + } + + func addSubscribedTasks(to output: inout [ImageTask]) { + // Do nothing + } +} + +extension Job.Event: @retroactive Equatable where Value: Equatable { + public static func == (lhs: Job.Event, rhs: Job.Event) -> Bool { + switch (lhs, rhs) { + case (let .value(lhs0, lhs1), let .value(rhs0, rhs1)): (lhs0, lhs1) == (rhs0, rhs1) + case (let .progress(lhs), let .progress(rhs)): lhs == rhs + case (let .error(lhs), let .error(rhs)): lhs == rhs + default: false + } + } +} diff --git a/Tests/NukeTests/LinkedListTest.swift b/Tests/NukeTests/LinkedListTest.swift index 48cc4dd63..4587f5446 100644 --- a/Tests/NukeTests/LinkedListTest.swift +++ b/Tests/NukeTests/LinkedListTest.swift @@ -1,44 +1,48 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation -import XCTest @testable import Nuke -class LinkedListTests: XCTestCase { +@Suite struct LinkedListTests { let list = LinkedList() - func testEmptyWhenCreated() { - XCTAssertNil(list.first) - XCTAssertNil(list.last) - XCTAssertTrue(list.isEmpty) + @Test func emptyWhenCreated() { + #expect(list.first == nil) + #expect(list.last == nil) + #expect(list.isEmpty) } // MARK: - Append - func testAppendOnce() { + @Test func appendOnce() { // When list.append(1) // Then - XCTAssertFalse(list.isEmpty) - XCTAssertEqual(list.first?.value, 1) - XCTAssertEqual(list.last?.value, 1) + #expect(list.isEmpty == false) + #expect(list.first?.value == 1) + #expect(list.last?.value == 1) + #expect(list.count == 1) } - func testAppendTwice() { + @Test func appendTwice() { // When list.append(1) list.append(2) // Then - XCTAssertEqual(list.first?.value, 1) - XCTAssertEqual(list.last?.value, 2) + #expect(list.first?.value == 1) + #expect(list.last?.value == 2) + #expect(list.count == 2) } // MARK: - Remove - func testRemoveSingle() { + @Test func removeSingle() { // Given let node = list.append(1) @@ -46,11 +50,12 @@ class LinkedListTests: XCTestCase { list.remove(node) // Then - XCTAssertNil(list.first) - XCTAssertNil(list.last) + #expect(list.first == nil) + #expect(list.last == nil) + #expect(list.count == 0) } - func testRemoveFromBeggining() { + @Test func removeFromBeggining() { // Given let node = list.append(1) list.append(2) @@ -60,11 +65,12 @@ class LinkedListTests: XCTestCase { list.remove(node) // Then - XCTAssertEqual(list.first?.value, 2) - XCTAssertEqual(list.last?.value, 3) + #expect(list.first?.value == 2) + #expect(list.last?.value == 3) + #expect(list.count == 2) } - func testRemoveFromEnd() { + @Test func removeFromEnd() { // Given list.append(1) list.append(2) @@ -74,11 +80,12 @@ class LinkedListTests: XCTestCase { list.remove(node) // Then - XCTAssertEqual(list.first?.value, 1) - XCTAssertEqual(list.last?.value, 2) + #expect(list.first?.value == 1) + #expect(list.last?.value == 2) + #expect(list.count == 2) } - func testRemoveFromMiddle() { + @Test func removeFromMiddle() { // Given list.append(1) let node = list.append(2) @@ -88,11 +95,12 @@ class LinkedListTests: XCTestCase { list.remove(node) // Then - XCTAssertEqual(list.first?.value, 1) - XCTAssertEqual(list.last?.value, 3) + #expect(list.first?.value == 1) + #expect(list.last?.value == 3) + #expect(list.count == 2) } - func testRemoveAll() { + @Test func removeAll() { // Given list.append(1) list.append(2) @@ -102,7 +110,8 @@ class LinkedListTests: XCTestCase { list.removeAllElements() // Then - XCTAssertNil(list.first) - XCTAssertNil(list.last) + #expect(list.first == nil) + #expect(list.last == nil) + #expect(list.count == 0) } } diff --git a/Tests/NukeTests/OperationTests.swift b/Tests/NukeTests/OperationTests.swift new file mode 100644 index 000000000..3e10c1899 --- /dev/null +++ b/Tests/NukeTests/OperationTests.swift @@ -0,0 +1,53 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import Foundation +@testable import Nuke + +@Suite @ImagePipelineActor struct OperationTests { + let queue = JobQueue(maxConcurrentJobCount: 1) + + @Test func basics() async { + // Given + let operation = Nuke.Operation { .success(42) } + + // When + let expectation = AsyncExpectation() + operation.receive { + switch $0 { + case .success(let value): + expectation.fulfill(with: value) + case .failure: + Issue.record() + } + } + queue.enqueue(operation) + + // Then + let value = await expectation.value + #expect(value == 42) + } + + @Test func priority() async { + // Given + let operation = Nuke.Operation { .success(42) } + let owner = MockJobOwner() + owner.priority = .high + + // When + operation.receive(owner) { _ in } + + // Then + #expect(operation.priority == .high) + } +} + +final class MockJobOwner: JobOwner { + var priority: JobPriority = .normal + + func addSubscribedTasks(to output: inout [ImageTask]) { + // Do nothing + } +} diff --git a/Tests/NukeTests/RateLimiterTests.swift b/Tests/NukeTests/RateLimiterTests.swift index 6a65c8734..e616607a4 100644 --- a/Tests/NukeTests/RateLimiterTests.swift +++ b/Tests/NukeTests/RateLimiterTests.swift @@ -1,107 +1,47 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 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 - startCount += 1 - }) - - // Then - XCTAssertEqual(startCount, 0) - } - - func testStarterCalledWhenSubscriptionIsAdded() { - // Given - var startCount = 0 - let task = SimpleTask(starter: { _ in - startCount += 1 - }) - - // When first subscription is added - _ = task.subscribe { _ in } - - // Then started is called - XCTAssertEqual(startCount, 1) - } - - func testStarterOnlyCalledOnce() { - // Given - var startCount = 0 - let task = SimpleTask(starter: { _ in - startCount += 1 - }) - - // When two subscriptions are added - _ = task.subscribe { _ in } - _ = task.subscribe { _ in } - - // Then started is only called once - XCTAssertEqual(startCount, 1) - } - - func testStarterIsDeallocated() { - // Given - class Foo { - } - - weak var weakFoo: Foo? - - let task: AsyncTask = autoreleasepool { // Just in case - let foo = Foo() - weakFoo = foo - return SimpleTask(starter: { _ in - _ = foo // Retain foo - }) - } - - XCTAssertNotNil(weakFoo, "Foo is retained by starter") - - // When first subscription is added and starter is called - _ = task.subscribe { _ in } - - // Then - XCTAssertNil(weakFoo, "Started wasn't deallocated") - } - - // MARK: - Subscribe - - func testWhenSubscriptionAddedEventsAreForwarded() { - // Given - let task = SimpleTask(starter: { - $0.send(progress: TaskProgress(completed: 1, total: 2)) - $0.send(value: 1) - $0.send(progress: TaskProgress(completed: 2, total: 2)) - $0.send(value: 2, isCompleted: true) - }) - - // When - var recordedEvents = [AsyncTask.Event]() - _ = task.subscribe { event in - recordedEvents.append(event) - } - - // Then - XCTAssertEqual(recordedEvents, [ - .progress(TaskProgress(completed: 1, total: 2)), - .value(1, isCompleted: false), - .progress(TaskProgress(completed: 2, total: 2)), - .value(2, isCompleted: true) - ]) - } - - func testBothSubscriptionsReceiveEvents() { - // Given - let task = AsyncTask() - - // When there are two subscriptions - var eventCount = 0 - - _ = task.subscribe { event in - XCTAssertEqual(event, .value(1, isCompleted: false)) - eventCount += 1 } - _ = task.subscribe { event in - XCTAssertEqual(event, .value(1, isCompleted: false)) - eventCount += 1 - } - - task.send(value: 1) - - // Then - XCTAssertEqual(eventCount, 2) - } - - func testCantSubscribeToAlreadyCancelledTask() { - // Given - let task = SimpleTask(starter: { _ in }) - let subscription = task.subscribe { _ in } - - // When - subscription?.unsubscribe() - - // Then - XCTAssertNil(task.subscribe { _ in }) - } - - func testCantSubscribeToAlreadySucceededTask() { - // Given - let task = AsyncTask() - _ = task.subscribe { _ in } - - // When - task.send(value: 1, isCompleted: true) - - // Then - XCTAssertNil(task.subscribe { _ in }) - } - - func testCantSubscribeToAlreadyFailedTasks() { - // Given - let task = AsyncTask() - _ = task.subscribe { _ in } - - // When - task.send(error: .init(raw: "1")) - - // Then - XCTAssertNil(task.subscribe { _ in }) - } - - func testSubscribeToTaskWithSynchronousCompletionReturnsNil() { - // 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() - } - - // Then - XCTAssertNil(subscription) - wait() - } - - // MARK: - Ubsubscribe - - func testWhenSubscriptionIsRemovedNoEventsAreSent() { - // Given - let task = AsyncTask() - var recordedEvents = [AsyncTask.Event]() - let subscription = task.subscribe { recordedEvents.append($0) } - - // When - subscription?.unsubscribe() - task.send(value: 1) - - // Then - XCTAssertTrue(recordedEvents.isEmpty, "Expect no events to be received by observer after subscription is removed") - } - - func testWhenSubscriptionIsRemovedTaskBecomesDisposed() { - // Given - let task = AsyncTask() - let subscription = task.subscribe { _ in } - - // When - subscription?.unsubscribe() - - // Then - XCTAssertTrue(task.isDisposed, "Expect task to be marked as disposed") - } - - func testWhenSubscriptionIsRemovedOnCancelIsCalled() { - // Given - let task = AsyncTask() - let subscription = task.subscribe { _ in } - - var onCancelledIsCalled = false - task.onCancelled = { - onCancelledIsCalled = true - } - - // When - subscription?.unsubscribe() - - // Then - XCTAssertTrue(onCancelledIsCalled) - } - - func testWhenSubscriptionIsRemovedOperationIsCancelled() { - // Given - let operation = Foundation.Operation() - let task = SimpleTask(starter: { $0.operation = operation }) - let subscription = task.subscribe { _ in } - XCTAssertFalse(operation.isCancelled) - - // When - subscription?.unsubscribe() - - // Then - XCTAssertTrue(operation.isCancelled) - } - - func testWhenSubscriptionIsRemovedDependencyIsCancelled() { - // 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) - - // When - subscription?.unsubscribe() - - // Then - XCTAssertTrue(operation.isCancelled) - } - - func testWhenOneOfTwoSubscriptionsAreRemovedTaskNotCancelled() { - // Given - let operation = Foundation.Operation() - let task = SimpleTask(starter: { $0.operation = operation }) - let subscription1 = task.subscribe { _ in } - _ = task.subscribe { _ in } - - // When - subscription1?.unsubscribe() - - // Then - XCTAssertFalse(operation.isCancelled) - } - - func testWhenTwoOfTwoSubscriptionsAreRemovedTaskIsCancelled() { - // Given - let operation = Foundation.Operation() - let task = SimpleTask(starter: { $0.operation = operation }) - let subscription1 = task.subscribe { _ in } - let subscription2 = task.subscribe { _ in } - - // When - subscription1?.unsubscribe() - subscription2?.unsubscribe() - - // Then - XCTAssertTrue(operation.isCancelled) - } - - // MARK: - Priority - - func testWhenPriorityIsUpdatedOperationPriorityAlsoUpdated() { - // Given - let operation = Foundation.Operation() - let task = SimpleTask(starter: { $0.operation = operation }) - let subscription = task.subscribe { _ in } - - // When - subscription?.setPriority(.high) - - // Then - XCTAssertEqual(operation.queuePriority, .high) - } - - func testWhenTaskChangesOperationPriorityUpdated() { // Or sets operation later - // Given - let task = AsyncTask() - let subscription = task.subscribe { _ in } - - // When - subscription?.setPriority(.high) - let operation = Foundation.Operation() - task.operation = operation - - // Then - XCTAssertEqual(operation.queuePriority, .high) - } - - func testThatPriorityCanBeLowered() { - // Given - let operation = Foundation.Operation() - let task = SimpleTask(starter: { $0.operation = operation }) - let subscription = task.subscribe { _ in } - - // When - subscription?.setPriority(.low) - - // Then - XCTAssertEqual(operation.queuePriority, .low) - } - - func testThatPriorityEqualMaximumPriorityOfAllSubscriptions() { - // Given - let operation = Foundation.Operation() - let task = SimpleTask(starter: { $0.operation = operation }) - let subscription1 = task.subscribe { _ in } - let subscription2 = task.subscribe { _ in } - - // When - subscription1?.setPriority(.low) - subscription2?.setPriority(.high) - - // Then - XCTAssertEqual(operation.queuePriority, .high) - } - - func testWhenSubscriptionIsRemovedPriorityIsUpdated() { - // Given - let operation = Foundation.Operation() - let task = SimpleTask(starter: { $0.operation = operation }) - let subscription1 = task.subscribe { _ in } - let subscription2 = task.subscribe { _ in } - - subscription1?.setPriority(.low) - subscription2?.setPriority(.high) - - // When - subscription2?.unsubscribe() - - // Then - XCTAssertEqual(operation.queuePriority, .low) - } - - func testWhenSubscriptionLowersPriorityButExistingSubscriptionHasHigherPriporty() { - // Given - let operation = Foundation.Operation() - let task = SimpleTask(starter: { $0.operation = operation }) - let subscription1 = task.subscribe { _ in } - let subscription2 = task.subscribe { _ in } - - // When - subscription2?.setPriority(.high) - subscription1?.setPriority(.low) - - // Then order of updating sub - XCTAssertEqual(operation.queuePriority, .high) - } - - func testPriorityOfDependencyUpdated() { - // 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 } - - // When - subscription?.setPriority(.high) - - // Then - XCTAssertEqual(operation.queuePriority, .high) - } - - // MARK: - Dispose - - func testExecutingTaskIsntDisposed() { - // Given - let task = AsyncTask() - var isDisposeCalled = false - task.onDisposed = { isDisposeCalled = true } - _ = task.subscribe { _ in } - - // When - task.send(value: 1) // Casually sending value - - // Then - XCTAssertFalse(isDisposeCalled) - XCTAssertFalse(task.isDisposed) - } - - func testThatTaskIsDisposedWhenCancelled() { - // Given - let task = SimpleTask(starter: { _ in }) - var isDisposeCalled = false - task.onDisposed = { isDisposeCalled = true } - let subscription = task.subscribe { _ in } - - // When - subscription?.unsubscribe() - - // Then - XCTAssertTrue(isDisposeCalled) - XCTAssertTrue(task.isDisposed) - } - - func testThatTaskIsDisposedWhenCompletedWithSuccess() { - // Given - let task = AsyncTask() - var isDisposeCalled = false - task.onDisposed = { isDisposeCalled = true } - _ = task.subscribe { _ in } - - // When - task.send(value: 1, isCompleted: true) - - // Then - XCTAssertTrue(isDisposeCalled) - XCTAssertTrue(task.isDisposed) - } - - func testThatTaskIsDisposedWhenCompletedWithFailure() { - // Given - let task = AsyncTask() - var isDisposeCalled = false - task.onDisposed = { isDisposeCalled = true } - _ = task.subscribe { _ in } - - // When - task.send(error: .init(raw: "1")) - - // Then - XCTAssertTrue(isDisposeCalled) - XCTAssertTrue(task.isDisposed) - } -} - -// MARK: - Helpers - -private struct MyError: Equatable { - let raw: String -} - -private final class SimpleTask: AsyncTask, @unchecked Sendable { - private var starter: ((SimpleTask) -> Void)? - - /// Initializes the task with the `starter`. - /// - parameter starter: The closure which gets called as soon as the first - /// subscription is added to the task. Only gets called once and is immediately - /// deallocated after it is called. - init(starter: ((SimpleTask) -> Void)? = nil) { - self.starter = starter - } - - override func start() { - starter?(self) - starter = nil - } -} - -extension AsyncTask { - func subscribe(priority: TaskPriority = .normal, _ observer: @escaping (Event) -> Void) -> TaskSubscription? { - publisher.subscribe(priority: priority, subscriber: "" as AnyObject, observer) - } -} diff --git a/Tests/NukeThreadSafetyTests/ThreadSafetyTests.swift b/Tests/NukeThreadSafetyTests/ThreadSafetyTests.swift index f6e4af6ef..e2734f9d5 100644 --- a/Tests/NukeThreadSafetyTests/ThreadSafetyTests.swift +++ b/Tests/NukeThreadSafetyTests/ThreadSafetyTests.swift @@ -1,58 +1,64 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing +import UIKit @testable import Nuke -import XCTest -class ThreadSafetyTests: XCTestCase { - func testImagePipelineThreadSafety() { +@ImagePipelineActor +@Suite struct ThreadSafetyTests { + @Test func imagePipelineThreadSafety() async { let dataLoader = MockDataLoader() let pipeline = ImagePipeline { $0.dataLoader = dataLoader $0.imageCache = nil } - - _testPipelineThreadSafety(pipeline) - - wait(20) { _ in - _ = (dataLoader, pipeline) - } + + let expectation = _testPipelineThreadSafety(pipeline) + await expectation.wait() + + _ = (dataLoader, pipeline) } - - func testSharingConfigurationBetweenPipelines() { // Especially operation queues + + @Test func sharingConfigurationBetweenPipelines() async { // Especially operation queues var pipelines = [ImagePipeline]() - + var configuration = ImagePipeline.Configuration() configuration.dataLoader = MockDataLoader() configuration.imageCache = nil - + pipelines.append(ImagePipeline(configuration: configuration)) pipelines.append(ImagePipeline(configuration: configuration)) pipelines.append(ImagePipeline(configuration: configuration)) - + + var expectations: [AsyncExpectation] = [] + for pipeline in pipelines { - _testPipelineThreadSafety(pipeline) + let expectation = _testPipelineThreadSafety(pipeline) + expectations.append(expectation) } - - wait(60) { _ in - _ = (pipelines) + + for expectation in expectations { + await expectation.wait() } + + _ = (pipelines) } - - func _testPipelineThreadSafety(_ pipeline: ImagePipeline) { - let expectation = self.expectation(description: "Finished") - expectation.expectedFulfillmentCount = 1000 - + + func _testPipelineThreadSafety(_ pipeline: ImagePipeline) -> AsyncExpectation { + let expectation = AsyncExpectation(expectedFulfillmentCount: 1000) + let queue = OperationQueue() queue.maxConcurrentOperationCount = 16 - + for _ in 0..<1000 { queue.addOperation { let url = URL(fileURLWithPath: "\(rnd(30))") let request = ImageRequest(url: url) let shouldCancel = rnd(3) == 0 - + let task = pipeline.loadImage(with: request) { _ in if shouldCancel { // do nothing, we don't expect completion on cancel @@ -60,26 +66,28 @@ class ThreadSafetyTests: XCTestCase { expectation.fulfill() } } - + if shouldCancel { task.cancel() expectation.fulfill() } } } + + return expectation } - - func testPrefetcherThreadSafety() { + + @Test func prefetcherThreadSafety() { let pipeline = ImagePipeline { $0.dataLoader = MockDataLoader() $0.imageCache = nil } - + let prefetcher = ImagePrefetcher(pipeline: pipeline) - - func makeRequests() -> [ImageRequest] { + + @Sendable func makeRequests() -> [ImageRequest] { return (0...rnd(30)).map { _ in - return ImageRequest(url: URL(string: "http://\(rnd(15))")!) + ImageRequest(url: URL(string: "http://\(rnd(15))")!) } } let queue = OperationQueue() @@ -92,16 +100,16 @@ class ThreadSafetyTests: XCTestCase { } queue.waitUntilAllOperationsAreFinished() } - - func testImageCacheThreadSafety() { + + @Test func imageCacheThreadSafety() { let cache = ImageCache() - + func rnd_cost() -> Int { return (2 + rnd(20)) * 1024 * 1024 } - + var ops = [() -> Void]() - + for _ in 0..<10 { // those ops happen more frequently ops += [ { cache[_request(index: rnd(10))] = ImageContainer(image: Test.image) }, @@ -109,12 +117,12 @@ class ThreadSafetyTests: XCTestCase { { let _ = cache[_request(index: rnd(10))] } ] } - + ops += [ { cache.trim(toCost: rnd_cost()) }, { cache.removeAll() } ] - + #if os(iOS) || os(tvOS) || os(visionOS) ops.append { NotificationCenter.default.post(name: UIApplication.didReceiveMemoryWarningNotification, object: nil) @@ -123,34 +131,35 @@ class ThreadSafetyTests: XCTestCase { NotificationCenter.default.post(name: UIApplication.didReceiveMemoryWarningNotification, object: nil) } #endif - + let queue = OperationQueue() queue.maxConcurrentOperationCount = 5 - + + let operations = ops for _ in 0..<10000 { queue.addOperation { - ops.randomElement()?() + operations.randomElement()?() } } - + queue.waitUntilAllOperationsAreFinished() } - + // MARK: - DataCache - - func testDataCacheThreadSafety() { + + @Test func dataCacheThreadSafety() { let cache = try! DataCache(name: UUID().uuidString, filenameGenerator: { $0 }) - + let data = Data(repeating: 1, count: 256 * 1024) - + for idx in 0..<500 { cache["\(idx)"] = data } cache.flush() - + let queue = OperationQueue() queue.maxConcurrentOperationCount = 5 - + for _ in 0..<5 { for idx in 0..<500 { queue.addOperation { @@ -165,15 +174,13 @@ class ThreadSafetyTests: XCTestCase { queue.waitUntilAllOperationsAreFinished() } - func testDataCacheMultipleThreadAccess() throws { + @Test func dataCacheMultipleThreadAccess() async throws { let cache = try DataCache(name: UUID().uuidString) let aURL = URL(string: "https://example.com/image-01-small.jpeg")! let imageData = Test.data(name: "fixture", extension: "jpeg") - let expectSuccessFromCache = self.expectation(description: "one successful load, from cache") - expectSuccessFromCache.expectedFulfillmentCount = 1 - expectSuccessFromCache.assertForOverFulfill = true + let expectSuccessFromCache = AsyncExpectation() let pipeline = ImagePipeline { $0.dataCache = cache @@ -186,61 +193,36 @@ class ThreadSafetyTests: XCTestCase { if response.cacheType == .memory || response.cacheType == .disk { expectSuccessFromCache.fulfill() } else { - XCTFail("didn't load that just cached image data: \(response)") + Issue.record("didn't load that just cached image data: \(response)") } case .failure: - XCTFail("didn't load that just cached image data") + Issue.record("didn't load that just cached image data") } } - wait(for: [expectSuccessFromCache], timeout: 2) + await expectSuccessFromCache.wait() try? FileManager.default.removeItem(at: cache.path) } } -final class OperationThreadSafetyTests: XCTestCase { - func testOperation() { - let queue = OperationQueue() - queue.maxConcurrentOperationCount = 10 - - DispatchQueue.concurrentPerform(iterations: 5) { _ in - for index in 0..<500 { - let operation = Operation(starter: { finish in - Thread.sleep(forTimeInterval: Double.random(in: 1...10) / 1000.0) - finish() - }) - if index % 3 == 0 { - DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(Int.random(in: 5...10))) { - operation.cancel() - operation.cancel() - operation.cancel() - } - } - queue.addOperation(operation) - } - } - queue.waitUntilAllOperationsAreFinished() - } -} - -final class RandomizedTests: XCTestCase { - func testImagePipeline() { +@Suite struct RandomizedTests { + @Test func imagePipeline() async { let dataLoader = MockDataLoader() let pipeline = ImagePipeline { $0.dataLoader = dataLoader $0.imageCache = nil $0.isRateLimiterEnabled = false } - + let queue = OperationQueue() queue.maxConcurrentOperationCount = 8 - - func every(_ count: Int) -> Bool { - return rnd() % count == 0 + + @Sendable func every(_ count: Int) -> Bool { + Int.random(in: 0 ..< .max) % count == 0 } - - func randomRequest() -> ImageRequest { + + @Sendable func randomRequest() -> ImageRequest { let url = URL(string: "\(Test.url)/\(rnd(50))")! var request = ImageRequest(url: url) request.priority = every(2) ? .high : .normal @@ -250,23 +232,22 @@ final class RandomizedTests: XCTestCase { } return request } - - func randomSleep() { + + @Sendable func randomSleep() { let ms = TimeInterval.random(in: 0 ..< 100) / 1000.0 Thread.sleep(forTimeInterval: ms) } - - let expectation = self.expectation(description: "Finished") - expectation.expectedFulfillmentCount = 1000 - + + let expectation = AsyncExpectation(expectedFulfillmentCount: 1000) + for _ in 0..<1000 { queue.addOperation { randomSleep() - + let request = randomRequest() - + let shouldCancel = every(3) - + let task = pipeline.loadImage(with: request) { _ in if shouldCancel { // do nothing, we don't expect completion on cancel @@ -274,7 +255,7 @@ final class RandomizedTests: XCTestCase { expectation.fulfill() } } - + if shouldCancel { queue.addOperation { randomSleep() @@ -282,7 +263,7 @@ final class RandomizedTests: XCTestCase { expectation.fulfill() } } - + if every(10) { queue.addOperation { randomSleep() @@ -292,13 +273,12 @@ final class RandomizedTests: XCTestCase { } } } - - wait(100) { _ in - _ = pipeline - } + + await expectation.wait() + _ = pipeline } } private func _request(index: Int) -> ImageRequest { - return ImageRequest(url: URL(string: "http://example.com/img\(index)")!) + ImageRequest(url: URL(string: "http://example.com/img\(index)")!) } diff --git a/Tests/NukeUITests/FetchImageTests.swift b/Tests/NukeUITests/FetchImageTests.swift index a0264a89d..fc405d3ce 100644 --- a/Tests/NukeUITests/FetchImageTests.swift +++ b/Tests/NukeUITests/FetchImageTests.swift @@ -1,13 +1,14 @@ // The MIT License (MIT) // -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2026 Alexander Grebenyuk (github.com/kean). + +import Testing -import XCTest @testable import Nuke @testable import NukeUI @MainActor -class FetchImageTests: XCTestCase { +@Suite struct FetchImageTests { var dataLoader: MockDataLoader! var imageCache: MockImageCache! var dataCache: MockDataCache! @@ -15,10 +16,7 @@ class FetchImageTests: XCTestCase { var pipeline: ImagePipeline! var image: FetchImage! - @MainActor - override func setUp() { - super.setUp() - + init() { dataLoader = MockDataLoader() imageCache = MockImageCache() observer = ImagePipelineObserver() @@ -34,138 +32,91 @@ class FetchImageTests: XCTestCase { image.pipeline = pipeline } - func testImageLoaded() throws { - // RECORD - let record = expect(image.$result.dropFirst()).toPublishSingleValue() + @Test func imageLoaded() async throws { + // Given + let expectation = image.$result.dropFirst() + .expectToPublishValue() - // WHEN + // When image.load(Test.request) - wait() + let result = try #require(await expectation.value) - // THEN - let result = try XCTUnwrap(try XCTUnwrap(record.last)) - XCTAssertTrue(result.isSuccess) - XCTAssertNotNil(image.image) + // Then + #expect(result.isSuccess) + #expect(result.value != nil) } - func testIsLoadingUpdated() { - // RECORD - expect(image.$result.dropFirst()).toPublishSingleValue() - let isLoading = record(image.$isLoading) + @Test func isLoadingUpdated() async { + // Given + let expectation1 = image.$result.dropFirst() + .expectToPublishValue() + let expectation2 = image.$isLoading.record(count: 3) - // WHEN + // When image.load(Test.request) - wait() + await expectation1.wait() - // THEN - XCTAssertEqual(isLoading.values, [false, true, false]) + // Then + let isLoadingValues = await expectation2.wait() + #expect(isLoadingValues == [false, true, false]) } - func testMemoryCacheLookup() throws { - // GIVEN + @Test func memoryCacheLookup() throws { + // Given pipeline.cache[Test.request] = Test.container - // WHEN + // When image.load(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) + // Then image loaded synchronously + let result = try #require(image.result) + #expect(result.isSuccess) + let response = try #require(result.value) + #expect(response.cacheType == .memory) + #expect(image.image != nil) } - func testPriorityUpdated() { + @ImagePipelineActor + @Test func priorityUpdated() async { + // Given let queue = pipeline.configuration.dataLoadingQueue queue.isSuspended = true - let observer = self.expect(queue).toEnqueueOperationsWithCount(1) - - image.priority = .high - image.load(Test.request) - wait() // Wait till the operation is created. - guard let operation = observer.operations.first else { - return XCTFail("No operations gor registered") + // When + let expectation = queue.expectJobAdded() + Task { @MainActor in + image.priority = .high + image.load(Test.request) } - XCTAssertEqual(operation.queuePriority, .high) + + // Then + let job = await expectation.value + #expect(job.priority == .high) } - func testPriorityUpdatedDynamically() { + @ImagePipelineActor + @Test func priorityUpdatedDynamically() async { + // Given let queue = pipeline.configuration.dataLoadingQueue queue.isSuspended = true - let observer = self.expect(queue).toEnqueueOperationsWithCount(1) - - image.load(Test.request) - wait() // Wait till the operation is created. - guard let operation = observer.operations.first else { - return XCTFail("No operations gor registered") + // When + let expectation1 = queue.expectJobAdded() + Task { @MainActor in + image.load(Test.request) } - expect(operation).toUpdatePriority() - 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) + // Then + let job = await expectation1.wait() - // 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 + // When + let expectation2 = queue.expectPriorityUpdated(for: job) + Task { @MainActor in + image.priority = .high } - wait() + + // Then + let priority = await expectation2.wait() + #expect(priority == .high) } } diff --git a/Tests/XCTestCase+Nuke.swift b/Tests/XCTestCase+Nuke.swift deleted file mode 100644 index 343cce2c8..000000000 --- a/Tests/XCTestCase+Nuke.swift +++ /dev/null @@ -1,173 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -import Foundation -@testable import Nuke - -#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) -import UIKit -#endif - -#if os(macOS) -import Cocoa -#endif - -extension XCTestCase { - func expect(_ pipeline: ImagePipeline) -> TestExpectationImagePipeline { - return TestExpectationImagePipeline(test: self, pipeline: pipeline) - } -} - -struct TestExpectationImagePipeline { - let test: XCTestCase - let pipeline: ImagePipeline - - @discardableResult - func toLoadImage(with request: ImageRequest, completion: @escaping ((Result) -> Void)) -> TestRecordedImageRequest { - toLoadImage(with: request, progress: nil, completion: completion) - } - - @discardableResult - func toLoadImage(with request: ImageRequest, - progress: ((_ intermediateResponse: ImageResponse?, _ completedUnitCount: Int64, _ totalUnitCount: Int64) -> Void)? = nil, - completion: ((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 - completion?(result) - record.result = result - XCTAssertTrue(Thread.isMainThread) - XCTAssertTrue(result.isSuccess) - expectation.fulfill() - } - return record - } - - @discardableResult - func toFailRequest(_ request: ImageRequest, completion: @escaping ((Result) -> Void)) -> ImageTask { - toFailRequest(request, progress: nil, completion: completion) - } - - @discardableResult - func toFailRequest(_ request: ImageRequest, - progress: ((_ intermediateResponse: ImageResponse?, _ completedUnitCount: Int64, _ totalUnitCount: Int64) -> Void)? = nil, - completion: ((Result) -> Void)? = nil) -> ImageTask { - let expectation = test.expectation(description: "Image request failed \(request)") - return pipeline.loadImage(with: request, progress: progress) { result in - completion?(result) - XCTAssertTrue(Thread.isMainThread) - XCTAssertTrue(result.isFailure) - expectation.fulfill() - } - } - - func toFailRequest(_ request: ImageRequest, with expectedError: ImagePipeline.Error, file: StaticString = #file, line: UInt = #line) { - toFailRequest(request) { result in - XCTAssertEqual(result.error, expectedError, file: file, line: line) - } - } - - @discardableResult - func toLoadData(with request: ImageRequest) -> TestRecorededDataTask { - let record = TestRecorededDataTask() - let request = request - let expectation = test.expectation(description: "Data loaded for \(request)") - record._task = pipeline.loadData(with: request, progress: nil) { result in - XCTAssertTrue(Thread.isMainThread) - record.result = result - expectation.fulfill() - } - return record - } -} - -final class TestRecordedImageRequest { - var task: ImageTask { - _task - } - fileprivate var _task: ImageTask! - - var result: Result? - - var response: ImageResponse? { - result?.value - } - - var image: PlatformImage? { - response?.image - } -} - -final class TestRecorededDataTask { - var task: ImageTask { - _task - } - fileprivate var _task: ImageTask! - - var result: Result<(data: Data, response: URLResponse?), ImagePipeline.Error>? - - var data: Data? { - guard case .success(let response)? = result else { - return nil - } - return response.data - } -} - -extension XCTestCase { - func expect(_ pipeline: ImagePipeline, _ dataLoader: MockProgressiveDataLoader) -> TestExpectationProgressivePipeline { - return TestExpectationProgressivePipeline(test: self, pipeline: pipeline, dataLoader: dataLoader) - } -} - -struct TestExpectationProgressivePipeline { - let test: XCTestCase - let pipeline: ImagePipeline - let dataLoader: MockProgressiveDataLoader - - // We expect two partial images (at 5 scans, and 9 scans marks). - func toProducePartialImages(for request: ImageRequest = Test.request, - withCount count: Int = 2, - progress: ((_ intermediateResponse: ImageResponse?, _ completedUnitCount: Int64, _ totalUnitCount: Int64) -> Void)? = nil, - completion: ((_ result: Result) -> Void)? = nil) { - let expectPartialImageProduced = test.expectation(description: "Partial Image Is Produced") - expectPartialImageProduced.expectedFulfillmentCount = count - - let expectFinalImageProduced = test.expectation(description: "Final Image Is Produced") - - pipeline.loadImage( - with: request, - progress: { image, completed, total in - progress?(image, completed, total) - - // This works because each new chunk resulted in a new scan - if image != nil { - expectPartialImageProduced.fulfill() - self.dataLoader.resume() - } - }, - completion: { result in - completion?(result) - XCTAssertTrue(result.isSuccess) - expectFinalImageProduced.fulfill() - } - ) - } -} - -// MARK: - UIImage - -func XCTAssertEqualImages(_ lhs: PlatformImage, _ rhs: PlatformImage, file: StaticString = #file, line: UInt = #line) { - XCTAssertTrue(isEqual(lhs, rhs), "Expected images to be equal", file: file, line: line) -} - -private func isEqual(_ lhs: PlatformImage, _ rhs: PlatformImage) -> Bool { - guard lhs.sizeInPixels == rhs.sizeInPixels else { - return false - } - // Note: this will probably need more work. - let encoder = ImageEncoders.ImageIO(type: .png, compressionRatio: 1) - return encoder.encode(lhs) == encoder.encode(rhs) -} diff --git a/Tests/XCTestCaseExtensions.swift b/Tests/XCTestCaseExtensions.swift deleted file mode 100644 index 7b24eaeae..000000000 --- a/Tests/XCTestCaseExtensions.swift +++ /dev/null @@ -1,319 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -import Foundation -import Combine - -extension XCTestCase { - @discardableResult - func expectNotification(_ name: Notification.Name, object: AnyObject? = nil, handler: XCTNSNotificationExpectation.Handler? = nil) -> XCTestExpectation { - return self.expectation(forNotification: name, object: object, handler: handler) - } - - func wait(_ timeout: TimeInterval = 5, handler: XCWaitCompletionHandler? = nil) { - self.waitForExpectations(timeout: timeout, handler: handler) - } -} - -// MARK: - Publishers - -extension XCTestCase { - func expect(_ publisher: P) -> TestExpectationPublisher

{ - TestExpectationPublisher(test: self, publisher: publisher) - } - - func record(_ publisher: P) -> TestRecordedPublisher

{ - let record = TestRecordedPublisher

() - publisher.sink(receiveCompletion: { - record.completion = $0 - }, receiveValue: { - record.values.append($0) - }).store(in: &cancellables) - return record - } - -#if swift(>=5.10) - // Safe because it's never mutated. - nonisolated(unsafe) private static let cancellablesAK = malloc(1)! -#else - private static let cancellablesAK = malloc(1)! -#endif - - fileprivate var cancellables: [AnyCancellable] { - get { (objc_getAssociatedObject(self, XCTestCase.cancellablesAK) as? [AnyCancellable]) ?? [] } - set { objc_setAssociatedObject(self, XCTestCase.cancellablesAK, newValue, .OBJC_ASSOCIATION_RETAIN) } - } -} - -struct TestExpectationPublisher { - let test: XCTestCase - let publisher: P - - @discardableResult - func toPublishSingleValue() -> TestRecordedPublisher

{ - let record = TestRecordedPublisher

() - let expectation = test.expectation(description: "ValueEmitted") - publisher.sink(receiveCompletion: { _ in - // Do nothing - }, receiveValue: { - guard record.values.isEmpty else { - return XCTFail("Already emitted value") - } - record.values.append($0) - expectation.fulfill() - }).store(in: &test.cancellables) - return record - } -} - -final class TestRecordedPublisher { - fileprivate(set) var values = [P.Output]() - fileprivate(set) var completion: Subscribers.Completion? - - var last: P.Output? { - values.last - } -} - -// MARK: - XCTestCase (KVO) - -extension XCTestCase { - /// A replacement for keyValueObservingExpectation which used Swift key paths. - /// - warning: Keep in mind that `changeHandler` will continue to get called - /// even after expectation is fulfilled. The method itself can't reliably stop - /// observing KVO in case its multithreaded. - func expectation(description: String = "", for object: Object, keyPath: KeyPath, options: NSKeyValueObservingOptions = .new, _ changeHandler: @escaping (Object, NSKeyValueObservedChange, XCTestExpectation) -> Void) { - let expectation = self.expectation(description: description) - let observation = object.observe(keyPath, options: options) { (object, change) in - changeHandler(object, change, expectation) - } - observations.append(observation) - } - - func expect(values: [Value], for object: Object, keyPath: KeyPath, changeHandler: ((Object, NSKeyValueObservedChange) -> Void)? = nil) { - let valuesExpectation = self.expect(values: values) - let observation = object.observe(keyPath, options: [.new]) { (object, change) in - changeHandler?(object, change) - DispatchQueue.main.async { // Synchronize access to `valuesExpectation` - valuesExpectation.received(change.newValue!) - } - } - observations.append(observation) - } - -#if swift(>=5.10) - // Safe because it's never mutated. - nonisolated(unsafe) private static let observationsAK = malloc(1)! -#else - private static let observationsAK = malloc(1)! -#endif - - private var observations: [NSKeyValueObservation] { - get { - return (objc_getAssociatedObject(self, XCTestCase.observationsAK) as? [NSKeyValueObservation]) ?? [] - } - set { - objc_setAssociatedObject(self, XCTestCase.observationsAK, newValue, .OBJC_ASSOCIATION_RETAIN) - } - } -} - -// MARK: - XCTestExpectationFactory - -struct TestExpectationOperationQueue { - let test: XCTestCase - let queue: OperationQueue - - @discardableResult - func toEnqueueOperationsWithCount(_ count: Int) -> OperationQueueObserver { - let expectation = test.expectation(description: "Expect queue to enqueue \(count) operations") - let observer = OperationQueueObserver(queue: queue) - observer.didAddOperation = { _ in - if observer.operations.count == count { - observer.didAddOperation = nil - expectation.fulfill() - } - } - return observer - } - - /// Fulfills an expectation as soon as a queue finished executing `n` - /// operations (doesn't matter whether they were cancelled or executed). - /// - /// Automatically resumes a queue as soon as `n` operations are enqueued. - @discardableResult - func toFinishWithEnqueuedOperationCount(_ count: Int) -> OperationQueueObserver { - precondition(queue.isSuspended, "Queue must be suspended in order to reliably track when all expected operations are enqueued.") - - let expectation = test.expectation(description: "Expect queue to finish with \(count) operations") - let observer = OperationQueueObserver(queue: queue) - - observer.didAddOperation = { _ in - // We don't expect any more operations added after that - XCTAssertTrue(self.queue.isSuspended, "More operations were added to the queue then were expected") - if observer.operations.count == count { - self.queue.isSuspended = false - } - } - observer.didFinishAllOperations = { - expectation.fulfill() - - // Release observer - observer.didAddOperation = nil - observer.didFinishAllOperations = nil - } - return observer - } -} - -extension XCTestCase { - func expect(_ queue: OperationQueue) -> TestExpectationOperationQueue { - return TestExpectationOperationQueue(test: self, queue: queue) - } -} - -struct TestExpectationOperation { - let test: XCTestCase - let operation: Operation - - // This is useful because KVO on Foundation.Operation is super flaky in Swift - func toCancel(with expectation: XCTestExpectation? = nil) { - let expectation = expectation ?? self.test.expectation(description: "Cancelled") - let operation = self.operation - - func check() { - if operation.isCancelled { - expectation.fulfill() - } else { - // Use GCD because Timer with closures not available on iOS 9 - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(5)) { - check() - } - } - } - check() - } - - func toUpdatePriority(from: Operation.QueuePriority = .normal, to: Operation.QueuePriority = .high) { - XCTAssertEqual(operation.queuePriority, from) - test.keyValueObservingExpectation(for: operation, keyPath: "queuePriority") { [weak operation] (_, _) in - XCTAssertEqual(operation?.queuePriority, to) - return true - } - } -} - -extension XCTestCase { - func expect(_ operation: Operation) -> TestExpectationOperation { - return TestExpectationOperation(test: self, operation: operation) - } -} - -// MARK: - ValuesExpectation - -extension XCTestCase { - class ValuesExpectation { - private let expectation: XCTestExpectation - private let expected: [Value] - private let isEqual: (Value, Value) -> Bool - private var _expected: [Value] - private var _recorded = [Value]() - - init(expected: [Value], isEqual: @escaping (Value, Value) -> Bool, expectation: XCTestExpectation) { - self.expected = expected - self.isEqual = isEqual - self._expected = expected.reversed() // to be ably to popLast - self.expectation = expectation - } - - func received(_ newValue: Value) { - _recorded.append(newValue) - guard let value = _expected.popLast() else { - XCTFail("Received unexpected value. Recorded: \(_recorded), Expected: \(expected)") - return - } - XCTAssertTrue(isEqual(newValue, value), "Recorded: \(_recorded), Expected: \(expected)") - if _expected.isEmpty { - expectation.fulfill() - } - } - } - - func expect(values: [Value]) -> ValuesExpectation { - return ValuesExpectation(expected: values, isEqual: ==, expectation: self.expectation(description: "Expecting values: \(values)")) - } - - func expect(values: [Value], isEqual: @escaping (Value, Value) -> Bool) -> ValuesExpectation { - return ValuesExpectation(expected: values, isEqual: isEqual, expectation: self.expectation(description: "Expecting values: \(values)")) - } - - func expectProgress(_ values: [(Int64, Int64)]) -> ValuesExpectation<(Int64, Int64)> { - return expect(values: values, isEqual: ==) - } -} - -// MARK: - OperationQueueObserver - -final class OperationQueueObserver { - private let queue: OperationQueue - // All recorded operations. - private(set) var operations = [Foundation.Operation]() - private var _ops = Set() - private var _observers = [NSKeyValueObservation]() - private let _lock = NSLock() - - var didAddOperation: ((Foundation.Operation) -> Void)? - var didFinishAllOperations: (() -> Void)? - - init(queue: OperationQueue) { - self.queue = queue - - _startObservingOperations() - } - - private func _startObservingOperations() { - let observer = queue.observe(\.operations) { [weak self] _, _ in - self?._didUpdateOperations() - } - _observers.append(observer) - } - - private func _didUpdateOperations() { - _lock.lock() - for operation in queue.operations { - if !_ops.contains(operation) { - _ops.insert(operation) - operations.append(operation) - didAddOperation?(operation) - } - } - if queue.operations.isEmpty { - didFinishAllOperations?() - } - _lock.unlock() - } -} - -// MARK: - Misc - -func rnd() -> Int { - return Int.random(in: 0 ..< .max) -} - -func rnd(_ uniform: Int) -> Int { - return Int.random(in: 0 ..< uniform) -} - -extension DispatchQueue { - func after(ticks: Int, _ closure: @escaping () -> Void) { - if ticks == 0 { - closure() - } else { - async { - self.after(ticks: ticks - 1, closure) - } - } - } -}