Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@
ReferencedContainer = "container:EssentialApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
78 changes: 76 additions & 2 deletions EssentialApp/EssentialApp/CombineHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ private extension FeedCache {
}

extension Publisher {
func dispatchOnMainQueue() -> AnyPublisher<Output, Failure> {
receive(on: DispatchQueue.immediateWhenOnMainQueueScheduler).eraseToAnyPublisher()
func dispatchOnMainThread() -> AnyPublisher<Output, Failure> {
receive(on: DispatchQueue.immediateWhenOnMainThreadScheduler).eraseToAnyPublisher()
}
}

Expand Down Expand Up @@ -170,6 +170,39 @@ extension DispatchQueue {
DispatchQueue.main.schedule(after: date, interval: interval, tolerance: tolerance, options: options, action)
}
}

static var immediateWhenOnMainThreadScheduler: ImmediateWhenOnMainThreadScheduler {
ImmediateWhenOnMainThreadScheduler()
}

struct ImmediateWhenOnMainThreadScheduler: Scheduler {
typealias SchedulerTimeType = DispatchQueue.SchedulerTimeType
typealias SchedulerOptions = DispatchQueue.SchedulerOptions

var now: SchedulerTimeType {
DispatchQueue.main.now
}

var minimumTolerance: SchedulerTimeType.Stride {
DispatchQueue.main.minimumTolerance
}

func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) {
guard Thread.isMainThread else {
return DispatchQueue.main.schedule(options: options, action)
}

action()
}

func schedule(after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) {
DispatchQueue.main.schedule(after: date, tolerance: tolerance, options: options, action)
}

func schedule(after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) -> Cancellable {
DispatchQueue.main.schedule(after: date, interval: interval, tolerance: tolerance, options: options, action)
}
}
}

typealias AnyDispatchQueueScheduler = AnyScheduler<DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions>
Expand All @@ -178,6 +211,47 @@ extension AnyDispatchQueueScheduler {
static var immediateOnMainQueue: Self {
DispatchQueue.immediateWhenOnMainQueueScheduler.eraseToAnyScheduler()
}

static var immediateOnMainThread: Self {
DispatchQueue.immediateWhenOnMainThreadScheduler.eraseToAnyScheduler()
}

static func scheduler(for store: CoreDataFeedStore) -> AnyDispatchQueueScheduler {
CoreDataFeedStoreScheduler(store: store).eraseToAnyScheduler()
}

private struct CoreDataFeedStoreScheduler: Scheduler {
let store: CoreDataFeedStore

var now: SchedulerTimeType { .init(.now()) }

var minimumTolerance: SchedulerTimeType.Stride { .zero }

func schedule(after date: DispatchQueue.SchedulerTimeType, interval: DispatchQueue.SchedulerTimeType.Stride, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) -> any Cancellable {
if store.contextQueue == .main, Thread.isMainThread {
action()
} else {
store.perform(action)
}
return AnyCancellable {}
}

func schedule(after date: DispatchQueue.SchedulerTimeType, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) {
if store.contextQueue == .main, Thread.isMainThread {
action()
} else {
store.perform(action)
}
}

func schedule(options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) {
if store.contextQueue == .main, Thread.isMainThread {
action()
} else {
store.perform(action)
}
}
}
}

