Add unit tests for offline reading features

This commit is contained in:
Ilyas Hallak 2025-12-01 21:50:42 +01:00
parent 90ced9ba0c
commit d3e15c6352
5 changed files with 1271 additions and 0 deletions

View File

@ -0,0 +1,347 @@
//
// OfflineCacheRepositoryTests.swift
// readeckTests
//
// Created by Claude on 21.11.25.
//
import Testing
import Foundation
import CoreData
@testable import readeck
@Suite("OfflineCacheRepository Tests")
struct OfflineCacheRepositoryTests {
// MARK: - Test Setup
private func createInMemoryCoreDataStack() -> NSManagedObjectContext {
let managedObjectModel = NSManagedObjectModel.mergedModel(from: [Bundle.main])!
let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel)
try! persistentStoreCoordinator.addPersistentStore(
ofType: NSInMemoryStoreType,
configurationName: nil,
at: nil,
options: nil
)
let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
context.persistentStoreCoordinator = persistentStoreCoordinator
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return context
}
private func createTestBookmark(id: String = "test-123", title: String = "Test Article") -> Bookmark {
return Bookmark(
id: id,
title: title,
url: "https://example.com/article",
href: "/api/bookmarks/\(id)",
description: "Test description",
authors: [],
created: ISO8601DateFormatter().string(from: Date()),
published: nil,
updated: ISO8601DateFormatter().string(from: Date()),
siteName: "Example Site",
site: "example.com",
readingTime: 5,
wordCount: 1000,
hasArticle: true,
isArchived: false,
isDeleted: false,
isMarked: false,
labels: [],
lang: "en",
loaded: true,
readProgress: 0,
documentType: "article",
state: 0,
textDirection: "ltr",
type: "bookmark",
resources: BookmarkResources(
article: Resource(src: "/api/bookmarks/\(id)/article"),
icon: nil,
image: nil,
log: nil,
props: nil,
thumbnail: nil
)
)
}
// MARK: - HTML Extraction Tests
@Test("Extract image URLs from HTML correctly")
func testExtractImageURLsFromHTML() {
let html = """
<html>
<body>
<img src="https://example.com/image1.jpg" alt="Image 1">
<img src="https://example.com/image2.png" />
<img src="/relative/image.jpg" alt="Relative">
<img src="https://example.com/image3.gif">
</body>
</html>
"""
// We need to test the private method indirectly via cacheBookmarkWithMetadata
// For now, we'll test the regex pattern separately
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let nsString = html as NSString
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))
var imageURLs: [String] = []
for result in results {
if result.numberOfRanges >= 2 {
let urlRange = result.range(at: 1)
if let url = nsString.substring(with: urlRange) as String?,
url.hasPrefix("http") {
imageURLs.append(url)
}
}
}
#expect(imageURLs.count == 3)
#expect(imageURLs.contains("https://example.com/image1.jpg"))
#expect(imageURLs.contains("https://example.com/image2.png"))
#expect(imageURLs.contains("https://example.com/image3.gif"))
#expect(!imageURLs.contains("/relative/image.jpg"))
}
@Test("Extract image URLs handles empty HTML")
func testExtractImageURLsFromEmptyHTML() {
let html = "<html><body><p>No images here</p></body></html>"
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: html.count))
#expect(results.count == 0)
}
@Test("Extract image URLs handles malformed HTML")
func testExtractImageURLsFromMalformedHTML() {
let html = """
<img src='single-quotes.jpg'>
<img src=no-quotes.jpg>
<img src="https://valid.com/image.jpg">
"""
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: html.count))
// Should only match double-quoted URLs
#expect(results.count == 1)
}
// MARK: - Cache Size Calculation Tests
@Test("Cache size calculation is accurate")
func testCacheSizeCalculation() {
let html = "Test HTML content"
let expectedSize = Int64(html.utf8.count)
#expect(expectedSize == 17)
}
@Test("Cache size handles empty content")
func testCacheSizeWithEmptyContent() {
let html = ""
let expectedSize = Int64(html.utf8.count)
#expect(expectedSize == 0)
}
@Test("Cache size handles UTF-8 characters correctly")
func testCacheSizeWithUTF8Characters() {
let html = "Hello 世界 🌍"
let expectedSize = Int64(html.utf8.count)
// UTF-8: "Hello " (6) + "" (6) + " " (1) + "🌍" (4) = 17 bytes
#expect(expectedSize > html.count) // More bytes than characters
}
// MARK: - Image URL Storage Tests
@Test("Image URLs are joined correctly with comma separator")
func testImageURLsJoining() {
let imageURLs = [
"https://example.com/image1.jpg",
"https://example.com/image2.png",
"https://example.com/image3.gif"
]
let joined = imageURLs.joined(separator: ",")
#expect(joined == "https://example.com/image1.jpg,https://example.com/image2.png,https://example.com/image3.gif")
// Test splitting
let split = joined.split(separator: ",").map(String.init)
#expect(split.count == 3)
#expect(split == imageURLs)
}
@Test("Image URLs splitting handles empty string")
func testImageURLsSplittingEmptyString() {
let imageURLsString = ""
let split = imageURLsString.split(separator: ",").map(String.init)
#expect(split.isEmpty)
}
@Test("Image URLs splitting handles single URL")
func testImageURLsSplittingSingleURL() {
let imageURLsString = "https://example.com/single.jpg"
let split = imageURLsString.split(separator: ",").map(String.init)
#expect(split.count == 1)
#expect(split.first == "https://example.com/single.jpg")
}
// MARK: - Bookmark Domain Model Tests
@Test("Bookmark creation has correct defaults")
func testBookmarkCreation() {
let bookmark = createTestBookmark()
#expect(bookmark.id == "test-123")
#expect(bookmark.title == "Test Article")
#expect(bookmark.url == "https://example.com/article")
#expect(bookmark.readProgress == 0)
#expect(bookmark.isMarked == false)
}
// MARK: - FIFO Cleanup Logic Tests
@Test("FIFO cleanup calculates correct number of items to delete")
func testFIFOCleanupCalculation() {
let totalCount = 30
let keepCount = 20
let expectedDeleteCount = totalCount - keepCount
#expect(expectedDeleteCount == 10)
}
@Test("FIFO cleanup does not delete when under limit")
func testFIFOCleanupUnderLimit() {
let totalCount = 15
let keepCount = 20
if totalCount > keepCount {
#expect(Bool(false), "Should not trigger cleanup")
} else {
#expect(Bool(true), "Cleanup should be skipped")
}
}
@Test("FIFO cleanup deletes all items when keepCount is zero")
func testFIFOCleanupKeepZero() {
let totalCount = 10
let keepCount = 0
let expectedDeleteCount = totalCount - keepCount
#expect(expectedDeleteCount == 10)
}
// MARK: - Date Handling Tests
@Test("Cache date and access date are set correctly")
func testCacheDates() {
let now = Date()
let cachedDate = now
let lastAccessDate = now
#expect(cachedDate.timeIntervalSince1970 == now.timeIntervalSince1970)
#expect(lastAccessDate.timeIntervalSince1970 == now.timeIntervalSince1970)
}
@Test("Last access date updates on read")
func testLastAccessDateUpdate() {
let initialDate = Date(timeIntervalSince1970: 1000)
let updatedDate = Date(timeIntervalSince1970: 2000)
#expect(updatedDate > initialDate)
#expect(updatedDate.timeIntervalSince1970 - initialDate.timeIntervalSince1970 == 1000)
}
// MARK: - ByteCountFormatter Tests
@Test("ByteCountFormatter formats small sizes correctly")
func testByteCountFormatterSmallSizes() {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
let bytes1KB = Int64(1024)
let formatted1KB = formatter.string(fromByteCount: bytes1KB)
#expect(formatted1KB.contains("KB") || formatted1KB.contains("kB"))
let bytes10KB = Int64(10 * 1024)
let formatted10KB = formatter.string(fromByteCount: bytes10KB)
#expect(formatted10KB.contains("KB") || formatted10KB.contains("kB"))
}
@Test("ByteCountFormatter formats large sizes correctly")
func testByteCountFormatterLargeSizes() {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
let bytes1MB = Int64(1024 * 1024)
let formatted1MB = formatter.string(fromByteCount: bytes1MB)
#expect(formatted1MB.contains("MB"))
let bytes100MB = Int64(100 * 1024 * 1024)
let formatted100MB = formatter.string(fromByteCount: bytes100MB)
#expect(formatted100MB.contains("MB"))
}
@Test("ByteCountFormatter handles zero bytes")
func testByteCountFormatterZero() {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
let formatted = formatter.string(fromByteCount: 0)
#expect(formatted.contains("0") || formatted.contains("Zero"))
}
// MARK: - NSPredicate Tests
@Test("Cache filter predicate syntax is correct")
func testCacheFilterPredicate() {
let predicate = NSPredicate(format: "htmlContent != nil")
// Test with mock data
let testData = ["htmlContent": "Some HTML"]
let result = predicate.evaluate(with: testData)
#expect(result == true)
}
@Test("ID filter predicate syntax is correct")
func testIDFilterPredicate() {
let testID = "test-123"
let predicate = NSPredicate(format: "id == %@", testID)
let testData = ["id": "test-123"]
let result = predicate.evaluate(with: testData)
#expect(result == true)
let wrongData = ["id": "wrong-id"]
let wrongResult = predicate.evaluate(with: wrongData)
#expect(wrongResult == false)
}
@Test("Combined cache and ID predicate is correct")
func testCombinedPredicate() {
let testID = "test-123"
let predicate = NSPredicate(format: "id == %@ AND htmlContent != nil", testID)
let validData = ["id": "test-123", "htmlContent": "HTML"]
let validResult = predicate.evaluate(with: validData)
#expect(validResult == true)
let missingHTML = ["id": "test-123", "htmlContent": nil as String?]
let missingHTMLResult = predicate.evaluate(with: missingHTML)
#expect(missingHTMLResult == false)
}
}

