diff --git a/AsyncImageViewTests/AsyncImageSchedulerSpec.swift b/AsyncImageViewTests/AsyncImageSchedulerSpec.swift deleted file mode 100644 index 77eff4f..0000000 --- a/AsyncImageViewTests/AsyncImageSchedulerSpec.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Quick -import Nimble -import UIKit - -import ReactiveSwift - -import AsyncImageView - -class AsyncImageSchedulerSpec: QuickSpec { - override class func spec() { - describe("AsyncImageView scheduling") { - it("schedules image creation once per request") { - let imageCreationScheduler = CountingScheduler() - let renderer = SchedulerCheckingRenderer(scheduler: imageCreationScheduler) - let view = AsyncImageView< - TestRenderData, - TestData, - SchedulerCheckingRenderer, - SchedulerCheckingRenderer - >( - initialFrame: CGRect(origin: .zero, size: CGSize(width: 10, height: 10)), - renderer: renderer, - placeholderRenderer: nil, - uiScheduler: ImmediateScheduler(), - imageCreationScheduler: imageCreationScheduler - ) - let window = UIWindow() - window.addSubview(view) - imageCreationScheduler.reset() - - view.data = .a - - expect(imageCreationScheduler.scheduleCount.value) == 1 - expect(renderer.startedOnScheduler.value) == true - } - } - } -} - -private final class CountingScheduler: Scheduler { - let scheduleCount = Atomic(0) - let isExecuting = Atomic(false) - - func schedule(_ action: @escaping () -> Void) -> Disposable? { - self.scheduleCount.modify { $0 += 1 } - self.isExecuting.modify { $0 = true } - action() - self.isExecuting.modify { $0 = false } - - return nil - } - - func reset() { - self.scheduleCount.modify { $0 = 0 } - } -} - -private final class SchedulerCheckingRenderer: RendererType { - let startedOnScheduler = Atomic(false) - private let scheduler: CountingScheduler - - init(scheduler: CountingScheduler) { - self.scheduler = scheduler - } - - func renderImageWithData(_ data: TestRenderData) -> SignalProducer { - self.startedOnScheduler.modify { $0 = self.scheduler.isExecuting.value } - - return SignalProducer(value: UIImage()) - } -} diff --git a/AsyncImageViewTests/AsyncImageSchedulerTests.swift b/AsyncImageViewTests/AsyncImageSchedulerTests.swift new file mode 100644 index 0000000..f76b830 --- /dev/null +++ b/AsyncImageViewTests/AsyncImageSchedulerTests.swift @@ -0,0 +1,68 @@ +import UIKit + +import ReactiveSwift +import Testing + +import AsyncImageView + +@Suite @MainActor +struct AsyncImageSchedulerTests { + @Test + func schedulesImageCreationOncePerRequest() { + let imageCreationScheduler = CountingScheduler() + let renderer = SchedulerCheckingRenderer(scheduler: imageCreationScheduler) + let view = AsyncImageView< + TestRenderData, + TestData, + SchedulerCheckingRenderer, + SchedulerCheckingRenderer + >( + initialFrame: CGRect(origin: .zero, size: CGSize(width: 10, height: 10)), + renderer: renderer, + placeholderRenderer: nil, + uiScheduler: ImmediateScheduler(), + imageCreationScheduler: imageCreationScheduler + ) + let window = UIWindow() + window.addSubview(view) + imageCreationScheduler.reset() + + view.data = .a + + #expect(imageCreationScheduler.scheduleCount.value == 1) + #expect(renderer.startedOnScheduler.value) + } +} + +private final class CountingScheduler: Scheduler { + let scheduleCount = Atomic(0) + let isExecuting = Atomic(false) + + func schedule(_ action: @escaping () -> Void) -> Disposable? { + self.scheduleCount.modify { $0 += 1 } + self.isExecuting.modify { $0 = true } + action() + self.isExecuting.modify { $0 = false } + + return nil + } + + func reset() { + self.scheduleCount.modify { $0 = 0 } + } +} + +private final class SchedulerCheckingRenderer: RendererType { + let startedOnScheduler = Atomic(false) + private let scheduler: CountingScheduler + + init(scheduler: CountingScheduler) { + self.scheduler = scheduler + } + + func renderImageWithData(_ data: TestRenderData) -> SignalProducer { + self.startedOnScheduler.modify { $0 = self.scheduler.isExecuting.value } + + return SignalProducer(value: UIImage()) + } +} diff --git a/AsyncImageViewTests/AsyncImageViewRequestSpec.swift b/AsyncImageViewTests/AsyncImageViewRequestSpec.swift deleted file mode 100644 index a46c5e0..0000000 --- a/AsyncImageViewTests/AsyncImageViewRequestSpec.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Quick -import Nimble -import UIKit - -import ReactiveSwift - -import AsyncImageView - -class AsyncImageViewRequestSpec: QuickSpec { - override class func spec() { - describe("AsyncImageView requests") { - var counter: Atomic! - var view: AsyncImageView< - RequestTestRenderData, - RequestTestViewData, - RequestTestRenderer, - RequestTestRenderer - >! - var window: UIWindow! - - beforeEach { - counter = Atomic(0) - view = AsyncImageView< - RequestTestRenderData, - RequestTestViewData, - RequestTestRenderer, - RequestTestRenderer - >( - initialFrame: CGRect(origin: .zero, size: CGSize(width: 10, height: 10)), - renderer: RequestTestRenderer(), - placeholderRenderer: nil, - uiScheduler: ImmediateScheduler(), - imageCreationScheduler: ImmediateScheduler() - ) - window = UIWindow() - window.addSubview(view) - view.data = RequestTestViewData(counter: counter) - } - - it("does not create render data for frame origin changes") { - for offset in 1...10 { - view.frame.origin.x = CGFloat(offset) - } - - expect(counter.value) == 1 - } - - it("does not create render data for bounds origin changes") { - for offset in 1...10 { - view.bounds.origin.x = CGFloat(offset) - } - - expect(counter.value) == 1 - } - - it("creates render data for size changes") { - view.frame.size = CGSize(width: 20, height: 20) - view.bounds.size = CGSize(width: 30, height: 30) - - expect(counter.value) == 3 - } - } - } -} - -private struct RequestTestViewData: ImageViewDataType { - let counter: Atomic - - func renderDataWithSize(_ size: CGSize) -> RequestTestRenderData { - self.counter.modify { $0 += 1 } - - return RequestTestRenderData(size: size) - } -} - -private struct RequestTestRenderData: RenderDataType { - let size: CGSize -} - -private final class RequestTestRenderer: RendererType { - func renderImageWithData(_ data: RequestTestRenderData) -> SignalProducer { - return SignalProducer(value: UIImage()) - } -} diff --git a/AsyncImageViewTests/AsyncImageViewRequestTests.swift b/AsyncImageViewTests/AsyncImageViewRequestTests.swift new file mode 100644 index 0000000..731f1dd --- /dev/null +++ b/AsyncImageViewTests/AsyncImageViewRequestTests.swift @@ -0,0 +1,87 @@ +import UIKit + +import ReactiveSwift +import Testing + +import AsyncImageView + +@Suite @MainActor +struct AsyncImageViewRequestTests { + private let fixture = RequestFixture() + + @Test + func frameOriginChangesDoNotCreateRenderData() { + for offset in 1...10 { + self.fixture.view.frame.origin.x = CGFloat(offset) + } + + #expect(self.fixture.requestCount == 1) + } + + @Test + func boundsOriginChangesDoNotCreateRenderData() { + for offset in 1...10 { + self.fixture.view.bounds.origin.x = CGFloat(offset) + } + + #expect(self.fixture.requestCount == 1) + } + + @Test + func sizeChangesCreateRenderData() { + self.fixture.view.frame.size = CGSize(width: 20, height: 20) + self.fixture.view.bounds.size = CGSize(width: 30, height: 30) + + #expect(self.fixture.requestCount == 3) + } +} + +@MainActor +private final class RequestFixture { + typealias ViewType = AsyncImageView< + RequestTestRenderData, + RequestTestViewData, + RequestTestRenderer, + RequestTestRenderer + > + + let counter = Atomic(0) + let view: ViewType + private let window = UIWindow() + + var requestCount: Int { + self.counter.value + } + + init() { + self.view = ViewType( + initialFrame: CGRect(origin: .zero, size: CGSize(width: 10, height: 10)), + renderer: RequestTestRenderer(), + placeholderRenderer: nil, + uiScheduler: ImmediateScheduler(), + imageCreationScheduler: ImmediateScheduler() + ) + self.window.addSubview(self.view) + self.view.data = RequestTestViewData(counter: self.counter) + } +} + +private struct RequestTestViewData: ImageViewDataType { + let counter: Atomic + + func renderDataWithSize(_ size: CGSize) -> RequestTestRenderData { + self.counter.modify { $0 += 1 } + + return RequestTestRenderData(size: size) + } +} + +private struct RequestTestRenderData: RenderDataType { + let size: CGSize +} + +private final class RequestTestRenderer: RendererType { + func renderImageWithData(_ data: RequestTestRenderData) -> SignalProducer { + SignalProducer(value: UIImage()) + } +} diff --git a/AsyncImageViewTests/AsyncImageViewSpec.swift b/AsyncImageViewTests/AsyncImageViewSpec.swift deleted file mode 100644 index 2939445..0000000 --- a/AsyncImageViewTests/AsyncImageViewSpec.swift +++ /dev/null @@ -1,388 +0,0 @@ -// -// AsyncImageViewSpec.swift -// AsyncImageViewTests -// -// Created by Nacho Soto on 11/22/15. -// Copyright © 2015 Nacho Soto. All rights reserved. -// - -import Quick -import Nimble -import UIKit -import XCTest - -import ReactiveSwift - -import AsyncImageView - -class AsyncImageViewSpec: QuickSpec { - override class func spec() { - describe("AsyncImageView") { - // To ensure that updating UI takes one extra cycle - // and we can verify image is reset before that - let uiScheduler = QueueScheduler(targeting: DispatchQueue.main) - var window: UIWindow! - - beforeEach { - window = UIWindow() - } - - context("No placeholder") { - typealias ViewType = AsyncImageView - - var view: ViewType! - var renderer: TestRenderer! - - beforeEach { - renderer = TestRenderer() - view = ViewType( - initialFrame: .zero, - renderer: renderer, - placeholderRenderer: nil, - uiScheduler: uiScheduler, - imageCreationScheduler: ImmediateScheduler() - ) - window.addSubview(view) - } - - func verifyView( - file: FileString = #file, - line: UInt = #line - ) { - verifyImage( - view.image, - withSize: view.frame.size, - data: view.data, - file: file, - line: line - ) - } - - it("has no image initially") { - expect(view.image).to(beNil()) - } - - context("Updating image") { - it("does not render image if size is not set yet") { - view.data = .a - - expect(view.image).to(beNil()) - } - - it("updates image when setting data") { - view.frame.size = CGSize(width: 10, height: 10) - view.data = .a - - verifyView() - } - - it("updates image when updating data") { - view.frame.size = CGSize(width: 10, height: 10) - view.data = .a - view.data = .b - - verifyView() - } - - it("updates image when setting frame") { - view.data = .c - view.frame.size = CGSize(width: 10, height: 10) - - verifyView() - } - - it("updates image when updating frame") { - view.frame.size = CGSize(width: 10, height: 10) - view.data = .c - - view.frame.size = CGSize(width: 15, height: 15) - - verifyView() - } - - it("updates image when updating bounds") { - view.frame.size = CGSize(width: 10, height: 10) - view.data = .c - - view.bounds.size = CGSize(width: 15, height: 15) - - verifyView() - } - - it("resets image when updating data") { - view.frame.size = CGSize(width: 10, height: 10) - view.data = .c - verifyView() - - view.data = .a - - expect(view.image).to(beNil()) // image should be reset immediately - verifyView() // and updated when rendering finishes - } - - it("resets image when updating frame") { - view.frame.size = CGSize(width: 10, height: 10) - view.data = .c - verifyView() - - view.frame.size = CGSize(width: 15, height: 15) - - expect(view.image).to(beNil()) // image should be reset immediately - verifyView() // and updated when rendering finishes - } - - it("resets image when setting data to nil") { - view.frame.size = CGSize(width: 10, height: 10) - view.data = .c - - verifyView() - - view.data = nil - expect(view.image).to(beNil()) // image should be reset immediately - } - } - - context("Not updating image if nothing changed") { - it("does not attempt to render anything is size is not ready") { - view.data = .a - view.frame.size = CGSize(width: 10, height: 0) - - expect(view.image).toEventually(beNil()) - } - - it("only renders once if data does not change") { - view.data = .a - view.frame.size = CGSize(width: 10, height: 10) - view.data = .a - - expect(view.image).toNotEventually(beNil()) - expect(renderer.renderedImages.value) == [TestRenderData(data: view.data!, size: view.frame.size)] - } - - it("only renders once if size does not change") { - view.data = .a - view.frame = CGRect(origin: CGPoint.zero, size: CGSize(width: 10, height: 10)) - view.frame = CGRect(origin: CGPoint(x: 1, y: 0), size: CGSize(width: 10, height: 10)) - - expect(view.image).toNotEventually(beNil()) - expect(renderer.renderedImages.value) == [TestRenderData(data: view.data!, size: view.frame.size)] - } - } - } - - context("Placeholder renderer") { - typealias ViewType = AsyncImageView - - var view: ViewType! - var placeholderRenderer: ManualRenderer! - var renderer: ManualRenderer! - - beforeEach { - placeholderRenderer = ManualRenderer() - renderer = ManualRenderer() - view = ViewType( - initialFrame: CGRect.zero, - renderer: renderer, - placeholderRenderer: placeholderRenderer, - uiScheduler: uiScheduler, - imageCreationScheduler: ImmediateScheduler() - ) - window.addSubview(view) - } - - func verifyRealImage( - file: FileString = #file, - line: UInt = #line - ) { - verifyImage( - view.image, - withSize: view.frame.size, - data: view.data!, - file: file, - line: line - ) - } - - func verifyPlaceholder( - file: FileString = #file, - line: UInt = #line - ) { - verifyImage( - view.image, - withSize: view.frame.size, - expectedScale: view.data!.placeholderScale, - file: file, - line: line - ) - } - - it("has no image initially") { - expect(view.image).to(beNil()) - } - - it("sets placeholder image if emitted first") { - view.frame.size = CGSize(width: 1, height: 1) - - let data: TestData = .a - let renderData = data.renderDataWithSize(view.frame.size) - - placeholderRenderer.addRenderSignal(renderData) - renderer.addRenderSignal(renderData) - - view.data = data - expect(view.image).to(beNil()) - - placeholderRenderer.emitImageForData(renderData, scale: data.placeholderScale) - verifyPlaceholder() - - renderer.emitImageForData(renderData, scale: data.rawValue) - verifyRealImage() - } - - it("does not clear placeholder image when updating data") { - view.frame.size = CGSize(width: 1, height: 1) - - let originalData: TestData = .a - let originalRenderData = originalData.renderDataWithSize(view.frame.size) - - let updatedData: TestData = .b - let updatedRenderData = updatedData.renderDataWithSize(view.frame.size) - - placeholderRenderer.addRenderSignal(originalRenderData) - placeholderRenderer.addRenderSignal(updatedRenderData) - renderer.addRenderSignal(originalRenderData) - renderer.addRenderSignal(updatedRenderData) - - view.data = originalData - - placeholderRenderer.emitImageForData(originalRenderData, scale: originalData.placeholderScale) - verifyPlaceholder() - - view.data = updatedData - verifyImage(view.image, withSize: view.frame.size, expectedScale: originalData.placeholderScale) - } - - it("sets placeholder image when updating data") { - view.frame.size = CGSize(width: 1, height: 1) - - let originalData: TestData = .a - let originalRenderData = originalData.renderDataWithSize(view.frame.size) - - let updatedData: TestData = .b - let updatedRenderData = updatedData.renderDataWithSize(view.frame.size) - - placeholderRenderer.addRenderSignal(originalRenderData) - placeholderRenderer.addRenderSignal(updatedRenderData) - renderer.addRenderSignal(originalRenderData) - renderer.addRenderSignal(updatedRenderData) - - view.data = originalData - - renderer.emitImageForData(originalRenderData, scale: originalData.rawValue) - verifyRealImage() - - view.data = updatedData - - placeholderRenderer.emitImageForData(updatedRenderData, scale: updatedData.placeholderScale) - verifyPlaceholder() - } - - it("resets image when setting data to nil") { - view.frame.size = CGSize(width: 10, height: 10) - - let data: TestData = .a - let renderData = data.renderDataWithSize(view.frame.size) - - placeholderRenderer.addRenderSignal(renderData) - renderer.addRenderSignal(renderData) - - view.data = data - - renderer.emitImageForData(renderData, scale: data.rawValue) - verifyRealImage() - - view.data = nil - expect(view.image).to(beNil()) // image should be reset immediately - } - - it("shows placeholder if renderer fails first") { - view.frame.size = CGSize(width: 1, height: 1) - - let data: TestData = .a - let renderData = data.renderDataWithSize(view.frame.size) - - placeholderRenderer.addRenderSignal(renderData) - renderer.addRenderSignal(renderData) - - view.data = data - - renderer.failAndComplete(renderData) - - placeholderRenderer.emitImageForData(renderData, scale: data.placeholderScale) - verifyPlaceholder() - } - - it("does not reset placeholder if renderer fails after") { - view.frame.size = CGSize(width: 1, height: 1) - - let data: TestData = .a - let renderData = data.renderDataWithSize(view.frame.size) - - placeholderRenderer.addRenderSignal(renderData) - renderer.addRenderSignal(renderData) - - view.data = data - - placeholderRenderer.emitImageForData(renderData, scale: data.placeholderScale) - verifyPlaceholder() - - renderer.failAndComplete(renderData) - verifyPlaceholder() - } - } - } - } -} - -private final class ManualRenderer: RendererType { - var signals: [TestRenderData: (output: Signal, input: Signal.Observer)] = [:] - - func addRenderSignal(_ data: TestRenderData) { - signals[data] = Signal.pipe() - } - - private func observer(forData data: TestRenderData) -> Signal.Observer { - return signals[data]!.input - } - - func emitImageForData(_ data: TestRenderData, scale: CGFloat) { - let image = TestRenderer.rendererForSize(data.size, - scale: scale) - .renderImageWithData(data) - let observer = self.observer(forData: data) - - observer.send(value: image) - observer.sendCompleted() - } - - func failAndComplete(_ data: TestRenderData) { - // Errors aren't allowed in AsyncImageView (they must be handled prior), - // so they instead simply cause the signal to complete. - self.observer(forData: data).sendCompleted() - } - - func renderImageWithData(_ data: TestRenderData) -> SignalProducer { - guard let signal = signals[data]?.output else { - XCTFail("Signal not created for \(data)") - return .empty - } - - return SignalProducer(signal) - } -} - -private extension TestData { - var placeholderScale: CGFloat { - return self.rawValue * 5 - } -} diff --git a/AsyncImageViewTests/AsyncImageViewTests.swift b/AsyncImageViewTests/AsyncImageViewTests.swift new file mode 100644 index 0000000..610475a --- /dev/null +++ b/AsyncImageViewTests/AsyncImageViewTests.swift @@ -0,0 +1,363 @@ +// +// AsyncImageViewTests.swift +// AsyncImageViewTests +// +// Created by Nacho Soto on 11/22/15. +// Copyright © 2015 Nacho Soto. All rights reserved. +// + +import UIKit + +import ReactiveSwift +import Testing + +import AsyncImageView + +@Suite(.serialized) @MainActor +struct AsyncImageViewBehaviorTests { + @Test + func hasNoImageInitially() { + let fixture = ImageViewFixture() + + #expect(fixture.view.image == nil) + } + + @Test + func doesNotRenderUntilSizeIsAvailable() { + let fixture = ImageViewFixture() + + fixture.view.data = .a + + #expect(fixture.view.image == nil) + #expect(fixture.renderer.renderedImages.value.isEmpty) + } + + @Test + func settingDataUpdatesImage() async { + let fixture = ImageViewFixture(size: CGSize(width: 10, height: 10)) + + fixture.view.data = .a + + await fixture.verifyRenderedImage() + } + + @Test + func updatingDataUpdatesImage() async { + let fixture = ImageViewFixture(size: CGSize(width: 10, height: 10)) + + fixture.view.data = .a + fixture.view.data = .b + + await fixture.verifyRenderedImage() + } + + @Test + func settingFrameUpdatesImage() async { + let fixture = ImageViewFixture() + + fixture.view.data = .c + fixture.view.frame.size = CGSize(width: 10, height: 10) + + await fixture.verifyRenderedImage() + } + + @Test + func changingFrameSizeUpdatesImage() async { + let fixture = ImageViewFixture(size: CGSize(width: 10, height: 10), data: .c) + + fixture.view.frame.size = CGSize(width: 15, height: 15) + + await fixture.verifyRenderedImage() + } + + @Test + func changingBoundsSizeUpdatesImage() async { + let fixture = ImageViewFixture(size: CGSize(width: 10, height: 10), data: .c) + + fixture.view.bounds.size = CGSize(width: 15, height: 15) + + await fixture.verifyRenderedImage() + } + + @Test + func updatingDataResetsImageBeforeRenderingReplacement() async { + let fixture = ImageViewFixture(size: CGSize(width: 10, height: 10), data: .c) + await fixture.verifyRenderedImage() + + fixture.view.data = .a + + #expect(fixture.view.image == nil) + await fixture.verifyRenderedImage() + } + + @Test + func updatingFrameResetsImageBeforeRenderingReplacement() async { + let fixture = ImageViewFixture(size: CGSize(width: 10, height: 10), data: .c) + await fixture.verifyRenderedImage() + + fixture.view.frame.size = CGSize(width: 15, height: 15) + + #expect(fixture.view.image == nil) + await fixture.verifyRenderedImage() + } + + @Test + func settingDataToNilResetsImage() async { + let fixture = ImageViewFixture(size: CGSize(width: 10, height: 10), data: .c) + await fixture.verifyRenderedImage() + + fixture.view.data = nil + + #expect(fixture.view.image == nil) + } + + @Test + func invalidSizeDoesNotRender() async { + let fixture = ImageViewFixture() + + fixture.view.data = .a + fixture.view.frame.size = CGSize(width: 10, height: 0) + + let didRender = await eventuallyOnMainActor { fixture.view.image != nil } + #expect(!didRender) + #expect(fixture.renderer.renderedImages.value.isEmpty) + } + + @Test + func unchangedDataRendersOnlyOnce() async { + let fixture = ImageViewFixture() + + fixture.view.data = .a + fixture.view.frame.size = CGSize(width: 10, height: 10) + fixture.view.data = .a + + await fixture.verifySingleRender() + } + + @Test + func unchangedSizeRendersOnlyOnce() async { + let fixture = ImageViewFixture() + + fixture.view.data = .a + fixture.view.frame = CGRect(origin: .zero, size: CGSize(width: 10, height: 10)) + fixture.view.frame.origin.x = 1 + + await fixture.verifySingleRender() + } + + @Test + func placeholderThenRealImageAreDisplayedInOrder() async { + let fixture = PlaceholderFixture() + let renderData = fixture.prepare(.a) + + fixture.view.data = .a + #expect(fixture.view.image == nil) + + fixture.placeholderRenderer.emitImage(for: renderData, scale: TestData.a.placeholderScale) + await fixture.verifyPlaceholder() + + fixture.renderer.emitImage(for: renderData, scale: TestData.a.rawValue) + await fixture.verifyRealImage() + } + + @Test + func updatingDataKeepsPreviousPlaceholderUntilReplacementArrives() async { + let fixture = PlaceholderFixture() + let originalRenderData = fixture.prepare(.a) + _ = fixture.prepare(.b) + fixture.view.data = .a + + fixture.placeholderRenderer.emitImage(for: originalRenderData, scale: TestData.a.placeholderScale) + await fixture.verifyPlaceholder() + + fixture.view.data = .b + + await fixture.verifyPlaceholder(scale: TestData.a.placeholderScale) + } + + @Test + func updatingDataDisplaysNewPlaceholder() async { + let fixture = PlaceholderFixture() + let originalRenderData = fixture.prepare(.a) + let updatedRenderData = fixture.prepare(.b) + fixture.view.data = .a + fixture.renderer.emitImage(for: originalRenderData, scale: TestData.a.rawValue) + await fixture.verifyRealImage() + + fixture.view.data = .b + fixture.placeholderRenderer.emitImage(for: updatedRenderData, scale: TestData.b.placeholderScale) + + await fixture.verifyPlaceholder() + } + + @Test + func placeholderViewResetsImageWhenDataBecomesNil() async { + let fixture = PlaceholderFixture(size: CGSize(width: 10, height: 10)) + let renderData = fixture.prepare(.a) + fixture.view.data = .a + fixture.renderer.emitImage(for: renderData, scale: TestData.a.rawValue) + await fixture.verifyRealImage() + + fixture.view.data = nil + + #expect(fixture.view.image == nil) + } + + @Test + func placeholderDisplaysIfMainRendererCompletesFirst() async { + let fixture = PlaceholderFixture() + let renderData = fixture.prepare(.a) + fixture.view.data = .a + + fixture.renderer.complete(renderData) + fixture.placeholderRenderer.emitImage(for: renderData, scale: TestData.a.placeholderScale) + + await fixture.verifyPlaceholder() + } + + @Test + func completingMainRendererDoesNotResetDisplayedPlaceholder() async { + let fixture = PlaceholderFixture() + let renderData = fixture.prepare(.a) + fixture.view.data = .a + fixture.placeholderRenderer.emitImage(for: renderData, scale: TestData.a.placeholderScale) + await fixture.verifyPlaceholder() + + fixture.renderer.complete(renderData) + + await fixture.verifyPlaceholder() + } +} + +@MainActor +private final class ImageViewFixture { + typealias ViewType = AsyncImageView + + let renderer = TestRenderer() + let view: ViewType + private let window = UIWindow() + + init(size: CGSize = .zero, data: TestData? = nil) { + self.view = ViewType( + initialFrame: CGRect(origin: .zero, size: size), + renderer: self.renderer, + placeholderRenderer: nil, + uiScheduler: QueueScheduler(targeting: DispatchQueue.main), + imageCreationScheduler: ImmediateScheduler() + ) + self.window.addSubview(self.view) + self.view.data = data + } + + func verifyRenderedImage() async { + await verifyImage( + self.view.image, + withSize: self.view.frame.size, + data: self.view.data + ) + } + + func verifySingleRender() async { + await self.verifyRenderedImage() + #expect( + self.renderer.renderedImages.value == [ + TestRenderData(data: self.view.data!, size: self.view.frame.size) + ] + ) + } +} + +@MainActor +private final class PlaceholderFixture { + typealias ViewType = AsyncImageView + + let placeholderRenderer = ManualRenderer() + let renderer = ManualRenderer() + let view: ViewType + private let window = UIWindow() + + init(size: CGSize = CGSize(width: 1, height: 1)) { + self.view = ViewType( + initialFrame: CGRect(origin: .zero, size: size), + renderer: self.renderer, + placeholderRenderer: self.placeholderRenderer, + uiScheduler: QueueScheduler(targeting: DispatchQueue.main), + imageCreationScheduler: ImmediateScheduler() + ) + self.window.addSubview(self.view) + } + + func prepare(_ data: TestData) -> TestRenderData { + let renderData = data.renderDataWithSize(self.view.frame.size) + self.placeholderRenderer.addSignal(for: renderData) + self.renderer.addSignal(for: renderData) + return renderData + } + + func verifyRealImage() async { + await verifyImage( + self.view.image, + withSize: self.view.frame.size, + data: self.view.data! + ) + } + + func verifyPlaceholder(scale: CGFloat? = nil) async { + await verifyImage( + self.view.image, + withSize: self.view.frame.size, + expectedScale: scale ?? self.view.data!.placeholderScale + ) + } +} + +private final class ManualRenderer: RendererType { + private var signals: [ + TestRenderData: ( + output: Signal, + input: Signal.Observer + ) + ] = [:] + + func addSignal(for data: TestRenderData) { + self.signals[data] = Signal.pipe() + } + + func emitImage(for data: TestRenderData, scale: CGFloat) { + let image = TestRenderer.rendererForSize(data.size, scale: scale) + .renderImageWithData(data) + let observer = self.observer(for: data) + + observer.send(value: image) + observer.sendCompleted() + } + + func complete(_ data: TestRenderData) { + // AsyncImageView renderers cannot fail, so failure is represented by + // completing without producing an image. + self.observer(for: data).sendCompleted() + } + + func renderImageWithData(_ data: TestRenderData) -> SignalProducer { + guard let signal = self.signals[data]?.output else { + Issue.record("Signal not created for \(data)") + return .empty + } + + return SignalProducer(signal) + } + + private func observer(for data: TestRenderData) -> Signal.Observer { + guard let observer = self.signals[data]?.input else { + preconditionFailure("Signal not created for \(data)") + } + + return observer + } +} + +private extension TestData { + var placeholderScale: CGFloat { + self.rawValue * 5 + } +} diff --git a/AsyncImageViewTests/AsyncSwiftUIImageViewSpec.swift b/AsyncImageViewTests/AsyncSwiftUIImageViewSpec.swift deleted file mode 100644 index bfbe47c..0000000 --- a/AsyncImageViewTests/AsyncSwiftUIImageViewSpec.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Quick -import Nimble -import SwiftUI -import UIKit - -import ReactiveSwift - -import AsyncImageView - -class AsyncSwiftUIImageViewSpec: QuickSpec { - override class func spec() { - describe("AsyncSwiftUIImageView") { - it("requests the full proposed size") { - let renderer = SquareImageRenderer() - typealias ViewType = AsyncSwiftUIImageView< - TestRenderData, - TestData, - SquareImageRenderer, - SquareImageRenderer - > - let view = ViewType( - renderer: renderer, - placeholderRenderer: nil, - uiScheduler: ImmediateScheduler(), - imageCreationScheduler: ImmediateScheduler() - ) - .data(.a) - .frame(width: 300, height: 100) - let viewController = UIHostingController(rootView: view) - let window = UIWindow(frame: CGRect(origin: .zero, size: CGSize(width: 300, height: 100))) - window.rootViewController = viewController - window.makeKeyAndVisible() - viewController.view.frame = window.bounds - viewController.view.layoutIfNeeded() - - let expectedRequest = TestRenderData(data: .a, size: window.bounds.size) - expect(renderer.renderedImages.value).toEventually(equal([expectedRequest])) - - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) - viewController.view.layoutIfNeeded() - - expect(renderer.renderedImages.value) == [expectedRequest] - } - } - } -} - -private final class SquareImageRenderer: RendererType { - let renderedImages = Atomic<[TestRenderData]>([]) - - func renderImageWithData(_ data: TestRenderData) -> SignalProducer { - TestRenderer.rendererForSize(CGSize(width: 100, height: 100), scale: 1) - .asyncRenderer(ImmediateScheduler()) - .renderImageWithData(data) - .on(started: { - self.renderedImages.modify { $0.append(data) } - }) - } -} diff --git a/AsyncImageViewTests/AsyncSwiftUIImageViewStateSpec.swift b/AsyncImageViewTests/AsyncSwiftUIImageViewStateSpec.swift deleted file mode 100644 index 90d54d1..0000000 --- a/AsyncImageViewTests/AsyncSwiftUIImageViewStateSpec.swift +++ /dev/null @@ -1,110 +0,0 @@ -import Quick -import Nimble -import SwiftUI -import UIKit - -import ReactiveSwift - -@testable import AsyncImageView - -class AsyncSwiftUIImageViewStateSpec: QuickSpec { - override class func spec() { - describe("AsyncSwiftUIImageView state") { - it("preserves every scheduler initializer combination") { - let renderer = StateTestRenderer() - - _ = StateTestImageView(renderer: renderer) - _ = StateTestImageView(renderer: renderer, uiScheduler: ImmediateScheduler()) - _ = StateTestImageView(renderer: renderer, imageCreationScheduler: ImmediateScheduler()) - _ = StateTestImageView( - renderer: renderer, - uiScheduler: ImmediateScheduler(), - imageCreationScheduler: ImmediateScheduler() - ) - } - - it("creates its retained schedulers only once across parent updates") { - let schedulerFactory = SchedulerFactory() - let renderer = StateTestRenderer() - let initialView = StateTestContainer( - generation: 0, - renderer: renderer, - schedulerFactory: schedulerFactory - ) - let viewController = UIHostingController(rootView: initialView) - let window = UIWindow(frame: CGRect(origin: .zero, size: CGSize(width: 100, height: 100))) - window.rootViewController = viewController - window.makeKeyAndVisible() - viewController.view.frame = window.bounds - viewController.view.layoutIfNeeded() - - for generation in 1...100 { - viewController.rootView = StateTestContainer( - generation: generation, - renderer: renderer, - schedulerFactory: schedulerFactory - ) - viewController.view.layoutIfNeeded() - } - - expect(schedulerFactory.uiSchedulerCount.value) == 1 - expect(schedulerFactory.imageSchedulerCount.value) == 1 - } - } - } -} - -private struct StateTestContainer: View { - let generation: Int - let renderer: StateTestRenderer - let schedulerFactory: SchedulerFactory - - var body: some View { - StateTestImageView( - renderer: self.renderer, - placeholderRenderer: nil, - uiSchedulerFactory: self.schedulerFactory.makeUIScheduler, - imageCreationSchedulerFactory: self.schedulerFactory.makeImageScheduler - ) - .frame(width: 100, height: 100) - .accessibilityIdentifier("generation-\(self.generation)") - } -} - -private typealias StateTestImageView = AsyncSwiftUIImageView< - StateTestRenderData, - StateTestViewData, - StateTestRenderer, - StateTestRenderer -> - -private final class SchedulerFactory { - let uiSchedulerCount = Atomic(0) - let imageSchedulerCount = Atomic(0) - - func makeUIScheduler() -> ReactiveSwift.Scheduler { - self.uiSchedulerCount.modify { $0 += 1 } - return ImmediateScheduler() - } - - func makeImageScheduler() -> ReactiveSwift.Scheduler { - self.imageSchedulerCount.modify { $0 += 1 } - return ImmediateScheduler() - } -} - -private struct StateTestViewData: ImageViewDataType { - func renderDataWithSize(_ size: CGSize) -> StateTestRenderData { - StateTestRenderData(size: size) - } -} - -private struct StateTestRenderData: RenderDataType { - let size: CGSize -} - -private final class StateTestRenderer: RendererType { - func renderImageWithData(_ data: StateTestRenderData) -> SignalProducer { - SignalProducer(value: UIImage()) - } -} diff --git a/AsyncImageViewTests/AsyncSwiftUIImageViewStateTests.swift b/AsyncImageViewTests/AsyncSwiftUIImageViewStateTests.swift new file mode 100644 index 0000000..4790f23 --- /dev/null +++ b/AsyncImageViewTests/AsyncSwiftUIImageViewStateTests.swift @@ -0,0 +1,108 @@ +import SwiftUI +import UIKit + +import ReactiveSwift +import Testing + +@testable import AsyncImageView + +@Suite @MainActor +struct AsyncSwiftUIImageViewStateTests { + @Test + func supportsEverySchedulerInitializerCombination() { + let renderer = StateTestRenderer() + + _ = StateTestImageView(renderer: renderer) + _ = StateTestImageView(renderer: renderer, uiScheduler: ImmediateScheduler()) + _ = StateTestImageView(renderer: renderer, imageCreationScheduler: ImmediateScheduler()) + _ = StateTestImageView( + renderer: renderer, + uiScheduler: ImmediateScheduler(), + imageCreationScheduler: ImmediateScheduler() + ) + } + + @Test + func createsRetainedSchedulersOnceAcrossParentUpdates() { + let schedulerFactory = SchedulerFactory() + let renderer = StateTestRenderer() + let initialView = StateTestContainer( + generation: 0, + renderer: renderer, + schedulerFactory: schedulerFactory + ) + let viewController = UIHostingController(rootView: initialView) + let window = UIWindow(frame: CGRect(origin: .zero, size: CGSize(width: 100, height: 100))) + window.rootViewController = viewController + window.makeKeyAndVisible() + viewController.view.frame = window.bounds + viewController.view.layoutIfNeeded() + + for generation in 1...100 { + viewController.rootView = StateTestContainer( + generation: generation, + renderer: renderer, + schedulerFactory: schedulerFactory + ) + viewController.view.layoutIfNeeded() + } + + #expect(schedulerFactory.uiSchedulerCount.value == 1) + #expect(schedulerFactory.imageSchedulerCount.value == 1) + } +} + +private struct StateTestContainer: View { + let generation: Int + let renderer: StateTestRenderer + let schedulerFactory: SchedulerFactory + + var body: some View { + StateTestImageView( + renderer: self.renderer, + placeholderRenderer: nil, + uiSchedulerFactory: self.schedulerFactory.makeUIScheduler, + imageCreationSchedulerFactory: self.schedulerFactory.makeImageScheduler + ) + .frame(width: 100, height: 100) + .accessibilityIdentifier("generation-\(self.generation)") + } +} + +private typealias StateTestImageView = AsyncSwiftUIImageView< + StateTestRenderData, + StateTestViewData, + StateTestRenderer, + StateTestRenderer +> + +private final class SchedulerFactory { + let uiSchedulerCount = Atomic(0) + let imageSchedulerCount = Atomic(0) + + func makeUIScheduler() -> ReactiveSwift.Scheduler { + self.uiSchedulerCount.modify { $0 += 1 } + return ImmediateScheduler() + } + + func makeImageScheduler() -> ReactiveSwift.Scheduler { + self.imageSchedulerCount.modify { $0 += 1 } + return ImmediateScheduler() + } +} + +private struct StateTestViewData: ImageViewDataType { + func renderDataWithSize(_ size: CGSize) -> StateTestRenderData { + StateTestRenderData(size: size) + } +} + +private struct StateTestRenderData: RenderDataType { + let size: CGSize +} + +private final class StateTestRenderer: RendererType { + func renderImageWithData(_ data: StateTestRenderData) -> SignalProducer { + SignalProducer(value: UIImage()) + } +} diff --git a/AsyncImageViewTests/AsyncSwiftUIImageViewTests.swift b/AsyncImageViewTests/AsyncSwiftUIImageViewTests.swift new file mode 100644 index 0000000..2a6c4ba --- /dev/null +++ b/AsyncImageViewTests/AsyncSwiftUIImageViewTests.swift @@ -0,0 +1,56 @@ +import SwiftUI +import UIKit + +import ReactiveSwift +import Testing + +import AsyncImageView + +@Suite @MainActor +struct AsyncSwiftUIImageViewTests { + @Test + func requestsFullProposedSizeOnlyOnce() { + let renderer = SquareImageRenderer() + typealias ViewType = AsyncSwiftUIImageView< + TestRenderData, + TestData, + SquareImageRenderer, + SquareImageRenderer + > + let view = ViewType( + renderer: renderer, + placeholderRenderer: nil, + uiScheduler: ImmediateScheduler(), + imageCreationScheduler: ImmediateScheduler() + ) + .data(.a) + .frame(width: 300, height: 100) + let viewController = UIHostingController(rootView: view) + let window = UIWindow(frame: CGRect(origin: .zero, size: CGSize(width: 300, height: 100))) + window.rootViewController = viewController + window.makeKeyAndVisible() + viewController.view.frame = window.bounds + viewController.view.layoutIfNeeded() + + let expectedRequest = TestRenderData(data: .a, size: window.bounds.size) + #expect(eventually { renderer.renderedImages.value == [expectedRequest] }) + + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + viewController.view.layoutIfNeeded() + + #expect(renderer.renderedImages.value == [expectedRequest]) + } +} + +private final class SquareImageRenderer: RendererType { + let renderedImages = Atomic<[TestRenderData]>([]) + + func renderImageWithData(_ data: TestRenderData) -> SignalProducer { + TestRenderer.rendererForSize(CGSize(width: 100, height: 100), scale: 1) + .asyncRenderer(ImmediateScheduler()) + .renderImageWithData(data) + .on(started: { + self.renderedImages.modify { $0.append(data) } + }) + } +} diff --git a/AsyncImageViewTests/CacheRendererSpec.swift b/AsyncImageViewTests/CacheRendererSpec.swift deleted file mode 100644 index 6a6ba2d..0000000 --- a/AsyncImageViewTests/CacheRendererSpec.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// CacheRendererSpec.swift -// AsyncImageView -// -// Created by Nacho Soto on 7/10/26. -// Copyright © 2026 Nacho Soto. All rights reserved. -// - -import Quick -import Nimble -import UIKit - -@testable import AsyncImageView - -class CacheRendererSpec: QuickSpec { - override class func spec() { - describe("CacheRenderer") { - it("does not cache the original image after inflation fails") { - let data = TestRenderData( - data: .a, - size: CGSize(width: 20, height: 30) - ) - let sourceRenderer = TestRenderer() - let cache = InMemoryCache(cacheName: #function) - var shouldFailContextCreation = true - - let renderer = ImageInflaterRenderer( - renderer: sourceRenderer, - screenScale: 2, - opaque: false, - bitmapContextFactory: { width, height, bytesPerRow, colorSpace, bitmapInfo in - guard !shouldFailContextCreation else { return nil } - - return UIImage.makeBitmapContext( - width: width, - height: height, - bytesPerRow: bytesPerRow, - colorSpace: colorSpace, - bitmapInfo: bitmapInfo - ) - } - ) - .withCache(cache) - - let fallbackResult = renderer.renderImageWithData(data).single()?.get() - - expect(fallbackResult?.image.scale) == data.data.rawValue - expect(fallbackResult?.shouldCache) == false - expect(cache.valueForKey(data)).to(beNil()) - expect(sourceRenderer.renderedImages.value.count) == 1 - - shouldFailContextCreation = false - - let inflatedResult = renderer.renderImageWithData(data).single()?.get() - - expect(inflatedResult?.image.scale) == 2 - expect(inflatedResult?.shouldCache) == true - expect(cache.valueForKey(data)).toNot(beNil()) - expect(sourceRenderer.renderedImages.value.count) == 2 - - let cachedResult = renderer.renderImageWithData(data).single()?.get() - - expect(cachedResult?.cacheHit) == true - expect(sourceRenderer.renderedImages.value.count) == 2 - } - - it("does not cache the original image after processing output creation fails") { - let data = TestRenderData( - data: .a, - size: CGSize(width: 20, height: 30) - ) - let sourceRenderer = TestRenderer() - let cache = InMemoryCache(cacheName: #function) - var shouldFailImageCreation = true - - let renderer = ImageProcessingRenderer( - renderer: sourceRenderer, - scale: 2, - opaque: false, - renderingBlock: { _, _, _, _, imageDrawing in - imageDrawing() - }, - bitmapImageFactory: { context in - guard !shouldFailImageCreation else { return nil } - - return context.makeImage() - } - ) - .withCache(cache) - - let fallbackResult = renderer.renderImageWithData(data).single()?.get() - - expect(fallbackResult?.image.scale) == data.data.rawValue - expect(fallbackResult?.shouldCache) == false - expect(cache.valueForKey(data)).to(beNil()) - expect(sourceRenderer.renderedImages.value.count) == 1 - - shouldFailImageCreation = false - - let processedResult = renderer.renderImageWithData(data).single()?.get() - - expect(processedResult?.image.scale) == 2 - expect(processedResult?.shouldCache) == true - expect(cache.valueForKey(data)).toNot(beNil()) - expect(sourceRenderer.renderedImages.value.count) == 2 - - let cachedResult = renderer.renderImageWithData(data).single()?.get() - - expect(cachedResult?.cacheHit) == true - expect(sourceRenderer.renderedImages.value.count) == 2 - } - } - } -} diff --git a/AsyncImageViewTests/CacheRendererTests.swift b/AsyncImageViewTests/CacheRendererTests.swift new file mode 100644 index 0000000..efa49f0 --- /dev/null +++ b/AsyncImageViewTests/CacheRendererTests.swift @@ -0,0 +1,106 @@ +// +// CacheRendererSpec.swift +// AsyncImageView +// +// Created by Nacho Soto on 7/10/26. +// Copyright © 2026 Nacho Soto. All rights reserved. +// + +import UIKit + +import ReactiveSwift +import Testing + +@testable import AsyncImageView + +@Suite +struct CacheRendererTests { + private let fixture = CacheFailureFixture() + + @Test + func failedInflationIsNotCached() { + var shouldFail = true + let renderer = ImageInflaterRenderer( + renderer: self.fixture.sourceRenderer, + screenScale: 2, + opaque: false, + bitmapContextFactory: { width, height, bytesPerRow, colorSpace, bitmapInfo in + guard !shouldFail else { return nil } + + return UIImage.makeBitmapContext( + width: width, + height: height, + bytesPerRow: bytesPerRow, + colorSpace: colorSpace, + bitmapInfo: bitmapInfo + ) + } + ) + .withCache(self.fixture.cache) + + self.fixture.verifyFailureThenSuccess(renderer: renderer) { + shouldFail = false + } + } + + @Test + func failedProcessingOutputIsNotCached() { + var shouldFail = true + let renderer = ImageProcessingRenderer( + renderer: self.fixture.sourceRenderer, + scale: 2, + opaque: false, + renderingBlock: { _, _, _, _, imageDrawing in + imageDrawing() + }, + bitmapImageFactory: { context in + guard !shouldFail else { return nil } + + return context.makeImage() + } + ) + .withCache(self.fixture.cache) + + self.fixture.verifyFailureThenSuccess(renderer: renderer) { + shouldFail = false + } + } +} + +private final class CacheFailureFixture { + let data = TestRenderData( + data: .a, + size: CGSize(width: 20, height: 30) + ) + let sourceRenderer = TestRenderer() + let cache = InMemoryCache(cacheName: #file) + + func verifyFailureThenSuccess( + renderer: Renderer, + enableSuccessfulRendering: () -> Void + ) where + Renderer.Data == TestRenderData, + Renderer.RenderResult == ImageResult, + Renderer.Error == Never { + let fallbackResult = renderer.renderImageWithData(self.data).single()?.get() + + #expect(fallbackResult?.image.scale == self.data.data.rawValue) + #expect(fallbackResult?.shouldCache == false) + #expect(self.cache.valueForKey(self.data) == nil) + #expect(self.sourceRenderer.renderedImages.value.count == 1) + + enableSuccessfulRendering() + + let processedResult = renderer.renderImageWithData(self.data).single()?.get() + + #expect(processedResult?.image.scale == 2) + #expect(processedResult?.shouldCache == true) + #expect(self.cache.valueForKey(self.data) != nil) + #expect(self.sourceRenderer.renderedImages.value.count == 2) + + let cachedResult = renderer.renderImageWithData(self.data).single()?.get() + + #expect(cachedResult?.cacheHit == true) + #expect(self.sourceRenderer.renderedImages.value.count == 2) + } +} diff --git a/AsyncImageViewTests/CachingSpec.swift b/AsyncImageViewTests/CachingSpec.swift deleted file mode 100644 index bad5644..0000000 --- a/AsyncImageViewTests/CachingSpec.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// CachingSpec.swift -// AsyncImageView -// -// Created by Nacho Soto on 11/24/15. -// Copyright © 2015 Nacho Soto. All rights reserved. -// - -import Quick -import Nimble -import Foundation -import CoreGraphics - -@testable import AsyncImageView - -class InMemoryCacheSpec: QuickSpec { - override class func spec() { - describe("InMemoryCache") { - var cache: InMemoryCache! - - beforeEach { - cache = InMemoryCache(cacheName: "test") - } - - it("returns nil when not cached") { - expect(cache.valueForKey(UUID().uuidString)).to(beNil()) - } - - it("recovers value after saving it") { - let key = UUID().uuidString - let value = String.randomReadableString() - - cache.setValue(value, forKey: key) - - expect(cache.valueForKey(key)) == value - } - - it("values don't override") { - let key1 = UUID().uuidString - let key2 = UUID().uuidString - let value1 = String.randomReadableString() - let value2 = String.randomReadableString() - - cache.setValue(value1, forKey: key1) - cache.setValue(value2, forKey: key2) - - expect(cache.valueForKey(key1)) == value1 - expect(cache.valueForKey(key2)) == value2 - } - - it("can remove a value") { - let key = UUID().uuidString - let value = String.randomReadableString() - - cache.setValue(value, forKey: key) - cache.setValue(nil, forKey: key) - - expect(cache.valueForKey(key)).to(beNil()) - } - } - } -} - -class DiskCacheSpec: QuickSpec { - override class func spec() { - describe("DiskCache") { - let directoryCreator = { - return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true) - } - - var cache: DiskCache! - - beforeEach { - cache = DiskCache(rootDirectory: directoryCreator()) - } - - it("returns nil when not cached") { - expect(cache.valueForKey(UUID().uuidString)).to(beNil()) - } - - it("recovers value after saving it") { - let key = UUID().uuidString - let value = String.randomReadableString() - - cache.setValue(value, forKey: key) - - expect(cache.valueForKey(key)) == value - } - - it("values don't override") { - let key1 = UUID().uuidString - let key2 = UUID().uuidString - let value1 = String.randomReadableString() - let value2 = String.randomReadableString() - - cache.setValue(value1, forKey: key1) - cache.setValue(value2, forKey: key2) - - expect(cache.valueForKey(key1)) == value1 - expect(cache.valueForKey(key2)) == value2 - } - - it("can remove a value") { - let key = UUID().uuidString - let value = String.randomReadableString() - - cache.setValue(value, forKey: key) - cache.setValue(nil, forKey: key) - - expect(cache.valueForKey(key)).to(beNil()) - } - - it("saves files in subdirectory") { - func readFile(_ url: URL) -> String? { - return (try? Data(contentsOf: url)) - .flatMap { NSString(data: $0, encoding: String.encoding.rawValue) as String? } - } - - let directory = directoryCreator() - let cache = DiskCache(rootDirectory: directory) - - cache.setValue("hello", forKey: "word") - cache.setValue("hi", forKey: "apple") - - expect(readFile(directory.appendingPathComponent("4").appendingPathComponent("word"))) == "hello" - expect(readFile(directory.appendingPathComponent("5").appendingPathComponent("apple"))) == "hi" - } - } - } -} - -class RenderDataTypeCacheSubdirectorySpec: QuickSpec { - override class func spec() { - it("works with integer sizes") { - expect(subdirectoryForSize(CGSize(width: 15.0, height: 10.0))) == "15.00x10.00" - } - - it("has limited precision") { - expect(subdirectoryForSize(CGSize(width: 15.1245, height: 10.6123))) == "15.12x10.61" - } - - it("does not depend on the current locale's decimal separator") { - let locale = Locale(identifier: "fr_FR") - expect(subdirectoryForSize(CGSize(width: 15.1245, height: 10.6123), locale: locale)) == "15,12x10,61" - expect(subdirectoryForSize(CGSize(width: 15.1245, height: 10.6123))) == "15.12x10.61" - } - } -} - -extension String: @retroactive DataFileType { - public var subdirectory: String? { - return "\(self.count)" - } - - public var uniqueFilename: String { - return self - } -} - -extension String: @retroactive NSDataConvertible { - public init?(data: Data) { - self.init(data: data, encoding: String.encoding) - } - - public var data: Data? { - return (self as NSString).data(using: String.encoding.rawValue) - } - - fileprivate static var encoding: String.Encoding { - return .utf8 - } -} diff --git a/AsyncImageViewTests/CachingTests.swift b/AsyncImageViewTests/CachingTests.swift new file mode 100644 index 0000000..87db04d --- /dev/null +++ b/AsyncImageViewTests/CachingTests.swift @@ -0,0 +1,112 @@ +// +// CachingSpec.swift +// AsyncImageView +// +// Created by Nacho Soto on 11/24/15. +// Copyright © 2015 Nacho Soto. All rights reserved. +// + +import CoreGraphics +import Foundation +import Testing + +@testable import AsyncImageView + +@Suite +struct CachingTests { + @Test + func inMemoryCacheFulfillsCacheContract() { + let cache = InMemoryCache(cacheName: #function) + + verifyCacheContract(cache) + } + + @Test + func diskCacheFulfillsCacheContract() { + let cache = DiskCache(rootDirectory: makeTemporaryDirectory()) + + verifyCacheContract(cache) + } + + @Test + func diskCacheSavesFilesInValueSubdirectories() { + func readFile(_ url: URL) -> String? { + (try? Data(contentsOf: url)) + .flatMap { NSString(data: $0, encoding: String.Encoding.utf8.rawValue) as String? } + } + + let directory = makeTemporaryDirectory() + let cache = DiskCache(rootDirectory: directory) + + cache.setValue("hello", forKey: "word") + cache.setValue("hi", forKey: "apple") + + #expect(readFile(directory.appendingPathComponent("4").appendingPathComponent("word")) == "hello") + #expect(readFile(directory.appendingPathComponent("5").appendingPathComponent("apple")) == "hi") + } + + @Test( + arguments: [ + (CGSize(width: 15, height: 10), "15.00x10.00"), + (CGSize(width: 15.1245, height: 10.6123), "15.12x10.61") + ] + ) + func cacheSubdirectoryUsesFixedPrecision(size: CGSize, expected: String) { + #expect(subdirectoryForSize(size) == expected) + } + + @Test + func cacheSubdirectoryUsesStableDefaultLocale() { + let size = CGSize(width: 15.1245, height: 10.6123) + + #expect(subdirectoryForSize(size, locale: Locale(identifier: "fr_FR")) == "15,12x10,61") + #expect(subdirectoryForSize(size) == "15.12x10.61") + } +} + +private func verifyCacheContract(_ cache: Cache) +where Cache.Key == String, Cache.Value == String { + let missingKey = UUID().uuidString + let firstKey = UUID().uuidString + let secondKey = UUID().uuidString + let firstValue = "first-value" + let secondValue = "second-value" + + #expect(cache.valueForKey(missingKey) == nil) + + cache.setValue(firstValue, forKey: firstKey) + cache.setValue(secondValue, forKey: secondKey) + + #expect(cache.valueForKey(firstKey) == firstValue) + #expect(cache.valueForKey(secondKey) == secondValue) + + cache.setValue(nil, forKey: firstKey) + + #expect(cache.valueForKey(firstKey) == nil) + #expect(cache.valueForKey(secondKey) == secondValue) +} + +private func makeTemporaryDirectory() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true) +} + +extension String: DataFileType { + public var subdirectory: String? { + "\(self.count)" + } + + public var uniqueFilename: String { + self + } +} + +extension String: NSDataConvertible { + public init?(data: Data) { + self.init(data: data, encoding: .utf8) + } + + public var data: Data? { + (self as NSString).data(using: String.Encoding.utf8.rawValue) + } +} diff --git a/AsyncImageViewTests/Helpers.swift b/AsyncImageViewTests/Helpers.swift index e6cf3eb..91836a7 100644 --- a/AsyncImageViewTests/Helpers.swift +++ b/AsyncImageViewTests/Helpers.swift @@ -8,23 +8,49 @@ import UIKit -internal extension CGSize { - static func random() -> CGSize { - return CGSize( - width: Double.random(in: 1...200), - height: Double.random(in: 1...200) - ) - } +@discardableResult +internal func eventually( + timeout: TimeInterval = 1, + pollInterval: TimeInterval = 0.01, + _ condition: () -> Bool +) -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + + repeat { + if condition() { + return true + } + + RunLoop.current.run(until: Date(timeIntervalSinceNow: pollInterval)) + } while Date() < deadline + + return condition() } -fileprivate let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") +@MainActor +internal func eventuallyOnMainActor( + timeout: TimeInterval = 1, + pollInterval: TimeInterval = 0.01, + _ condition: () -> Bool +) async -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + + repeat { + if condition() { + return true + } + + try? await Task.sleep(for: .seconds(pollInterval)) + } while Date() < deadline + + return condition() +} -internal extension String { - static func randomReadableString() -> String { - let length = Int.random(in: 1...15) - return String( - (1.. Bool { + abs(self.origin.x - other.origin.x) <= tolerance && + abs(self.origin.y - other.origin.y) <= tolerance && + abs(self.size.width - other.size.width) <= tolerance && + abs(self.size.height - other.size.height) <= tolerance } } diff --git a/AsyncImageViewTests/ImageInflaterRendererSpec.swift b/AsyncImageViewTests/ImageInflaterRendererSpec.swift deleted file mode 100644 index 4905dbe..0000000 --- a/AsyncImageViewTests/ImageInflaterRendererSpec.swift +++ /dev/null @@ -1,382 +0,0 @@ -// -// ImageInflaterRendererSpec.swift -// AsyncImageView -// -// Created by Nacho Soto on 11/28/15. -// Copyright © 2015 Nacho Soto. All rights reserved. -// - -import Quick -import Nimble -import CoreGraphics -import UIKit - -@testable import AsyncImageView - -class ImageInflaterRendererSpec: QuickSpec { - override class func spec() { - describe("ImageInflaterRenderer") { - context("bitmap context creation") { - it("returns the original image if context creation fails") { - let image = UIImage() - var didAttemptContextCreation = false - var didRender = false - - let result = image.processImageWithBitmapContext( - withSize: CGSize(width: 20, height: 30), - scale: 2, - opaque: false, - contentMode: .aspectFill, - bitmapContextFactory: { _, _, _, _, _ in - didAttemptContextCreation = true - - return nil - }, - renderingBlock: { _, _, _, _ in - didRender = true - } - ) - - expect(didAttemptContextCreation) == true - expect(didRender) == false - expect(result.image).to(beIdenticalTo(image)) - expect(result.didProcess) == false - } - } - - context("Aspect Fit") { - it("returns identity frame if sizes match") { - let size = CGSize.random() - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: size, - inSize: size - ) - - expect(result) == CGRect(origin: CGPoint.zero, size: size) - } - - it("reduces size if aspect ratio matches, but canvas is smaller") { - let imageSize = CGSize.random() - let canvasSize = CGSize(width: imageSize.width * 0.4, height: imageSize.height * 0.4) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: imageSize, - inSize: canvasSize - ) - - expectRect(result, toBeCloseTo: CGRect(origin: CGPoint.zero, size: canvasSize)) - } - - it("scales up size if aspect ratio matches, but canvas is bigger") { - let imageSize = CGSize.random() - let canvasSize = CGSize(width: imageSize.width * 2, height: imageSize.height * 2) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: imageSize, - inSize: canvasSize - ) - - expectRect(result, toBeCloseTo: CGRect(origin: CGPoint.zero, size: canvasSize)) - } - - it("scales and centers image vertically if height matches, but canvas width is smaller") { - let imageSize = CGSize(width: 1242, height: 240) - let canvasSize = CGSize(width: 750, height: 240) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: imageSize, - inSize: canvasSize - ) - - let expectedHeight = canvasSize.height * (canvasSize.width / imageSize.width) // preserve aspect ratio - - expect(result.origin) == CGPoint(x: 0, y: (expectedHeight - canvasSize.height) / -2.0) - expect(result.size.width).to(beCloseTo(canvasSize.width)) - expect(result.size.height).to(beCloseTo(expectedHeight)) } - - it("centers horizontally if height matches, but canvas width is bigger") { - let imageSize = CGSize(width: 1242, height: 240) - let canvasSize = CGSize(width: 1334, height: 240) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin) == CGPoint(x: (imageSize.width - canvasSize.width) / -2.0, y: 0) - expect(result.size) == imageSize - } - - it("scales and centers image horizontally if width matches, but canvas height is smaller") { - let imageSize = CGSize(width: 1242, height: 240) - let canvasSize = CGSize(width: 1242, height: 100) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: imageSize, - inSize: canvasSize - ) - - let expectedWidth = canvasSize.width * (canvasSize.height / imageSize.height) // preserve aspect ratio - - expect(result.origin) == CGPoint(x: (expectedWidth - canvasSize.width) / -2.0, y: 0) - expect(result.size.width).to(beCloseTo(expectedWidth)) - expect(result.size.height).to(beCloseTo(canvasSize.height)) - } - - it("centers vertically if width matches, but canvas height is bigger") { - let imageSize = CGSize(width: 1242, height: 240) - let canvasSize = CGSize(width: 1242, height: 300) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin) == CGPoint(x: 0, y: (imageSize.height - canvasSize.height) / -2.0) - expect(result.size) == imageSize - } - - context("aspect ratio and image size are different") { - context("image size is smaller") { - it("image aspect ratio is smaller") { - let imageSize = CGSize(width: 30, height: 40) - let canvasSize = CGSize(width: 50, height: 60) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin.x).to(beCloseTo(2.5)) - expect(result.origin.y).to(beCloseTo(0)) - expect(result.size.width).to(beCloseTo(45)) - expect(result.size.height).to(beCloseTo(60)) - } - - it("image aspect ratio is bigger") { - let imageSize = CGSize(width: 50, height: 60) - let canvasSize = CGSize(width: 60, height: 80) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin.x).to(beCloseTo(0)) - expect(result.origin.y).to(beCloseTo(4)) - expect(result.size.width).to(beCloseTo(60)) - expect(result.size.height).to(beCloseTo(72)) - } - } - - context("image size is bigger") { - it("image aspect ratio is smaller") { - let imageSize = CGSize(width: 60, height: 80) - let canvasSize = CGSize(width: 50, height: 60) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin.x).to(beCloseTo(2.5)) - expect(result.origin.y).to(beCloseTo(0)) - expect(result.size.width).to(beCloseTo(45)) - expect(result.size.height).to(beCloseTo(60)) - } - - it("image aspect ratio is bigger") { - let imageSize = CGSize(width: 100, height: 120) - let canvasSize = CGSize(width: 60, height: 80) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin.x).to(beCloseTo(0)) - expect(result.origin.y).to(beCloseTo(4)) - expect(result.size.width).to(beCloseTo(60)) - expect(result.size.height).to(beCloseTo(72)) - } - } - } - } - - context("Aspect Fill") { - it("returns identity frame if sizes match") { - let size = CGSize.random() - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: size, - inSize: size - ) - - expect(result) == CGRect(origin: CGPoint.zero, size: size) - } - - it("reduces size if aspect ratio matches, but canvas is smaller") { - let imageSize = CGSize.random() - let canvasSize = CGSize(width: imageSize.width * 0.4, height: imageSize.height * 0.4) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: imageSize, - inSize: canvasSize - ) - - expectRect(result, toBeCloseTo: CGRect(origin: CGPoint.zero, size: canvasSize)) - } - - it("scales up size if aspect ratio matches, but canvas is bigger") { - let imageSize = CGSize.random() - let canvasSize = CGSize(width: imageSize.width * 2, height: imageSize.height * 2) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: imageSize, - inSize: canvasSize - ) - - expectRect(result, toBeCloseTo: CGRect(origin: CGPoint.zero, size: canvasSize)) - } - - it("centers image horizontally if height matches, but canvas width is smaller") { - let imageSize = CGSize(width: 1242, height: 240) - let canvasSize = CGSize(width: 750, height: 240) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin) == CGPoint(x: (imageSize.width - canvasSize.width) / -2.0, y: 0) - expect(result.size) == imageSize - } - - it("scales image and centers horizontally if height matches, but canvas width is bigger") { - let imageSize = CGSize(width: 1242, height: 240) - let canvasSize = CGSize(width: 1334, height: 240) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: imageSize, - inSize: canvasSize - ) - - let expectedHeight = canvasSize.height * (canvasSize.width / imageSize.width) // preserve aspect ratio - - expect(result.origin.x).to(beCloseTo(0)) - expect(result.origin.y).to(beCloseTo((canvasSize.height - expectedHeight) / 2.0)) - expect(result.size.width).to(beCloseTo(canvasSize.width)) - expect(result.size.height).to(beCloseTo(expectedHeight)) - } - - it("centers image vertically if width matches, but canvas height is smaller") { - let imageSize = CGSize(width: 1242, height: 240) - let canvasSize = CGSize(width: 1242, height: 100) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin) == CGPoint(x: 0, y: (imageSize.height - canvasSize.height) / -2.0) - expect(result.size) == imageSize - } - - it("scales image and centers vertically if width matches, but canvas height is bigger") { - let imageSize = CGSize(width: 1242, height: 240) - let canvasSize = CGSize(width: 1242, height: 300) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: imageSize, - inSize: canvasSize - ) - - let expectedWidth = canvasSize.width * (canvasSize.height / imageSize.height) // preserve aspect ratio - - // TODO: write matcher for `CGRect`. - expect(result.origin.x).to(beCloseTo((canvasSize.width - expectedWidth) / 2.0)) - expect(result.origin.y).to(beCloseTo(0)) - expect(result.size.width).to(beCloseTo(expectedWidth)) - expect(result.size.height).to(beCloseTo(canvasSize.height)) - } - - context("aspect ratio and image size are different") { - context("image size is smaller") { - it("image aspect ratio is smaller") { - let imageSize = CGSize(width: 30, height: 40) - let canvasSize = CGSize(width: 50, height: 60) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin.x).to(beCloseTo(0)) - expect(result.origin.y).to(beCloseTo(-3.3333)) - expect(result.size.width).to(beCloseTo(50)) - expect(result.size.height).to(beCloseTo(66.6666)) - } - - it("image aspect ratio is bigger") { - let imageSize = CGSize(width: 50, height: 60) - let canvasSize = CGSize(width: 60, height: 80) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin.x).to(beCloseTo(-3.3333)) - expect(result.origin.y).to(beCloseTo(0)) - expect(result.size.width).to(beCloseTo(66.6666)) - expect(result.size.height).to(beCloseTo(80)) - } - } - - context("image size is bigger") { - it("image aspect ratio is smaller") { - let imageSize = CGSize(width: 60, height: 80) - let canvasSize = CGSize(width: 50, height: 60) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin.x).to(beCloseTo(0)) - expect(result.origin.y).to(beCloseTo(-3.3333)) - expect(result.size.width).to(beCloseTo(50)) - expect(result.size.height).to(beCloseTo(66.6666)) - } - - it("image aspect ratio is bigger") { - let imageSize = CGSize(width: 100, height: 120) - let canvasSize = CGSize(width: 60, height: 80) - - let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( - imageSize: imageSize, - inSize: canvasSize - ) - - expect(result.origin.x).to(beCloseTo(-3.3333)) - expect(result.origin.y).to(beCloseTo(0)) - expect(result.size.width).to(beCloseTo(66.6666)) - expect(result.size.height).to(beCloseTo(80)) - } - } - } - } - } - } -} - -private func expectRect( - _ actual: CGRect, - toBeCloseTo expected: CGRect, - file: FileString = #file, - line: UInt = #line -) { - expect(file: file, line: line, actual.origin.x).to(beCloseTo(expected.origin.x)) - expect(file: file, line: line, actual.origin.y).to(beCloseTo(expected.origin.y)) - expect(file: file, line: line, actual.size.width).to(beCloseTo(expected.size.width)) - expect(file: file, line: line, actual.size.height).to(beCloseTo(expected.size.height)) -} diff --git a/AsyncImageViewTests/ImageInflaterRendererTests.swift b/AsyncImageViewTests/ImageInflaterRendererTests.swift new file mode 100644 index 0000000..887627e --- /dev/null +++ b/AsyncImageViewTests/ImageInflaterRendererTests.swift @@ -0,0 +1,220 @@ +// +// ImageInflaterRendererTests.swift +// AsyncImageView +// +// Created by Nacho Soto on 11/28/15. +// Copyright © 2015 Nacho Soto. All rights reserved. +// + +import CoreGraphics +import UIKit + +import Testing + +@testable import AsyncImageView + +@Suite +struct ImageInflaterRendererTests { + @Test + func failedBitmapContextCreationReturnsOriginalImage() { + let image = UIImage() + var didAttemptContextCreation = false + var didRender = false + + let result = image.processImageWithBitmapContext( + withSize: CGSize(width: 20, height: 30), + scale: 2, + opaque: false, + contentMode: .aspectFill, + bitmapContextFactory: { _, _, _, _, _ in + didAttemptContextCreation = true + return nil + }, + renderingBlock: { _, _, _, _ in + didRender = true + } + ) + + #expect(didAttemptContextCreation) + #expect(!didRender) + #expect(result.image === image) + #expect(!result.didProcess) + } + + @Test(arguments: DrawingRectCase.aspectFitCases) + func aspectFitDrawingRect(testCase: DrawingRectCase) { + let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFit( + imageSize: testCase.imageSize, + inSize: testCase.canvasSize + ) + + #expect(result.isApproximatelyEqual(to: testCase.expected)) + } + + @Test(arguments: DrawingRectCase.aspectFillCases) + func aspectFillDrawingRect(testCase: DrawingRectCase) { + let result = InflaterSizeCalculator.drawingRectForRenderingWithAspectFill( + imageSize: testCase.imageSize, + inSize: testCase.canvasSize + ) + + #expect(result.isApproximatelyEqual(to: testCase.expected)) + } +} + +struct DrawingRectCase: CustomStringConvertible { + let description: String + let imageSize: CGSize + let canvasSize: CGSize + let expected: CGRect + + init( + _ description: String, + imageSize: CGSize, + canvasSize: CGSize, + expected: CGRect + ) { + self.description = description + self.imageSize = imageSize + self.canvasSize = canvasSize + self.expected = expected + } + + static let aspectFitCases: [Self] = [ + Self( + "matching sizes", + imageSize: CGSize(width: 120, height: 80), + canvasSize: CGSize(width: 120, height: 80), + expected: CGRect(x: 0, y: 0, width: 120, height: 80) + ), + Self( + "proportional reduction", + imageSize: CGSize(width: 120, height: 80), + canvasSize: CGSize(width: 48, height: 32), + expected: CGRect(x: 0, y: 0, width: 48, height: 32) + ), + Self( + "proportional enlargement", + imageSize: CGSize(width: 120, height: 80), + canvasSize: CGSize(width: 240, height: 160), + expected: CGRect(x: 0, y: 0, width: 240, height: 160) + ), + Self( + "narrower canvas with matching height", + imageSize: CGSize(width: 1242, height: 240), + canvasSize: CGSize(width: 750, height: 240), + expected: CGRect(x: 0, y: 47.5362318841, width: 750, height: 144.9275362319) + ), + Self( + "wider canvas with matching height", + imageSize: CGSize(width: 1242, height: 240), + canvasSize: CGSize(width: 1334, height: 240), + expected: CGRect(x: 46, y: 0, width: 1242, height: 240) + ), + Self( + "shorter canvas with matching width", + imageSize: CGSize(width: 1242, height: 240), + canvasSize: CGSize(width: 1242, height: 100), + expected: CGRect(x: 362.25, y: 0, width: 517.5, height: 100) + ), + Self( + "taller canvas with matching width", + imageSize: CGSize(width: 1242, height: 240), + canvasSize: CGSize(width: 1242, height: 300), + expected: CGRect(x: 0, y: 30, width: 1242, height: 240) + ), + Self( + "smaller image with narrower aspect ratio", + imageSize: CGSize(width: 30, height: 40), + canvasSize: CGSize(width: 50, height: 60), + expected: CGRect(x: 2.5, y: 0, width: 45, height: 60) + ), + Self( + "smaller image with wider aspect ratio", + imageSize: CGSize(width: 50, height: 60), + canvasSize: CGSize(width: 60, height: 80), + expected: CGRect(x: 0, y: 4, width: 60, height: 72) + ), + Self( + "larger image with narrower aspect ratio", + imageSize: CGSize(width: 60, height: 80), + canvasSize: CGSize(width: 50, height: 60), + expected: CGRect(x: 2.5, y: 0, width: 45, height: 60) + ), + Self( + "larger image with wider aspect ratio", + imageSize: CGSize(width: 100, height: 120), + canvasSize: CGSize(width: 60, height: 80), + expected: CGRect(x: 0, y: 4, width: 60, height: 72) + ) + ] + + static let aspectFillCases: [Self] = [ + Self( + "matching sizes", + imageSize: CGSize(width: 120, height: 80), + canvasSize: CGSize(width: 120, height: 80), + expected: CGRect(x: 0, y: 0, width: 120, height: 80) + ), + Self( + "proportional reduction", + imageSize: CGSize(width: 120, height: 80), + canvasSize: CGSize(width: 48, height: 32), + expected: CGRect(x: 0, y: 0, width: 48, height: 32) + ), + Self( + "proportional enlargement", + imageSize: CGSize(width: 120, height: 80), + canvasSize: CGSize(width: 240, height: 160), + expected: CGRect(x: 0, y: 0, width: 240, height: 160) + ), + Self( + "narrower canvas with matching height", + imageSize: CGSize(width: 1242, height: 240), + canvasSize: CGSize(width: 750, height: 240), + expected: CGRect(x: -246, y: 0, width: 1242, height: 240) + ), + Self( + "wider canvas with matching height", + imageSize: CGSize(width: 1242, height: 240), + canvasSize: CGSize(width: 1334, height: 240), + expected: CGRect(x: 0, y: -8.8888888889, width: 1334, height: 257.7777777778) + ), + Self( + "shorter canvas with matching width", + imageSize: CGSize(width: 1242, height: 240), + canvasSize: CGSize(width: 1242, height: 100), + expected: CGRect(x: 0, y: -70, width: 1242, height: 240) + ), + Self( + "taller canvas with matching width", + imageSize: CGSize(width: 1242, height: 240), + canvasSize: CGSize(width: 1242, height: 300), + expected: CGRect(x: -155.25, y: 0, width: 1552.5, height: 300) + ), + Self( + "smaller image with narrower aspect ratio", + imageSize: CGSize(width: 30, height: 40), + canvasSize: CGSize(width: 50, height: 60), + expected: CGRect(x: 0, y: -3.3333333333, width: 50, height: 66.6666666667) + ), + Self( + "smaller image with wider aspect ratio", + imageSize: CGSize(width: 50, height: 60), + canvasSize: CGSize(width: 60, height: 80), + expected: CGRect(x: -3.3333333333, y: 0, width: 66.6666666667, height: 80) + ), + Self( + "larger image with narrower aspect ratio", + imageSize: CGSize(width: 60, height: 80), + canvasSize: CGSize(width: 50, height: 60), + expected: CGRect(x: 0, y: -3.3333333333, width: 50, height: 66.6666666667) + ), + Self( + "larger image with wider aspect ratio", + imageSize: CGSize(width: 100, height: 120), + canvasSize: CGSize(width: 60, height: 80), + expected: CGRect(x: -3.3333333333, y: 0, width: 66.6666666667, height: 80) + ) + ] +} diff --git a/AsyncImageViewTests/MulticastedRendererSpec.swift b/AsyncImageViewTests/MulticastedRendererSpec.swift deleted file mode 100644 index 5c4f5a8..0000000 --- a/AsyncImageViewTests/MulticastedRendererSpec.swift +++ /dev/null @@ -1,179 +0,0 @@ -// -// MulticastedRendererSpec.swift -// AsyncImageView -// -// Created by Nacho Soto on 11/27/15. -// Copyright © 2015 Nacho Soto. All rights reserved. -// - -import Quick -import Nimble -import UIKit -import Foundation -import XCTest - -import ReactiveSwift - -import AsyncImageView - -class MulticastedRendererSpec: QuickSpec { - override class func spec() { - describe("MulticastedRenderer") { - let data: TestData = .a - let size = CGSize(width: 1, height: 1) - - context("General tests") { - typealias InnerRendererType = AnyRenderer - typealias RenderType = MulticastedRenderer - - var innerRenderer: InnerRendererType! - var renderer: RenderType! - - func getProducerForData(_ data: TestData, _ size: CGSize) -> SignalProducer { - return renderer.renderImageWithData(data.renderDataWithSize(size)) - } - - func getImageForData(_ data: TestData, _ size: CGSize) -> ImageResult? { - return getProducerForData(data, size) - .single()? - .get() - } - - beforeEach { - innerRenderer = AnyRenderer(TestRenderer()) - renderer = RenderType(renderer: innerRenderer) - } - - it("produces an image") { - let result = getImageForData(data, size) - - verifyImage(result?.image, withSize: size, data: data) - } - - it("multicasts rendering") { - // Get both producers at the same time. - let result1 = getProducerForData(data, size) - let result2 = getProducerForData(data, size) - - // Starting the producers should yield the same image. - guard let image1 = result1.single()?.get().image else { XCTFail("Failed to produce image"); return } - guard let image2 = result2.single()?.get().image else { XCTFail("Failed to produce image"); return } - - expect(image1) === image2 - } - } - - context("Cache hit") { - typealias InnerRendererType = AnyRenderer - typealias RenderType = MulticastedRenderer - - var scheduler: TestScheduler! - let delay: TimeInterval = 1 - let interval: DispatchTimeInterval = .seconds(Int(delay)) - - var innerRenderer: InnerRendererType! - var renderer: RenderType! - - var cacheHitRenderer: CacheHitRenderer! - - func getProducerForData(_ data: TestData, _ size: CGSize) -> SignalProducer { - return renderer.renderImageWithData(data.renderDataWithSize(size)) - } - - func getImageForData(_ data: TestData, _ size: CGSize) -> ImageResult? { - return getProducerForData(data, size) - .single()? - .get() - } - - beforeEach { - scheduler = TestScheduler() - - cacheHitRenderer = CacheHitRenderer() - innerRenderer = AnyRenderer(cacheHitRenderer) - - let delayedTestRenderer: InnerRendererType = AnyRenderer(DelayedRenderer( - renderer: innerRenderer, - delay: delay, - scheduler: scheduler - )) - - renderer = RenderType(renderer: delayedTestRenderer) - } - - func getCacheHitValue() -> Bool { - let producer = getProducerForData(data, size) - var result: ImageResult? - - producer.startWithValues { result = $0 } - - scheduler.advance(by: interval) - - expect(result).toEventuallyNot(beNil()) - - return result!.cacheHit - } - - it("does not cache hit the first time") { - cacheHitRenderer.shouldCacheHit = false - - expect(getCacheHitValue()) == false - } - - it("does not cache hit the first time even if inner renderer was a hit") { - cacheHitRenderer.shouldCacheHit = true - - // We asume that the underlying renderer took longer than a simple Property lookup - expect(getCacheHitValue()) == false - } - - it("is a cache hit after the initial producer completes") { - var initialResult: ImageResult? - getProducerForData(data, size).startWithValues { initialResult = $0 } - scheduler.advance(by: interval) - expect(initialResult).toEventuallyNot(beNil()) - - expect(initialResult?.cacheHit) == false - expect(getImageForData(data, size)?.cacheHit) == true - } - } - } - } -} - -/// `RendererType` decorator that returns `RenderResult` values with -/// `cacheHit` set to whatever the value of `shouldCacheHit` is at a given time. -private final class CacheHitRenderer: RendererType { - var shouldCacheHit: Bool = false - - private let testRenderer = TestRenderer() - - func renderImageWithData(_ data: TestRenderData) -> SignalProducer { - return testRenderer.renderImageWithData(data) - .map { - return RenderResult( - image: $0.image, - cacheHit: self.shouldCacheHit - ) - } - } -} - -/// `RendererType` decorator which introduces a delay on the resulting image. -private final class DelayedRenderer: RendererType { - private let renderer: T - private let delay: TimeInterval - private let scheduler: DateScheduler - - init(renderer: T, delay: TimeInterval, scheduler: DateScheduler) { - self.renderer = renderer - self.delay = delay - self.scheduler = scheduler - } - - func renderImageWithData(_ data: T.Data) -> SignalProducer { - return renderer - .renderImageWithData(data) - .delay(self.delay, on: self.scheduler) - } -} diff --git a/AsyncImageViewTests/MulticastedRendererTests.swift b/AsyncImageViewTests/MulticastedRendererTests.swift index 769b1ad..d724c69 100644 --- a/AsyncImageViewTests/MulticastedRendererTests.swift +++ b/AsyncImageViewTests/MulticastedRendererTests.swift @@ -90,6 +90,20 @@ struct MulticastedRendererTests { #expect(cached.cacheHit == true) } + @Test + func reportsInitialCacheMissEvenWhenUpstreamReportsAHit() throws { + let source = UpstreamCacheHitRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + + let initial = try #require(renderer.renderImageWithData(data).single()?.get()) + let cached = try #require(renderer.renderImageWithData(data).single()?.get()) + + #expect(source.renderCount == 1) + #expect(initial.cacheHit == false) + #expect(cached.cacheHit == true) + } + @Test func sharesOneUpstreamRenderBetweenConcurrentWaiters() throws { let image = makeImage() @@ -176,7 +190,7 @@ struct MulticastedRendererTests { func cancelsInFlightWorkWhenTheRendererIsReleased() { let source = CancellableRenderer() var renderer: MulticastedRenderer? = source.multicasted() - weak var weakRenderer = renderer + weak let weakRenderer = renderer let disposable = renderer?.renderImageWithData(MulticastRenderData(identifier: 1)).start() #expect(source.started.value == true) @@ -194,7 +208,7 @@ struct MulticastedRendererTests { let image = makeImage() let source = MultiValueRenderer() var renderer: MulticastedRenderer? = source.multicasted() - weak var weakRenderer = renderer + weak let weakRenderer = renderer var result: ImageResult? let disposable = renderer? .renderImageWithData(MulticastRenderData(identifier: 1)) @@ -305,6 +319,17 @@ private final class SynchronousRenderer: RendererType { } } +private final class UpstreamCacheHitRenderer: RendererType { + private let image = makeImage() + private(set) var renderCount = 0 + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + self.renderCount += 1 + + return SignalProducer(value: ImageResult(image: self.image, cacheHit: true)) + } +} + private final class CancellableRenderer: RendererType { let started = Atomic(false) let cancelled = Atomic(false) diff --git a/AsyncImageViewTests/TestRenderData.swift b/AsyncImageViewTests/TestRenderData.swift index bca29d7..4498868 100644 --- a/AsyncImageViewTests/TestRenderData.swift +++ b/AsyncImageViewTests/TestRenderData.swift @@ -9,9 +9,7 @@ import UIKit import ReactiveSwift - -import Quick -import Nimble +import Testing import AsyncImageView @@ -23,11 +21,11 @@ internal enum TestData: CGFloat, Hashable { extension TestData: ImageViewDataType { var data: TestData { - return self + self } func renderDataWithSize(_ size: CGSize) -> TestRenderData { - return RenderData(data: self.data, size: size) + RenderData(data: self.data, size: size) } } @@ -51,7 +49,7 @@ internal final class TestRenderer: RendererType { var renderedImages: Atomic<[TestRenderData]> = Atomic([]) func renderImageWithData(_ data: TestRenderData) -> SignalProducer { - return TestRenderer.rendererForSize(data.size, scale: data.data.rawValue) + TestRenderer.rendererForSize(data.size, scale: data.data.rawValue) .asyncRenderer(ImmediateScheduler()) .renderImageWithData(data) .on(started: { @@ -69,27 +67,25 @@ internal final class TestRenderer: RendererType { } } +@MainActor internal func verifyImage(_ image: @autoclosure @escaping () -> UIImage?, withSize size: CGSize, - data: TestData?, - file: FileString = #file, - line: UInt = #line) { + data: TestData?) async { if let data = data { - verifyImage(image(), - withSize: size, - expectedScale: data.rawValue, - file: file, - line: line) + await verifyImage( + image(), + withSize: size, + expectedScale: data.rawValue + ) } else { - expect(file: file, line: line, image()).toEventually(beNil()) + #expect(await eventuallyOnMainActor { image() == nil }) } } +@MainActor internal func verifyImage(_ image: @autoclosure @escaping () -> UIImage?, withSize size: CGSize, - expectedScale: CGFloat, - file: FileString = #file, - line: UInt = #line) { - expect(file: file, line: line, image()?.size).toEventually(equal(size)) - expect(file: file, line: line, image()?.scale).toEventually(equal(expectedScale)) + expectedScale: CGFloat) async { + #expect(await eventuallyOnMainActor { image()?.size == size }) + #expect(await eventuallyOnMainActor { image()?.scale == expectedScale }) } diff --git a/Package.resolved b/Package.resolved index ad6b40e..f3e5fc7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,41 +1,6 @@ { + "originHash" : "ee1195d7a98eddf416adb589294e2e081898124dcb0ea4356e0933c158bfdcdc", "pins" : [ - { - "identity" : "cwlcatchexception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlCatchException.git", - "state" : { - "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c", - "version" : "2.2.1" - } - }, - { - "identity" : "cwlpreconditiontesting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state" : { - "revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", - "version" : "2.2.2" - } - }, - { - "identity" : "nimble", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Quick/Nimble.git", - "state" : { - "revision" : "edaedc1ec86f14ac6e2ca495b94f0ff7150d98d0", - "version" : "12.3.0" - } - }, - { - "identity" : "quick", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Quick/Quick.git", - "state" : { - "revision" : "1163a1b1b114a657c7432b63dd1f92ce99fe11a6", - "version" : "7.6.2" - } - }, { "identity" : "reactiveswift", "kind" : "remoteSourceControl", @@ -44,34 +9,7 @@ "revision" : "40c465af19b993344e84355c00669ba2022ca3cd", "version" : "7.1.1" } - }, - { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms.git", - "state" : { - "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", - "version" : "1.6.2" - } - }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", - "state" : { - "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", - "version" : "1.1.1" - } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 5d62385..673fc4a 100644 --- a/Package.swift +++ b/Package.swift @@ -13,9 +13,7 @@ let package = Package( .library(name: "AsyncImageView", targets: ["AsyncImageView"]) ], dependencies: [ - .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift.git", from: "7.1.0"), - .package(url: "https://github.com/Quick/Quick.git", from: "7.4.0"), - .package(url: "https://github.com/Quick/Nimble.git", from: "12.2.0") + .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift.git", from: "7.1.0") ], targets: [ .target( @@ -26,9 +24,7 @@ let package = Package( .testTarget( name: "AsyncImageViewTests", dependencies: [ - "AsyncImageView", - "Quick", - "Nimble" + "AsyncImageView" ], path: "AsyncImageViewTests" )