iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: extract storyFullsizeCached and add unit tests

Extract the cache-check logic from StoryViewer into a module-internal
free function storyFullsizeCached(_:in:) that accepts an injectable
ImagePipeline, making it testable without a SwiftUI view or shared state.

Add StoryImageCacheTests covering nil story, invalid URL, cache miss,
cache hit, and pipeline isolation. Also fix project.yml so the test
target uses stable PRODUCT_MODULE_NAME/PRODUCT_NAME settings and a
BUNDLE_NAME-aware TEST_HOST, which was previously broken.

+105 -2
+11
Grain/Utilities/StoryImageCache.swift
··· 1 + import Foundation 2 + import Nuke 3 + 4 + /// Returns `true` if the fullsize image for `story` is already in `pipeline`'s memory cache. 5 + /// 6 + /// Extracted from `StoryViewer` so it can be tested without spinning up a SwiftUI view 7 + /// and without touching `ImagePipeline.shared`. 8 + func storyFullsizeCached(_ story: GrainStory?, in pipeline: ImagePipeline = .shared) -> Bool { 9 + guard let url = story.flatMap({ URL(string: $0.fullsize) }) else { return false } 10 + return pipeline.cache.cachedImage(for: ImageRequest(url: url)) != nil 11 + }
+1 -2
Grain/Views/Stories/StoryViewer.swift
··· 554 554 } 555 555 556 556 private func isFullsizeCached(_ story: GrainStory?) -> Bool { 557 - guard let url = story.flatMap({ URL(string: $0.fullsize) }) else { return false } 558 - return ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: url)) != nil 557 + storyFullsizeCached(story) 559 558 } 560 559 561 560 private func goToNext() {
+88
GrainTests/StoryImageCacheTests.swift
··· 1 + @testable import Grain 2 + import Nuke 3 + import XCTest 4 + 5 + final class StoryImageCacheTests: XCTestCase { 6 + // MARK: - Helpers 7 + 8 + private func makeStory(fullsize: String) -> GrainStory { 9 + GrainStory( 10 + uri: "at://did:plc:test/social.grain.story/\(UUID().uuidString)", 11 + cid: "bafy-test", 12 + creator: GrainProfile(cid: "cid", did: "did:plc:test", handle: "test.bsky.social"), 13 + thumb: "https://cdn.example.com/thumb.jpg", 14 + fullsize: fullsize, 15 + aspectRatio: AspectRatio(width: 4, height: 3), 16 + createdAt: "2024-01-01T00:00:00Z" 17 + ) 18 + } 19 + 20 + /// Builds an `ImagePipeline` backed entirely by an in-memory cache so tests 21 + /// never touch the network or the disk, and never pollute `ImagePipeline.shared`. 22 + private func makeIsolatedPipeline() -> ImagePipeline { 23 + var config = ImagePipeline.Configuration() 24 + config.dataLoader = DataLoader(configuration: { 25 + let cfg = URLSessionConfiguration.ephemeral 26 + cfg.protocolClasses = [] 27 + return cfg 28 + }()) 29 + config.imageCache = ImageCache() 30 + config.dataCache = nil 31 + return ImagePipeline(configuration: config) 32 + } 33 + 34 + private func seedCache(pipeline: ImagePipeline, url: URL) { 35 + let image = UIImage(systemName: "photo") ?? UIImage() 36 + let container = ImageContainer(image: image) 37 + pipeline.cache.storeCachedImage(container, for: ImageRequest(url: url)) 38 + } 39 + 40 + // MARK: - nil story 41 + 42 + func testNilStory_returnsFalse() { 43 + let pipeline = makeIsolatedPipeline() 44 + XCTAssertFalse(storyFullsizeCached(nil, in: pipeline)) 45 + } 46 + 47 + // MARK: - invalid / empty fullsize URL 48 + 49 + func testEmptyFullsizeURL_returnsFalse() { 50 + let pipeline = makeIsolatedPipeline() 51 + let story = makeStory(fullsize: "") 52 + XCTAssertFalse(storyFullsizeCached(story, in: pipeline)) 53 + } 54 + 55 + func testInvalidFullsizeURL_returnsFalse() { 56 + let pipeline = makeIsolatedPipeline() 57 + let story = makeStory(fullsize: "not a url !!!") 58 + XCTAssertFalse(storyFullsizeCached(story, in: pipeline)) 59 + } 60 + 61 + // MARK: - valid URL, not cached 62 + 63 + func testValidURL_notInCache_returnsFalse() { 64 + let pipeline = makeIsolatedPipeline() 65 + let story = makeStory(fullsize: "https://cdn.example.com/stories/abc.jpg") 66 + XCTAssertFalse(storyFullsizeCached(story, in: pipeline)) 67 + } 68 + 69 + // MARK: - valid URL, cached 70 + 71 + func testValidURL_inCache_returnsTrue() throws { 72 + let pipeline = makeIsolatedPipeline() 73 + let urlString = "https://cdn.example.com/stories/xyz.jpg" 74 + let story = makeStory(fullsize: urlString) 75 + try seedCache(pipeline: pipeline, url: XCTUnwrap(URL(string: urlString))) 76 + XCTAssertTrue(storyFullsizeCached(story, in: pipeline)) 77 + } 78 + 79 + func testValidURL_cachedInDifferentPipeline_doesNotAffectIsolatedPipeline() throws { 80 + let pipeline1 = makeIsolatedPipeline() 81 + let pipeline2 = makeIsolatedPipeline() 82 + let urlString = "https://cdn.example.com/stories/shared.jpg" 83 + let story = makeStory(fullsize: urlString) 84 + try seedCache(pipeline: pipeline1, url: XCTUnwrap(URL(string: urlString))) 85 + // pipeline2 should not see pipeline1's cache 86 + XCTAssertFalse(storyFullsizeCached(story, in: pipeline2)) 87 + } 88 + }
+5
project.yml
··· 43 43 base: 44 44 INFOPLIST_FILE: Grain/Info.plist 45 45 PRODUCT_BUNDLE_IDENTIFIER: ${BUNDLE_ID} 46 + PRODUCT_MODULE_NAME: Grain 46 47 MARKETING_VERSION: "1.0.0" 47 48 CURRENT_PROJECT_VERSION: "42" 48 49 CODE_SIGN_STYLE: Automatic ··· 82 83 settings: 83 84 base: 84 85 GENERATE_INFOPLIST_FILE: YES 86 + PRODUCT_NAME: GrainTests 87 + PRODUCT_MODULE_NAME: GrainTests 88 + TEST_HOST: $(BUILT_PRODUCTS_DIR)/$(BUNDLE_NAME).app/$(BUNDLE_NAME) 89 + BUNDLE_LOADER: $(TEST_HOST) 85 90 dependencies: 86 91 - target: Grain 87 92