View File

@ -0,0 +1,185 @@
//
// OfflineSettingsTests.swift
// readeckTests
//
// Created by Claude on 21.11.25.
//
import Testing
import Foundation
@testable import readeck
@Suite("OfflineSettings Tests")
struct OfflineSettingsTests {
// MARK: - Initialization Tests
@Test("Default initialization has correct values")
func testDefaultInitialization() {
let settings = OfflineSettings()
#expect(settings.enabled == true)
#expect(settings.maxUnreadArticles == 20.0)
#expect(settings.saveImages == false)
#expect(settings.lastSyncDate == nil)
#expect(settings.maxUnreadArticlesInt == 20)
}
// MARK: - maxUnreadArticlesInt Tests
@Test("maxUnreadArticlesInt converts Double to Int correctly")
func testMaxUnreadArticlesIntConversion() {
var settings = OfflineSettings()
settings.maxUnreadArticles = 15.0
#expect(settings.maxUnreadArticlesInt == 15)
settings.maxUnreadArticles = 50.7
#expect(settings.maxUnreadArticlesInt == 50)
settings.maxUnreadArticles = 99.9
#expect(settings.maxUnreadArticlesInt == 99)
}
// MARK: - shouldSyncOnAppStart Tests
@Test("shouldSyncOnAppStart returns false when disabled")
func testShouldNotSyncWhenDisabled() {
var settings = OfflineSettings()
settings.enabled = false
settings.lastSyncDate = nil // Never synced
#expect(settings.shouldSyncOnAppStart == false)
}
@Test("shouldSyncOnAppStart returns true when never synced")
func testShouldSyncWhenNeverSynced() {
var settings = OfflineSettings()
settings.enabled = true
settings.lastSyncDate = nil
#expect(settings.shouldSyncOnAppStart == true)
}
@Test("shouldSyncOnAppStart returns true when last sync was more than 4 hours ago")
func testShouldSyncWhenLastSyncOlderThan4Hours() {
var settings = OfflineSettings()
settings.enabled = true
// Test with 5 hours ago
settings.lastSyncDate = Date().addingTimeInterval(-5 * 60 * 60)
#expect(settings.shouldSyncOnAppStart == true)
// Test with 4.5 hours ago
settings.lastSyncDate = Date().addingTimeInterval(-4.5 * 60 * 60)
#expect(settings.shouldSyncOnAppStart == true)
// Test with exactly 4 hours + 1 second ago
settings.lastSyncDate = Date().addingTimeInterval(-4 * 60 * 60 - 1)
#expect(settings.shouldSyncOnAppStart == true)
}
@Test("shouldSyncOnAppStart returns false when last sync was less than 4 hours ago")
func testShouldNotSyncWhenLastSyncWithin4Hours() {
var settings = OfflineSettings()
settings.enabled = true
// Test with 3 hours ago
settings.lastSyncDate = Date().addingTimeInterval(-3 * 60 * 60)
#expect(settings.shouldSyncOnAppStart == false)
// Test with 1 hour ago
settings.lastSyncDate = Date().addingTimeInterval(-1 * 60 * 60)
#expect(settings.shouldSyncOnAppStart == false)
// Test with 1 minute ago
settings.lastSyncDate = Date().addingTimeInterval(-60)
#expect(settings.shouldSyncOnAppStart == false)
// Test with just now
settings.lastSyncDate = Date()
#expect(settings.shouldSyncOnAppStart == false)
}
@Test("shouldSyncOnAppStart boundary test near 4 hours")
func testShouldSyncBoundaryAt4Hours() {
var settings = OfflineSettings()
settings.enabled = true
// Test slightly under 4 hours (3h 59m 30s) - should NOT sync
settings.lastSyncDate = Date().addingTimeInterval(-3 * 60 * 60 - 59 * 60 - 30)
#expect(settings.shouldSyncOnAppStart == false)
// Test slightly over 4 hours (4h 0m 30s) - should sync
settings.lastSyncDate = Date().addingTimeInterval(-4 * 60 * 60 - 30)
#expect(settings.shouldSyncOnAppStart == true)
}
@Test("shouldSyncOnAppStart with future date edge case")
func testShouldNotSyncWithFutureDate() {
var settings = OfflineSettings()
settings.enabled = true
// Edge case: lastSyncDate in the future (clock skew/bug)
settings.lastSyncDate = Date().addingTimeInterval(60 * 60) // 1 hour in future
#expect(settings.shouldSyncOnAppStart == false)
}
// MARK: - Codable Tests
@Test("OfflineSettings is encodable and decodable")
func testCodableRoundTrip() throws {
var original = OfflineSettings()
original.enabled = false
original.maxUnreadArticles = 35.0
original.saveImages = true
original.lastSyncDate = Date(timeIntervalSince1970: 1699999999)
let encoder = JSONEncoder()
let data = try encoder.encode(original)
let decoder = JSONDecoder()
let decoded = try decoder.decode(OfflineSettings.self, from: data)
#expect(decoded.enabled == original.enabled)
#expect(decoded.maxUnreadArticles == original.maxUnreadArticles)
#expect(decoded.saveImages == original.saveImages)
#expect(decoded.lastSyncDate?.timeIntervalSince1970 == original.lastSyncDate?.timeIntervalSince1970)
}
@Test("OfflineSettings decodes with missing optional fields")
func testDecodingWithMissingFields() throws {
let json = """
{
"enabled": true,
"maxUnreadArticles": 25.0,
"saveImages": false
}
"""
let data = json.data(using: .utf8)!
let decoder = JSONDecoder()
let settings = try decoder.decode(OfflineSettings.self, from: data)
#expect(settings.enabled == true)
#expect(settings.maxUnreadArticles == 25.0)
#expect(settings.saveImages == false)
#expect(settings.lastSyncDate == nil)
}
// MARK: - Edge Cases
@Test("maxUnreadArticles handles extreme values")
func testMaxUnreadArticlesExtremeValues() {
var settings = OfflineSettings()
settings.maxUnreadArticles = 0.0
#expect(settings.maxUnreadArticlesInt == 0)
settings.maxUnreadArticles = 1000.0
#expect(settings.maxUnreadArticlesInt == 1000)
settings.maxUnreadArticles = 0.1
#expect(settings.maxUnreadArticlesInt == 0)
}
}