extension Scheduler {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class LoadResourcePresentationAdapter<Resource, View: ResourceView> {
private let loader: () -> AnyPublisher<Resource, Error>
private var cancellable: Cancellable?
private var isLoading = false

var presenter: LoadResourcePresenter<Resource, View>?

init(loader: @escaping () -> AnyPublisher<Resource, Error>) {
Expand All @@ -24,7 +25,7 @@ final class LoadResourcePresentationAdapter<Resource, View: ResourceView> {
isLoading = true

cancellable = loader()
.dispatchOnMainQueue()
.dispatchOnMainThread()
.handleEvents(receiveCancel: { [weak self] in
self?.isLoading = false
})
Expand Down
19 changes: 12 additions & 7 deletions EssentialApp/EssentialApp/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@ import EssentialFeed
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

private lazy var scheduler: AnyDispatchQueueScheduler = DispatchQueue(
label: "portocode.infra.queue",
qos: .userInitiated,
attributes: .concurrent
).eraseToAnyScheduler()
private lazy var scheduler: AnyDispatchQueueScheduler = {
if let store = store as? CoreDataFeedStore {
return .scheduler(for: store)
}

return DispatchQueue(
label: "com.essentialdeveloper.infra.queue",
qos: .userInitiated,
attributes: .concurrent
).eraseToAnyScheduler()
}()

private lazy var httpClient: HTTPClient = {
URLSessionHTTPClient(session: URLSession(configuration: .ephemeral))
Expand Down Expand Up @@ -49,11 +55,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
imageLoader: makeLocalImageLoaderWithRemoteFallback,
selection: showComments))

convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore, scheduler: AnyDispatchQueueScheduler) {
convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) {
self.init()
self.httpClient = httpClient
self.store = store
self.scheduler = scheduler
}

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
Expand Down
66 changes: 45 additions & 21 deletions EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import EssentialFeediOS

class FeedAcceptanceTests: XCTestCase {

func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() {
let feed = launch(httpClient: .online(response), store: .empty)
func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() throws {
let feed = try launch(httpClient: .online(response), store: .empty)

XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2)
XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0())
Expand All @@ -35,8 +35,8 @@ class FeedAcceptanceTests: XCTestCase {
XCTAssertFalse(feed.canLoadMoreFeed)
}

func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() {
let sharedStore = InMemoryFeedStore.empty
func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() throws {
let sharedStore = try CoreDataFeedStore.empty

let onlineFeed = launch(httpClient: .online(response), store: sharedStore)
onlineFeed.simulateFeedImageViewVisible(at: 0)
Expand All @@ -52,30 +52,30 @@ class FeedAcceptanceTests: XCTestCase {
XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 2), makeImageData2())
}

func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() {
let feed = launch(httpClient: .offline, store: .empty)
func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() throws {
let feed = try launch(httpClient: .offline, store: .empty)

XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 0)
}

func test_onEnteringBackground_deletesExpiredFeedCache() {
let store = InMemoryFeedStore.withExpiredFeedCache
func test_onEnteringBackground_deletesExpiredFeedCache() throws {
let store = try CoreDataFeedStore.withExpiredFeedCache

enterBackground(with: store)

XCTAssertNil(store.feedCache, "Expected to delete expired cache")
XCTAssertNil(try store.retrieve(), "Expected to delete expired cache")
}

func test_onEnteringBackground_keepsNonExpiredFeedCache() {
let store = InMemoryFeedStore.withNonExpiredFeedCache
func test_onEnteringBackground_keepsNonExpiredFeedCache() throws {
let store = try CoreDataFeedStore.withNonExpiredFeedCache

enterBackground(with: store)

XCTAssertNotNil(store.feedCache, "Expected to keep non-expired cache")
XCTAssertNotNil(try store.retrieve(), "Expected to keep non-expired cache")
}

func test_onFeedImageSelection_displaysComments() {
let comments = showCommentsForFirstImage()
func test_onFeedImageSelection_displaysComments() throws {
let comments = try showCommentsForFirstImage()

XCTAssertEqual(comments.numberOfRenderedComments(), 1)
XCTAssertEqual(comments.commentMessage(at: 0), makeCommentMessage())
Expand All @@ -85,9 +85,9 @@ class FeedAcceptanceTests: XCTestCase {

private func launch(
httpClient: HTTPClientStub = .offline,
store: InMemoryFeedStore = .empty
store: CoreDataFeedStore
) -> ListViewController {
let sut = SceneDelegate(httpClient: httpClient, store: store, scheduler: .immediateOnMainQueue)
let sut = SceneDelegate(httpClient: httpClient, store: store)
sut.window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 1))
sut.configureWindow()

Expand All @@ -97,13 +97,13 @@ class FeedAcceptanceTests: XCTestCase {
return vc
}

private func enterBackground(with store: InMemoryFeedStore) {
let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store, scheduler: .immediateOnMainQueue)
private func enterBackground(with store: CoreDataFeedStore) {
let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store)
sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!)
}

private func showCommentsForFirstImage() -> ListViewController {
let feed = launch(httpClient: .online(response), store: .empty)
private func showCommentsForFirstImage() throws -> ListViewController {
let feed = try launch(httpClient: .online(response), store: .empty)

feed.simulateTapOnFeedImage(at: 0)
RunLoop.current.run(until: Date())
Expand Down Expand Up @@ -133,7 +133,7 @@ class FeedAcceptanceTests: XCTestCase {

case "/essential-feed/v1/feed" where url.query?.contains("after_id=166FCDD7-C9F4-420A-B2D6-CE2EAFA3D82F") == true:
return makeLastEmptyFeedPageData()

case "/essential-feed/v1/image/2AB2AE66-A4B7-4A16-B374-51BBAC8DB086/comments":
return makeCommentsData()

Expand Down Expand Up @@ -181,3 +181,27 @@ class FeedAcceptanceTests: XCTestCase {
}

}

extension CoreDataFeedStore {
static var empty: CoreDataFeedStore {
get throws {
try CoreDataFeedStore(storeURL: URL(fileURLWithPath: "/dev/null"), contextQueue: .main)
}
}

static var withExpiredFeedCache: CoreDataFeedStore {
get throws {
let store = try CoreDataFeedStore.empty
try store.insert([], timestamp: .distantPast)
return store
}
}

static var withNonExpiredFeedCache: CoreDataFeedStore {
get throws {
let store = try CoreDataFeedStore.empty
try store.insert([], timestamp: Date())
return store
}
}
}
11 changes: 5 additions & 6 deletions EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ class FeedUIIntegrationTests: XCTestCase {
let (sut, loader) = makeSUT()

sut.simulateAppearance()
loader.completeFeedLoading()
loader.completeFeedLoading(with: [makeImage()])
XCTAssertEqual(loader.loadMoreCallCount, 0, "Expected no requests before until load more action")

sut.simulateLoadMoreFeedAction()
Expand Down Expand Up @@ -210,13 +210,13 @@ class FeedUIIntegrationTests: XCTestCase {
sut.simulateAppearance()
XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once view is loaded")

loader.completeFeedLoading(at: 0)
loader.completeFeedLoading(with: [makeImage()], at: 0)
XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once loading completes successfully")

sut.simulateLoadMoreFeedAction()
XCTAssertTrue(sut.isShowingLoadMoreFeedIndicator, "Expected loading indicator on load more action")

loader.completeLoadMore(at: 0)
loader.completeLoadMore(with: [makeImage()], at: 0)
XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once user initiated loading completes successfully")

sut.simulateLoadMoreFeedAction()
Expand Down Expand Up @@ -279,10 +279,9 @@ class FeedUIIntegrationTests: XCTestCase {
let (sut, loader) = makeSUT()

sut.simulateAppearance()
loader.completeFeedLoading(with: [image0, image1])

XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until views become visible")

loader.completeFeedLoading(with: [image0, image1])
sut.simulateFeedImageViewVisible(at: 0)
XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first view becomes visible")

Expand Down Expand Up @@ -433,9 +432,9 @@ class FeedUIIntegrationTests: XCTestCase {
let (sut, loader) = makeSUT()

sut.simulateAppearance()
loader.completeFeedLoading(with: [image0, image1])
XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until image is near visible")

loader.completeFeedLoading(with: [image0, image1])
sut.simulateFeedImageViewNearVisible(at: 0)
XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first image is near visible")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "IDEPreferLogStreaming"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
Loading