View File

@ -0,0 +1,306 @@
//
// HTMLImageEmbedderTests.swift
// readeckTests
//
// Created by Claude on 30.11.25.
//
import Testing
import Foundation
import Kingfisher
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@testable import readeck
@Suite("HTMLImageEmbedder Tests")
struct HTMLImageEmbedderTests {
// MARK: - Test Data
private let htmlWithImages = """
<html>
<body>
<img src="https://example.com/image1.jpg" alt="Image 1">
<img src="https://example.com/image2.png">
</body>
</html>
"""
private let htmlWithoutImages = """
<html>
<body>
<p>Just text, no images here.</p>
</body>
</html>
"""
private let htmlWithDataURI = """
<html>
<body>
<img src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2w==">
<img src="https://example.com/new-image.jpg">
</body>
</html>
"""
// MARK: - Helper Methods
/// Creates a test image and caches it in Kingfisher for testing
private func cacheTestImage(url: URL) async {
// Create a simple 1x1 pixel red image for testing
#if os(iOS)
let size = CGSize(width: 1, height: 1)
UIGraphicsBeginImageContextWithOptions(size, false, 1.0)
UIColor.red.setFill()
UIRectFill(CGRect(origin: .zero, size: size))
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
#elseif os(macOS)
let size = NSSize(width: 1, height: 1)
let image = NSImage(size: size)
image.lockFocus()
NSColor.red.setFill()
NSBezierPath.fill(NSRect(origin: .zero, size: size))
image.unlockFocus()
#endif
if let image = image {
// Store both in memory and on disk for testing
let options = KingfisherParsedOptionsInfo([
.cacheOriginalImage,
.diskCacheExpiration(.never)
])
try? await ImageCache.default.store(image, forKey: url.cacheKey, options: options)
// Small delay to ensure cache write completes
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
}
}
/// Clears all cached images after tests
private func clearTestCache() async {
// Clear both memory and disk cache
await ImageCache.default.clearMemoryCache()
await ImageCache.default.clearDiskCache()
// Small delay to ensure cache clear completes
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
}
// MARK: - Basic Functionality Tests
@Test("Embed Base64 images converts URLs to data URIs")
func testEmbedBase64ImagesConvertsURLs() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
// Cache test images first
let url1 = URL(string: "https://example.com/image1.jpg")!
let url2 = URL(string: "https://example.com/image2.png")!
await cacheTestImage(url: url1)
await cacheTestImage(url: url2)
let result = await embedder.embedBase64Images(in: htmlWithImages)
// Verify images were embedded as Base64
#expect(result.contains("data:image/jpeg;base64,"))
#expect(!result.contains("https://example.com/image1.jpg"))
#expect(!result.contains("https://example.com/image2.png"))
await clearTestCache()
}
@Test("Embed Base64 images skips images not in cache")
func testEmbedBase64ImagesSkipsUncachedImages() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
// Don't cache any images - all should be skipped
let result = await embedder.embedBase64Images(in: htmlWithImages)
// Original URLs should remain unchanged
#expect(result.contains("https://example.com/image1.jpg"))
#expect(result.contains("https://example.com/image2.png"))
#expect(!result.contains("data:image/jpeg;base64,"))
}
@Test("Embed Base64 images increases HTML size")
func testEmbedBase64ImagesIncreasesHTMLSize() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
// Cache one test image
let url1 = URL(string: "https://example.com/image1.jpg")!
await cacheTestImage(url: url1)
let originalSize = htmlWithImages.utf8.count
let result = await embedder.embedBase64Images(in: htmlWithImages)
let newSize = result.utf8.count
// Base64 encoded images should make HTML larger
#expect(newSize > originalSize)
await clearTestCache()
}
@Test("Embed Base64 images uses JPEG format with quality 0.85")
func testEmbedBase64ImagesUsesJPEGFormat() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
let url = URL(string: "https://example.com/image1.jpg")!
await cacheTestImage(url: url)
let result = await embedder.embedBase64Images(in: htmlWithImages)
// Verify data URI uses JPEG format
#expect(result.contains("data:image/jpeg;base64,"))
await clearTestCache()
}
// MARK: - Edge Case Tests
@Test("Embed Base64 images handles empty HTML")
func testEmbedBase64ImagesHandlesEmptyHTML() async {
let embedder = HTMLImageEmbedder()
let emptyHTML = ""
let result = await embedder.embedBase64Images(in: emptyHTML)
#expect(result.isEmpty)
#expect(result == emptyHTML)
}
@Test("Embed Base64 images handles HTML without images")
func testEmbedBase64ImagesHandlesHTMLWithoutImages() async {
let embedder = HTMLImageEmbedder()
let result = await embedder.embedBase64Images(in: htmlWithoutImages)
// Should return unchanged HTML
#expect(result == htmlWithoutImages)
#expect(!result.contains("data:image"))
}
@Test("Embed Base64 images skips already embedded data URIs")
func testEmbedBase64ImagesSkipsDataURIs() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
// Cache the non-data URI image
let url = URL(string: "https://example.com/new-image.jpg")!
await cacheTestImage(url: url)
let result = await embedder.embedBase64Images(in: htmlWithDataURI)
// Original data URI should remain
#expect(result.contains("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2w=="))
// New image should be embedded
#expect(!result.contains("https://example.com/new-image.jpg"))
await clearTestCache()
}
@Test("Embed Base64 images processes multiple images correctly")
func testEmbedBase64ImagesProcessesMultipleImages() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
let htmlMultiple = """
<img src="https://example.com/img1.jpg">
<img src="https://example.com/img2.jpg">
<img src="https://example.com/img3.jpg">
"""
// Cache all three images
for i in 1...3 {
let url = URL(string: "https://example.com/img\(i).jpg")!
await cacheTestImage(url: url)
}
let result = await embedder.embedBase64Images(in: htmlMultiple)
// All three should be embedded
let dataURICount = result.components(separatedBy: "data:image/jpeg;base64,").count - 1
#expect(dataURICount == 3)
// None of the original URLs should remain
#expect(!result.contains("https://example.com/img1.jpg"))
#expect(!result.contains("https://example.com/img2.jpg"))
#expect(!result.contains("https://example.com/img3.jpg"))
await clearTestCache()
}
// MARK: - Statistics & Logging Tests
@Test("Embed Base64 images tracks success and failure counts")
func testEmbedBase64ImagesTracksStatistics() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
let htmlMixed = """
<img src="https://cached.com/image.jpg">
<img src="https://not-cached.com/image.jpg">
"""
// Cache only the first image
let cachedURL = URL(string: "https://cached.com/image.jpg")!
await cacheTestImage(url: cachedURL)
let result = await embedder.embedBase64Images(in: htmlMixed)
// First image should be embedded
#expect(result.contains("data:image/jpeg;base64,"))
#expect(!result.contains("https://cached.com/image.jpg"))
// Second image should remain as URL
#expect(result.contains("https://not-cached.com/image.jpg"))
await clearTestCache()
}
@Test("Embed Base64 images handles invalid URLs gracefully")
func testEmbedBase64ImagesHandlesInvalidURLs() async {
// Clear cache first to ensure clean state
await clearTestCache()
let embedder = HTMLImageEmbedder()
let htmlInvalid = """
<img src="not a valid url">
<img src="https://valid.com/image.jpg">
"""
let url = URL(string: "https://valid.com/image.jpg")!
await cacheTestImage(url: url)
let result = await embedder.embedBase64Images(in: htmlInvalid)
// Invalid URL should remain unchanged
#expect(result.contains("not a valid url"))
// Valid URL should be embedded
#expect(!result.contains("https://valid.com/image.jpg"))
#expect(result.contains("data:image/jpeg;base64,"))
await clearTestCache()
}
}

View File

@ -0,0 +1,194 @@
//
// HTMLImageExtractorTests.swift
// readeckTests
//
// Created by Claude on 30.11.25.
//
import Testing
import Foundation
@testable import readeck
@Suite("HTMLImageExtractor Tests")
struct HTMLImageExtractorTests {
// MARK: - Test Data
private let htmlWithImages = """
<html>
<body>
<img src="https://example.com/image1.jpg" alt="Image 1">
<img src="https://example.com/image2.png" />
<img src="https://example.com/image3.gif">
</body>
</html>
"""
private let htmlWithMixedURLs = """
<html>
<body>
<img src="https://absolute.com/img.jpg">
<img src="/relative/path.jpg">
<img src="data:image/jpeg;base64,abc123">
<img src="https://another.com/photo.png">
</body>
</html>
"""
private let htmlWithoutImages = """
<html>
<body>
<p>This is just text content with no images.</p>
<div>Some more content</div>
</body>
</html>
"""
private let htmlEmpty = ""
// MARK: - Basic Functionality Tests
@Test("Extract finds all absolute image URLs from HTML")
func testExtractFindsAllImageURLs() {
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlWithImages)
#expect(imageURLs.count == 3)
#expect(imageURLs.contains("https://example.com/image1.jpg"))
#expect(imageURLs.contains("https://example.com/image2.png"))
#expect(imageURLs.contains("https://example.com/image3.gif"))
}
@Test("Extract only includes absolute URLs with http or https")
func testExtractOnlyIncludesAbsoluteURLs() {
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlWithMixedURLs)
#expect(imageURLs.count == 2)
#expect(imageURLs.contains("https://absolute.com/img.jpg"))
#expect(imageURLs.contains("https://another.com/photo.png"))
// Verify relative and data URIs are NOT included
#expect(!imageURLs.contains("/relative/path.jpg"))
#expect(!imageURLs.contains(where: { $0.hasPrefix("data:") }))
}
@Test("Extract returns empty array when HTML has no images")
func testExtractReturnsEmptyArrayWhenNoImages() {
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlWithoutImages)
#expect(imageURLs.isEmpty)
}
// MARK: - Edge Case Tests
@Test("Extract ignores relative URLs without http prefix")
func testExtractIgnoresRelativeURLs() {
let htmlWithRelative = """
<img src="/images/logo.png">
<img src="./photos/pic.jpg">
<img src="../assets/icon.svg">
<img src="https://valid.com/image.jpg">
"""
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlWithRelative)
#expect(imageURLs.count == 1)
#expect(imageURLs.first == "https://valid.com/image.jpg")
}
@Test("Extract handles empty HTML string")
func testExtractHandlesEmptyHTML() {
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlEmpty)
#expect(imageURLs.isEmpty)
}
@Test("Extract ignores data URI images")
func testExtractIgnoresDataURIs() {
let htmlWithDataURI = """
<img src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2w==">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">
<img src="https://example.com/real-image.jpg">
"""
let extractor = HTMLImageExtractor()
let imageURLs = extractor.extract(from: htmlWithDataURI)
#expect(imageURLs.count == 1)
#expect(imageURLs.first == "https://example.com/real-image.jpg")
// Verify no data URIs are included
for url in imageURLs {
#expect(!url.hasPrefix("data:"))
}
}
// MARK: - Hero/Thumbnail Tests
@Test("Extract with hero image prepends it to array")
func testExtractWithHeroImagePrependsToArray() {
let extractor = HTMLImageExtractor()
let heroURL = "https://example.com/hero.jpg"
let imageURLs = extractor.extract(
from: htmlWithImages,
heroImageURL: heroURL,
thumbnailURL: nil
)
#expect(imageURLs.count == 4) // 3 from HTML + 1 hero
#expect(imageURLs.first == heroURL) // Hero should be at position 0
#expect(imageURLs.contains("https://example.com/image1.jpg"))
}
@Test("Extract with thumbnail prepends it when no hero image")
func testExtractWithThumbnailPrependsWhenNoHero() {
let extractor = HTMLImageExtractor()
let thumbnailURL = "https://example.com/thumbnail.jpg"
let imageURLs = extractor.extract(
from: htmlWithImages,
heroImageURL: nil,
thumbnailURL: thumbnailURL
)
#expect(imageURLs.count == 4) // 3 from HTML + 1 thumbnail
#expect(imageURLs.first == thumbnailURL) // Thumbnail should be at position 0
}
@Test("Extract prefers hero image over thumbnail when both provided")
func testExtractPrefersHeroOverThumbnail() {
let extractor = HTMLImageExtractor()
let heroURL = "https://example.com/hero.jpg"
let thumbnailURL = "https://example.com/thumbnail.jpg"
let imageURLs = extractor.extract(
from: htmlWithImages,
heroImageURL: heroURL,
thumbnailURL: thumbnailURL
)
#expect(imageURLs.count == 4) // 3 from HTML + 1 hero (thumbnail ignored)
#expect(imageURLs.first == heroURL) // Hero takes precedence
#expect(!imageURLs.contains(thumbnailURL)) // Thumbnail should NOT be added
}
@Test("Extract with hero and thumbnail but no HTML images")
func testExtractWithHeroAndNoHTMLImages() {
let extractor = HTMLImageExtractor()
let heroURL = "https://example.com/hero.jpg"
let imageURLs = extractor.extract(
from: htmlWithoutImages,
heroImageURL: heroURL,
thumbnailURL: nil
)
#expect(imageURLs.count == 1)
#expect(imageURLs.first == heroURL)
}
}

View File

@ -0,0 +1,239 @@
//
// KingfisherImagePrefetcherTests.swift
// readeckTests
//
// Created by Claude on 30.11.25.
//
import Testing
import Foundation
import Kingfisher
@testable import readeck
import UIKit
@Suite("KingfisherImagePrefetcher Tests")
struct KingfisherImagePrefetcherTests {
// MARK: - Test Setup & Helpers
/// Mock server URL for test images
private let testImageURL1 = URL(string: "https://via.placeholder.com/150/FF0000/FFFFFF?text=Test1")!
private let testImageURL2 = URL(string: "https://via.placeholder.com/150/00FF00/FFFFFF?text=Test2")!
private let testImageURL3 = URL(string: "https://via.placeholder.com/150/0000FF/FFFFFF?text=Test3")!
/// Creates a simple test image for caching
private func createTestImage() -> KFCrossPlatformImage {
#if os(iOS)
let size = CGSize(width: 10, height: 10)
UIGraphicsBeginImageContextWithOptions(size, false, 1.0)
UIColor.blue.setFill()
UIRectFill(CGRect(origin: .zero, size: size))
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
return image
#elseif os(macOS)
let size = NSSize(width: 10, height: 10)
let image = NSImage(size: size)
image.lockFocus()
NSColor.blue.setFill()
NSBezierPath.fill(NSRect(origin: .zero, size: size))
image.unlockFocus()
return image
#endif
}
/// Clears Kingfisher cache after tests
private func clearCache() async {
await ImageCache.default.clearCache()
}
/// Checks if an image is cached
private func isImageCached(forKey key: String) async -> Bool {
await withCheckedContinuation { continuation in
ImageCache.default.retrieveImage(forKey: key) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image != nil)
case .failure:
continuation.resume(returning: false)
}
}
}
}
// MARK: - Prefetch Tests
@Test("Prefetch images handles empty URL array")
func testPrefetchImagesHandlesEmptyArray() async {
let prefetcher = KingfisherImagePrefetcher()
let emptyURLs: [URL] = []
// Should complete without errors
await prefetcher.prefetchImages(urls: emptyURLs)
// No assertions needed - just verify it doesn't crash
#expect(emptyURLs.isEmpty)
}
@Test("Prefetch images uses never expiration for disk cache")
func testPrefetchImagesUsesNeverExpiration() async {
// This test verifies the configuration is set correctly
// The actual implementation uses .diskCacheExpiration(.never)
let prefetcher = KingfisherImagePrefetcher()
// Pre-cache a test image to verify it persists
let testURL = URL(string: "https://example.com/test.jpg")!
let testImage = createTestImage()
try? await ImageCache.default.store(
testImage,
forKey: testURL.cacheKey,
options: KingfisherParsedOptionsInfo([.diskCacheExpiration(.never)])
)
let isCached = await isImageCached(forKey: testURL.cacheKey)
#expect(isCached == true)
await clearCache()
}
@Test("Verify prefetched images confirms cache status")
func testVerifyPrefetchedImagesConfirmsCacheStatus() async {
let prefetcher = KingfisherImagePrefetcher()
// Manually cache some test images
let url1 = URL(string: "https://example.com/cached1.jpg")!
let url2 = URL(string: "https://example.com/cached2.jpg")!
let url3 = URL(string: "https://example.com/not-cached.jpg")!
let testImage = createTestImage()
try? await ImageCache.default.store(testImage, forKey: url1.cacheKey)
try? await ImageCache.default.store(testImage, forKey: url2.cacheKey)
// Verify the cached ones
await prefetcher.verifyPrefetchedImages([url1, url2, url3])
// Check that first two are cached
let isCached1 = await isImageCached(forKey: url1.cacheKey)
let isCached2 = await isImageCached(forKey: url2.cacheKey)
let isCached3 = await isImageCached(forKey: url3.cacheKey)
#expect(isCached1 == true)
#expect(isCached2 == true)
#expect(isCached3 == false)
await clearCache()
}
// MARK: - Custom Cache Key Tests
@Test("Cache image with custom key stores correctly")
func testCacheImageWithCustomKeyStoresCorrectly() async {
let prefetcher = KingfisherImagePrefetcher()
let customKey = "bookmark-123-hero"
// Pre-cache a test image with URL key so it can be "downloaded"
let sourceURL = URL(string: "https://example.com/hero.jpg")!
let testImage = createTestImage()
try? await ImageCache.default.store(testImage, forKey: sourceURL.cacheKey)
// Now use the prefetcher to cache with custom key
await prefetcher.cacheImageWithCustomKey(url: sourceURL, key: customKey)
// Verify it's cached with custom key
let isCached = await isImageCached(forKey: customKey)
#expect(isCached == true)
await clearCache()
}
@Test("Cache image with custom key skips if already cached")
func testCacheImageWithCustomKeySkipsIfAlreadyCached() async {
let prefetcher = KingfisherImagePrefetcher()
let customKey = "bookmark-456-hero"
let sourceURL = URL(string: "https://example.com/hero2.jpg")!
// Pre-cache with custom key
let testImage = createTestImage()
try? await ImageCache.default.store(testImage, forKey: customKey)
// Call again - should skip (verify by checking it doesn't fail)
await prefetcher.cacheImageWithCustomKey(url: sourceURL, key: customKey)
// Should still be cached
let isCached = await isImageCached(forKey: customKey)
#expect(isCached == true)
await clearCache()
}
// MARK: - Clear Cache Tests
@Test("Clear cached images removes all specified URLs")
func testClearCachedImagesRemovesAllURLs() async {
let prefetcher = KingfisherImagePrefetcher()
// Cache some test images
let url1 = URL(string: "https://example.com/clear1.jpg")!
let url2 = URL(string: "https://example.com/clear2.jpg")!
let testImage = createTestImage()
try? await ImageCache.default.store(testImage, forKey: url1.cacheKey)
try? await ImageCache.default.store(testImage, forKey: url2.cacheKey)
// Verify they are cached
var isCached1 = await isImageCached(forKey: url1.cacheKey)
var isCached2 = await isImageCached(forKey: url2.cacheKey)
#expect(isCached1 == true)
#expect(isCached2 == true)
// Clear them
await prefetcher.clearCachedImages(urls: [url1, url2])
// Verify they are removed
isCached1 = await isImageCached(forKey: url1.cacheKey)
isCached2 = await isImageCached(forKey: url2.cacheKey)
#expect(isCached1 == false)
#expect(isCached2 == false)
}
@Test("Clear cached images handles empty array")
func testClearCachedImagesHandlesEmptyArray() async {
let prefetcher = KingfisherImagePrefetcher()
let emptyURLs: [URL] = []
// Should complete without errors
await prefetcher.clearCachedImages(urls: emptyURLs)
// No assertions needed - just verify it doesn't crash
#expect(emptyURLs.isEmpty)
}
// MARK: - Integration Tests
@Test("Prefetch and verify workflow")
func testPrefetchAndVerifyWorkflow() async {
let prefetcher = KingfisherImagePrefetcher()
// Pre-populate cache with test images
let urls = [
URL(string: "https://example.com/workflow1.jpg")!,
URL(string: "https://example.com/workflow2.jpg")!
]
let testImage = createTestImage()
for url in urls {
try? await ImageCache.default.store(testImage, forKey: url.cacheKey)
}
// Verify they were cached
await prefetcher.verifyPrefetchedImages(urls)
for url in urls {
let isCached = await isImageCached(forKey: url.cacheKey)
#expect(isCached == true)
}
await clearCache()
}
}