diff --git a/DBMultiverseComicKit/Sources/DBMultiverseComicKit/CoverImageCache/CoverImageMetaData.swift b/DBMultiverseComicKit/Sources/DBMultiverseComicKit/CoverImageCache/CoverImageMetaData.swift index a0f642f..b46da15 100644 --- a/DBMultiverseComicKit/Sources/DBMultiverseComicKit/CoverImageCache/CoverImageMetaData.swift +++ b/DBMultiverseComicKit/Sources/DBMultiverseComicKit/CoverImageCache/CoverImageMetaData.swift @@ -7,7 +7,7 @@ import Foundation -public struct CoverImageMetaData { +public struct CoverImageMetaData: Equatable { public let chapterName: String public let chapterNumber: Int public let readProgress: Int diff --git a/DBMultiverseComicKit/Tests/DBMultiverseComicKitTests/ComicPageViewModelTests.swift b/DBMultiverseComicKit/Tests/DBMultiverseComicKitTests/ComicPageViewModelTests.swift index 7b1402c..6bb8f77 100644 --- a/DBMultiverseComicKit/Tests/DBMultiverseComicKitTests/ComicPageViewModelTests.swift +++ b/DBMultiverseComicKit/Tests/DBMultiverseComicKitTests/ComicPageViewModelTests.swift @@ -11,80 +11,82 @@ import NnSwiftTestingHelpers @testable import DBMultiverseComicKit @MainActor -final class ComicPageViewModelTests: TrackingMemoryLeaks { +@LeakTracked +final class ComicPageViewModelTests { @Test("Starting values are initialized correctly") func emptyStartingValues() { let currentPageNumber = 1 let (sut, delegate) = makeSUT(currentPageNumber: currentPageNumber) - + #expect(sut.pages.isEmpty) #expect(!sut.didFetchInitialPages) #expect(sut.currentPageNumber == currentPageNumber) #expect(delegate.savedPageInfo == nil) } - - // MARK: - Display Data Tests - +} + +// MARK: - Display Data +extension ComicPageViewModelTests { @Test("Current page position reflects single page correctly") func currentPagePositionSinglePage() { let pageInfo = makePageInfo(pageNumber: 5, secondPageNumber: nil) let chapter = makeChapter(startPage: 1, endPage: 10) let (sut, _) = makeSUT(chapter: chapter, currentPageNumber: 5, currentPages: [pageInfo]) - + let position = sut.currentPagePosition - + #expect(position.page == 5) #expect(position.secondPage == nil) #expect(position.endPage == 10) } - + @Test("Current page position reflects double page spread correctly") func currentPagePositionDoublePage() { let pageInfo = makePageInfo(pageNumber: 4, secondPageNumber: 5) let chapter = makeChapter(startPage: 1, endPage: 10) let (sut, _) = makeSUT(chapter: chapter, currentPageNumber: 4, currentPages: [pageInfo]) - + let position = sut.currentPagePosition - + #expect(position.page == 4) #expect(position.secondPage == 5) #expect(position.endPage == 10) } - + @Test("Current page info returns nil when page not loaded") func currentPageInfoMissing() { - let (sut, _) = makeSUT(currentPageNumber: 5, currentPages: []) - + let sut = makeSUT(currentPageNumber: 5, currentPages: []).sut + #expect(sut.currentPageInfo == nil) } - + @Test("Current page info returns correct page when loaded") func currentPageInfoPresent() { let pageInfo = makePageInfo(pageNumber: 5) - let (sut, _) = makeSUT(currentPageNumber: 5, currentPages: [pageInfo]) - + let sut = makeSUT(currentPageNumber: 5, currentPages: [pageInfo]).sut + let result = sut.currentPageInfo - + #expect(result?.pageNumber == 5) #expect(result?.imageData == pageInfo.imageData) } - + @Test("Current page returns nil when page info missing") func currentPageMissing() { - let (sut, _) = makeSUT(currentPageNumber: 5, currentPages: []) - + let sut = makeSUT(currentPageNumber: 5, currentPages: []).sut + #expect(sut.currentPage == nil) } - + @Test("Current page returns complete page data when available") func currentPageComplete() { let chapterName = "Test Chapter" let pageInfo = makePageInfo(pageNumber: 5, secondPageNumber: 6) let chapter = makeChapter(name: chapterName, startPage: 1, endPage: 10) - let (sut, _) = makeSUT(chapter: chapter, currentPageNumber: 5, currentPages: [pageInfo]) - + let sut = makeSUT(chapter: chapter, currentPageNumber: 5, currentPages: [pageInfo]).sut + let result = sut.currentPage - + #expect(result?.number == 5) #expect(result?.chapterName == chapterName) #expect(result?.pagePosition.page == 5) @@ -92,136 +94,138 @@ final class ComicPageViewModelTests: TrackingMemoryLeaks { #expect(result?.pagePosition.endPage == 10) #expect(result?.imageData == pageInfo.imageData) } - - // MARK: - Load Data Tests - +} + +// MARK: - Load Data +extension ComicPageViewModelTests { @Test("Loading data fetches initial pages when not previously loaded") func loadDataFetchesInitialPages() async throws { let pagesToLoad = [makePageInfo(pageNumber: 3), makePageInfo(pageNumber: 4), makePageInfo(pageNumber: 5)] let chapter = makeChapter(startPage: 1, endPage: 20) let sut = makeSUT(chapter: chapter, currentPageNumber: 3, pagesToLoad: pagesToLoad).sut - + try await sut.loadData() try await sut.$didFetchInitialPages.waitUntil(condition: { $0 }) - + #expect(sut.pages.count == 3) #expect(sut.pages.map(\.pageNumber).sorted() == [3, 4, 5]) } - + @Test("Loading data respects chapter end page limit") func loadDataRespectsEndPageLimit() async throws { let pagesToLoad = [makePageInfo(pageNumber: 9), makePageInfo(pageNumber: 10)] let chapter = makeChapter(startPage: 1, endPage: 10) - let (sut, _) = makeSUT(chapter: chapter, currentPageNumber: 9, pagesToLoad: pagesToLoad) - + let sut = makeSUT(chapter: chapter, currentPageNumber: 9, pagesToLoad: pagesToLoad).sut + try await sut.loadData() try await sut.$didFetchInitialPages.waitUntil(condition: { $0 }) - + #expect(sut.pages.count == 2) #expect(sut.pages.map(\.pageNumber).sorted() == [9, 10]) } - + @Test("Loading data propagates delegate errors") func loadDataPropagatesErrors() async { - let (sut, _) = makeSUT(throwError: true) - - + let sut = makeSUT(throwError: true).sut + await #expect(throws: (any Error).self) { try await sut.loadData() } - + #expect(!sut.didFetchInitialPages) #expect(sut.pages.isEmpty) } - - // MARK: - Page Navigation Tests - +} + +// MARK: - Page Navigation +extension ComicPageViewModelTests { @Test("Moving to next page advances to correct page number") func nextPageAdvancesCorrectly() { let currentPageInfo = makePageInfo(pageNumber: 5, secondPageNumber: nil) let chapter = makeChapter(startPage: 1, endPage: 10) - let (sut, _) = makeSUT(chapter: chapter, currentPageNumber: 5, currentPages: [currentPageInfo]) - + let sut = makeSUT(chapter: chapter, currentPageNumber: 5, currentPages: [currentPageInfo]).sut + sut.nextPage() - + #expect(sut.currentPageNumber == 6) } - + @Test("Moving to next page from double spread advances correctly") func nextPageFromDoubleSpread() { let currentPageInfo = makePageInfo(pageNumber: 4, secondPageNumber: 5) let chapter = makeChapter(startPage: 1, endPage: 10) - let (sut, _) = makeSUT(chapter: chapter, currentPageNumber: 4, currentPages: [currentPageInfo]) - + let sut = makeSUT(chapter: chapter, currentPageNumber: 4, currentPages: [currentPageInfo]).sut + sut.nextPage() - + #expect(sut.currentPageNumber == 6) } - + @Test("Moving to next page stops at chapter end") func nextPageStopsAtEnd() { let currentPageInfo = makePageInfo(pageNumber: 10, secondPageNumber: nil) let chapter = makeChapter(startPage: 1, endPage: 10) - let (sut, _) = makeSUT(chapter: chapter, currentPageNumber: 10, currentPages: [currentPageInfo]) - + let sut = makeSUT(chapter: chapter, currentPageNumber: 10, currentPages: [currentPageInfo]).sut + sut.nextPage() - + #expect(sut.currentPageNumber == 10) } - + @Test("Moving to previous page decrements correctly") func previousPageDecrementsCorrectly() throws { let page4 = makePageInfo(pageNumber: 4) let page5 = makePageInfo(pageNumber: 5) let currentPages = [page4, page5] let (sut, delegate) = makeSUT(currentPageNumber: page5.pageNumber, currentPages: currentPages) - + sut.previousPage() - + #expect(sut.currentPageNumber == page4.pageNumber) - + let delegatePage = try #require(delegate.updatedPageNumber) - + #expect(delegatePage == page4.pageNumber) } - + @Test("Moving to previous page on a 'double page' skips a page number") func previousPageFromDoublePageSkipsNumber() throws { let page2 = makePageInfo(pageNumber: 2) let page4 = makePageInfo(pageNumber: 4) let currentPages = [page2, page4] let (sut, delegate) = makeSUT(currentPageNumber: page4.pageNumber, currentPages: currentPages) - + sut.previousPage() - + #expect(sut.currentPageNumber == page2.pageNumber) - + let delegatePage = try #require(delegate.updatedPageNumber) - + #expect(delegatePage == page2.pageNumber) } - + @Test("Moving to previous page stops at chapter start") func previousPageStopsAtStart() { let chapter = makeChapter(startPage: 1, endPage: 10) - let (sut, _) = makeSUT(chapter: chapter, currentPageNumber: 1) - + let sut = makeSUT(chapter: chapter, currentPageNumber: 1).sut + sut.previousPage() - + #expect(sut.currentPageNumber == 1) } - + @Test("Moving to previous page skips missing page info") func previousPageSkipsMissingInfo() { - let (sut, _) = makeSUT(currentPageNumber: 5, currentPages: []) - + let sut = makeSUT(currentPageNumber: 5, currentPages: []).sut + sut.previousPage() - + #expect(sut.currentPageNumber == 3) } - - // MARK: - Background Loading Tests - +} + +// MARK: - Background Loading +extension ComicPageViewModelTests { @Test("Loading data triggers background loading of remaining pages") func loadDataTriggersBackgroundLoading() async throws { let initialPages = [makePageInfo(pageNumber: 3), makePageInfo(pageNumber: 4), makePageInfo(pageNumber: 5)] @@ -229,15 +233,15 @@ final class ComicPageViewModelTests: TrackingMemoryLeaks { let allPages = initialPages + remainingPages let chapter = makeChapter(startPage: 1, endPage: 5) let sut = makeSUT(chapter: chapter, currentPageNumber: 3, pagesToLoad: allPages).sut - + try await sut.loadData() try await sut.$didFetchInitialPages.waitUntil(condition: { $0 }) try await sut.$pages.waitUntil(condition: { $0.count == 5 }) - + #expect(sut.pages.count == 5) #expect(sut.pages.map(\.pageNumber).sorted() == [1, 2, 3, 4, 5]) } - + @Test("Loading data caches cover image when start page is loaded in background") func loadDataCachesCoverImageInBackground() async throws { let initialPages = [makePageInfo(pageNumber: 2), makePageInfo(pageNumber: 3)] @@ -245,37 +249,49 @@ final class ComicPageViewModelTests: TrackingMemoryLeaks { let allPages = initialPages + [coverPageInfo] let chapter = makeChapter(startPage: 1, endPage: 3) let (sut, delegate) = makeSUT(chapter: chapter, currentPageNumber: 2, pagesToLoad: allPages) - + try await sut.loadData() try await sut.$didFetchInitialPages.waitUntil(condition: { $0 }) try await sut.$pages.waitUntil(condition: { $0.count == 3 }) - + #expect(delegate.savedPageInfo?.pageNumber == 1) } } - // MARK: - SUT private extension ComicPageViewModelTests { - func makeSUT(chapter: Chapter? = nil, currentPageNumber: Int = 1, currentPages: [PageInfo] = [], pagesToLoad: [PageInfo] = [], throwError: Bool = false, fileID: String = #fileID, filePath: String = #filePath, line: Int = #line, column: Int = #column) -> (sut: ComicPageViewModel, delegate: MockDelegate) { + func makeSUT( + chapter: Chapter? = nil, + currentPageNumber: Int = 1, + currentPages: [PageInfo] = [], + pagesToLoad: [PageInfo] = [], + throwError: Bool = false, + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column + ) -> (sut: ComicPageViewModel, delegate: MockDelegate) { let delegate = MockDelegate(throwError: throwError, pagesToLoad: pagesToLoad) let sut = ComicPageViewModel(chapter: chapter ?? makeChapter(), currentPageNumber: currentPageNumber, delegate: delegate, pages: currentPages) - + trackForMemoryLeaks(sut, fileID: fileID, filePath: filePath, line: line, column: column) - + trackForMemoryLeaks(delegate, fileID: fileID, filePath: filePath, line: line, column: column) + return (sut, delegate) } - +} + +// MARK: - Test Helpers +private extension ComicPageViewModelTests { func makeChapter(name: String = "", number: Int = 0, startPage: Int = 0, endPage: Int = 10) -> Chapter { return .init(name: name, number: number, startPage: startPage, endPage: endPage, universe: nil, lastReadPage: nil, coverImageURL: "", didFinishReading: false) } - + func makePageInfo(pageNumber: Int, secondPageNumber: Int? = nil) -> PageInfo { return PageInfo(chapter: 0, pageNumber: pageNumber, secondPageNumber: secondPageNumber, imageData: Data("test\(pageNumber)".utf8)) } } - // MARK: - Mocks private extension ComicPageViewModelTests { final class MockDelegate: ComicPageDelegate, @unchecked Sendable { @@ -283,23 +299,23 @@ private extension ComicPageViewModelTests { private let pagesToLoad: [PageInfo] private(set) var savedPageInfo: PageInfo? private(set) var updatedPageNumber: Int? - + init(throwError: Bool, pagesToLoad: [PageInfo]) { self.throwError = throwError self.pagesToLoad = pagesToLoad } - + func saveChapterCoverPage(_ info: PageInfo) { savedPageInfo = info } - + func updateCurrentPageNumber(_ pageNumber: Int) { updatedPageNumber = pageNumber } - + func loadPages(_ pages: [Int]) async throws -> [PageInfo] { if throwError { throw NSError(domain: "Test", code: 0) } - + return pagesToLoad } } diff --git a/DBMultiverseComicKit/Tests/DBMultiverseComicKitTests/CoverImageManagerTests.swift b/DBMultiverseComicKit/Tests/DBMultiverseComicKitTests/CoverImageManagerTests.swift index f10bb9a..efc9cd0 100644 --- a/DBMultiverseComicKit/Tests/DBMultiverseComicKitTests/CoverImageManagerTests.swift +++ b/DBMultiverseComicKit/Tests/DBMultiverseComicKitTests/CoverImageManagerTests.swift @@ -11,67 +11,70 @@ import NnSwiftTestingHelpers @testable import DBMultiverseComicKit struct CoverImageManagerTests { - // MARK: - Load Data Tests @Test("Load current chapter data returns nil when file doesn't exist") func loadCurrentChapterDataReturnsNilWhenFileDoesntExist() { let (sut, mockFileSystem, _) = makeSUT(throwError: true) let result = sut.loadCurrentChapterData() - + #expect(result == nil) #expect(mockFileSystem.readDataCallCount == 1) } - +} + +// MARK: - Load Data +extension CoverImageManagerTests { @Test("Load current chapter data returns decoded data when file exists") func loadCurrentChapterDataReturnsDecodedData() throws { let expectedData = makeCurrentChapterData() let jsonData = try JSONEncoder().encode(expectedData) let (sut, mockFileSystem, _) = makeSUT(mockReadData: jsonData) - + let result = sut.loadCurrentChapterData() - + #expect(result == expectedData) #expect(mockFileSystem.readDataCallCount == 1) } - + @Test("Load current chapter data returns nil when JSON decoding fails") func loadCurrentChapterDataReturnsNilWhenDecodingFails() { let (sut, mockFileSystem, _) = makeSUT(mockReadData: Data("invalid json".utf8)) - + let result = sut.loadCurrentChapterData() - + #expect(result == nil) #expect(mockFileSystem.readDataCallCount == 1) } - - // MARK: - Save Data Tests - +} + +// MARK: - Save Data +extension CoverImageManagerTests { @Test("Save current chapter data with metadata compresses and saves image") func saveCurrentChapterDataWithMetadataCompressesAndSaves() { let imageData = Data("test image".utf8) let compressedData = Data("compressed".utf8) let metadata = makeImageMetaData() let (sut, mockFileSystem, mockCompressor) = makeSUT(mockCompressedData: compressedData) - + sut.saveCurrentChapterData(imageData: imageData, metadata: metadata) - + #expect(mockCompressor.compressCallCount == 1) #expect(mockCompressor.lastCompressedData == imageData) - #expect(mockFileSystem.writeCallCount == 2) // Image + JSON + #expect(mockFileSystem.writeCallCount == 2) #expect(mockFileSystem.writtenData.contains(compressedData)) } - + @Test("Save current chapter data with metadata fails when compression fails") func saveCurrentChapterDataWithMetadataFailsWhenCompressionFails() { let imageData = Data("test image".utf8) let metadata = makeImageMetaData() let (sut, mockFileSystem, mockCompressor) = makeSUT(mockCompressedData: nil) - + sut.saveCurrentChapterData(imageData: imageData, metadata: metadata) - + #expect(mockCompressor.compressCallCount == 1) #expect(mockFileSystem.writeCallCount == 0) } - + @Test("Save current chapter data with parameters compresses and saves image") func saveCurrentChapterDataWithParametersCompressesAndSaves() { let imageData = Data("test image".utf8) @@ -80,43 +83,43 @@ struct CoverImageManagerTests { let name = "Test Chapter" let progress = 75 let (sut, mockFileSystem, mockCompressor) = makeSUT(mockCompressedData: compressedData) - + sut.saveCurrentChapterData(chapter: chapter, name: name, progress: progress, imageData: imageData) - + #expect(mockCompressor.compressCallCount == 1) #expect(mockCompressor.lastCompressedData == imageData) - #expect(mockFileSystem.writeCallCount == 2) // Image + JSON + #expect(mockFileSystem.writeCallCount == 2) #expect(mockFileSystem.writtenData.contains(compressedData)) } - + @Test("Save current chapter data handles write errors gracefully") func saveCurrentChapterDataHandlesWriteErrors() { let imageData = Data("test image".utf8) let compressedData = Data("compressed".utf8) let metadata = makeImageMetaData() let (sut, mockFileSystem, mockCompressor) = makeSUT(mockCompressedData: compressedData, throwError: true) - + sut.saveCurrentChapterData(imageData: imageData, metadata: metadata) - + #expect(mockCompressor.compressCallCount == 1) - #expect(mockFileSystem.writeCallCount == 1) // Fails on first write, doesn't reach second + #expect(mockFileSystem.writeCallCount == 1) } - - // MARK: - Update Progress Tests - +} + +// MARK: - Update Progress +extension CoverImageManagerTests { @Test("Update progress reads existing data and saves with new progress") func updateProgressReadsExistingDataAndSaves() throws { let originalData = makeCurrentChapterData(progress: 50) let jsonData = try JSONEncoder().encode(originalData) let newProgress = 85 let (sut, mockFileSystem, _) = makeSUT(mockReadData: jsonData) - + sut.updateProgress(to: newProgress) - + #expect(mockFileSystem.readDataCallCount == 1) #expect(mockFileSystem.writeCallCount == 1) - - // Verify the saved data has updated progress + let savedData = mockFileSystem.writtenData.last let decodedData = try JSONDecoder().decode(CurrentChapterData.self, from: savedData!) #expect(decodedData.progress == newProgress) @@ -124,49 +127,55 @@ struct CoverImageManagerTests { #expect(decodedData.name == originalData.name) #expect(decodedData.coverImagePath == originalData.coverImagePath) } - + @Test("Update progress handles read errors gracefully") func updateProgressHandlesReadErrors() { let (sut, mockFileSystem, _) = makeSUT(throwError: true) - + sut.updateProgress(to: 85) - + #expect(mockFileSystem.readDataCallCount == 1) #expect(mockFileSystem.writeCallCount == 0) } - + @Test("Update progress handles decoding errors gracefully") func updateProgressHandlesDecodingErrors() { let (sut, mockFileSystem, _) = makeSUT(mockReadData: Data("invalid json".utf8)) - + sut.updateProgress(to: 85) - + #expect(mockFileSystem.readDataCallCount == 1) #expect(mockFileSystem.writeCallCount == 0) } } - // MARK: - SUT private extension CoverImageManagerTests { - func makeSUT(containerURL: URL = .init(fileURLWithPath: "/test/container"), mockReadData: Data = Data(), mockCompressedData: Data? = Data("compressed".utf8), throwError: Bool = false) -> (sut: CoverImageManager, fileSystem: MockFileSystemManager, compressor: MockImageCompressor) { + func makeSUT( + containerURL: URL = .init(fileURLWithPath: "/test/container"), + mockReadData: Data = Data(), + mockCompressedData: Data? = Data("compressed".utf8), + throwError: Bool = false + ) -> (sut: CoverImageManager, fileSystem: MockFileSystemManager, compressor: MockImageCompressor) { let mockCompressor = MockImageCompressor(mockCompressedData: mockCompressedData) let mockFileSystem = MockFileSystemManager(mockContainerURL: containerURL, mockReadData: mockReadData, shouldThrowError: throwError) let sut = CoverImageManager(appGroupIdentifier: "test.group.identifier", fileSystemManager: mockFileSystem, imageCompressor: mockCompressor) - + return (sut, mockFileSystem, mockCompressor) } - +} + +// MARK: - Test Helpers +private extension CoverImageManagerTests { func makeImageMetaData(name: String = "Test Chapter", number: Int = 42, readProgres: Int = 75) -> CoverImageMetaData { return .init(chapterName: name, chapterNumber: number, readProgress: readProgres) } - + func makeCurrentChapterData(number: Int = 42, name: String = "Test Chapter", progress: Int = 75, coverImagePath: String = "/path/to/image.jpg") -> CurrentChapterData { return .init(number: number, name: name, progress: progress, coverImagePath: coverImagePath) } } - // MARK: - Mocks private extension CoverImageManagerTests { final class MockFileSystemManager: FileSystem, @unchecked Sendable { @@ -178,18 +187,18 @@ private extension CoverImageManagerTests { private(set) var writeCallCount = 0 private(set) var writtenData: [Data] = [] private(set) var writtenURLs: [URL] = [] - + init(mockContainerURL: URL, mockReadData: Data, shouldThrowError: Bool) { self.mockContainerURL = mockContainerURL self.mockReadData = mockReadData self.shouldThrowError = shouldThrowError } - + func containerURL(forSecurityApplicationGroupIdentifier identifier: String) -> URL? { requestedIdentifier = identifier return mockContainerURL } - + func write(data: Data, to url: URL) throws { writeCallCount += 1 if shouldThrowError { @@ -198,7 +207,7 @@ private extension CoverImageManagerTests { writtenData.append(data) writtenURLs.append(url) } - + func readData(from url: URL) throws -> Data { readDataCallCount += 1 if shouldThrowError { @@ -207,16 +216,16 @@ private extension CoverImageManagerTests { return mockReadData } } - + final class MockImageCompressor: ImageCompressing, @unchecked Sendable { private(set) var mockCompressedData: Data? private(set) var compressCallCount = 0 private(set) var lastCompressedData: Data? - + init(mockCompressedData: Data? = Data("compressed".utf8)) { self.mockCompressedData = mockCompressedData } - + func compressImageData(_ data: Data) -> Data? { compressCallCount += 1 lastCompressedData = data diff --git a/DBMultiverseParseKit/Sources/DBMultiverseParseKit/ComicHTMLParser.swift b/DBMultiverseParseKit/Sources/DBMultiverseParseKit/ComicHTMLParser.swift index 5d95fd7..f2c3b32 100644 --- a/DBMultiverseParseKit/Sources/DBMultiverseParseKit/ComicHTMLParser.swift +++ b/DBMultiverseParseKit/Sources/DBMultiverseParseKit/ComicHTMLParser.swift @@ -13,8 +13,11 @@ public enum ComicHTMLParser { do { let html = try makeHTML(from: data) let document = try SwiftSoup.parse(html) - - guard let imgElement = try document.select("img[id=balloonsimg]").first() else { + + guard let imgElement = try document + .select("#balloonsimg img") + .first() + else { throw ComicParseError.imageElementNotFound } diff --git a/DBMultiverseUnitTests/UnitTests/ComicImageCacheManagerTests.swift b/DBMultiverseUnitTests/UnitTests/ComicImageCacheManagerTests.swift index 873329e..f8bd2a6 100644 --- a/DBMultiverseUnitTests/UnitTests/ComicImageCacheManagerTests.swift +++ b/DBMultiverseUnitTests/UnitTests/ComicImageCacheManagerTests.swift @@ -12,93 +12,88 @@ import DBMultiverseComicKit @testable import DBMultiverse @MainActor -final class ComicImageCacheManagerTests: TrackingMemoryLeaks { - - // MARK: - Initial State Tests - +@LeakTracked +final class ComicImageCacheManagerTests { @Test("Initialization starts with empty state") func initStartsWithEmptyState() { let (_, store, fileSystem, coverDelegate) = makeSUT() - + #expect(store.lastUpdateInfo == nil) #expect(coverDelegate.lastProgressUpdate == nil) #expect(coverDelegate.lastSavedChapterData == nil) #expect(fileSystem.writtenData.isEmpty) #expect(fileSystem.createdDirectories.isEmpty) } - - // MARK: - Update Current Page Number Tests - - @Test("Update current page number updates cover image progress") - func updateCurrentPageNumberUpdatesCoverImageProgress() { +} + +// MARK: - Update Current Page Number +extension ComicImageCacheManagerTests { + @Test("Updates cover image progress") + func updatesCoverImageProgress() { let pageNumber = 5 let readProgress = 50 let (sut, _, _, coverDelegate) = makeSUT() - + sut.updateCurrentPageNumber(pageNumber, readProgress: readProgress) - + #expect(coverDelegate.lastProgressUpdate == readProgress) } - - @Test("Update current page number updates store with page number and comic type") - func updateCurrentPageNumberUpdatesStore() async { + + @Test("Updates store with page number and comic type") + func updatesStoreWithPageNumberAndComicType() async throws { let pageNumber = 7 let readProgress = 70 let comicType = ComicType.specials let (sut, store, _, _) = makeSUT(comicType: comicType) - + sut.updateCurrentPageNumber(pageNumber, readProgress: readProgress) - - // Wait for async dispatch to complete - try? await Task.sleep(for: .milliseconds(100)) - - #expect(store.lastUpdateInfo?.pageNumber == pageNumber) - #expect(store.lastUpdateInfo?.comicType == comicType) + + let expectedUpdate = MockComicPageStore.UpdateInfo(pageNumber: pageNumber, comicType: comicType) + try await store.$lastUpdateInfo.waitUntil { $0 == expectedUpdate } } - - // MARK: - Save Chapter Cover Image Tests - - @Test("Save chapter cover image delegates to cover image delegate") - func saveChapterCoverImageDelegatesToCoverImageDelegate() throws { +} + +// MARK: - Save Chapter Cover Image +extension ComicImageCacheManagerTests { + @Test("Delegates to cover image delegate") + func delegatesToCoverImageDelegate() throws { let imageData = Data("test-image".utf8) - let metadata = CoverImageMetaData(chapterName: "Chapter 1", chapterNumber: 1, readProgress: 25) + let metadata = makeCoverImageMetadata() let (sut, _, _, coverDelegate) = makeSUT() - + try sut.saveChapterCoverImage(imageData: imageData, metadata: metadata) - - #expect(coverDelegate.lastSavedChapterData?.imageData == imageData) - #expect(coverDelegate.lastSavedChapterData?.metadata.chapterName == metadata.chapterName) - #expect(coverDelegate.lastSavedChapterData?.metadata.chapterNumber == metadata.chapterNumber) - #expect(coverDelegate.lastSavedChapterData?.metadata.readProgress == metadata.readProgress) + + let expectedData = MockCoverImageDelegate.SavedChapterData(imageData: imageData, metadata: metadata) + #expect(coverDelegate.lastSavedChapterData == expectedData) } - - // MARK: - Load Cached Image Tests - - @Test("Load cached image returns single page when file exists") - func loadCachedImageReturnsSinglePageWhenFileExists() throws { +} + +// MARK: - Load Cached Image +extension ComicImageCacheManagerTests { + @Test("Returns single page when file exists") + func returnsSinglePageWhenFileExists() throws { let chapter = 3 let page = 5 let expectedData = Data("cached-image".utf8) let (sut, _, fileSystem, _) = makeSUT() fileSystem.fileContents["/Users/test/Library/Caches/Chapters/Chapter_3/Page_5.jpg"] = expectedData - + let result = try sut.loadCachedImage(chapter: chapter, page: page) - + #expect(result?.chapter == chapter) #expect(result?.pageNumber == page) #expect(result?.secondPageNumber == nil) #expect(result?.imageData == expectedData) } - - @Test("Load cached image returns double page from metadata") - func loadCachedImageReturnsDoublePageFromMetadata() throws { + + @Test("Returns double page from metadata") + func returnsDoublePageFromMetadata() throws { let chapter = 2 let page = 8 let secondPage = 9 let expectedData = Data("double-page-image".utf8) let (sut, _, fileSystem, _) = makeSUT() - - // Setup metadata + let metadata: [String: Any] = [ "pages": [ ["pageNumber": page, "secondPageNumber": secondPage, "fileName": "Page_8-9.jpg"] @@ -107,53 +102,66 @@ final class ComicImageCacheManagerTests: TrackingMemoryLeaks { let metadataData = try JSONSerialization.data(withJSONObject: metadata) fileSystem.fileContents["/Users/test/Library/Caches/Chapters/Chapter_2/metadata.json"] = metadataData fileSystem.fileContents["/Users/test/Library/Caches/Chapters/Chapter_2/Page_8-9.jpg"] = expectedData - + let result = try sut.loadCachedImage(chapter: chapter, page: page) - + #expect(result?.chapter == chapter) #expect(result?.pageNumber == page) #expect(result?.secondPageNumber == secondPage) #expect(result?.imageData == expectedData) } - - @Test("Load cached image returns nil when no file or metadata exists") - func loadCachedImageReturnsNilWhenNoFileExists() throws { - let (sut, _, _, _) = makeSUT() - + + @Test("Returns nil when no file or metadata exists") + func returnsNilWhenNoFileExists() throws { + let sut = makeSUT().sut + let result = try sut.loadCachedImage(chapter: 1, page: 1) - + #expect(result == nil) } - - // MARK: - Save Page Image Tests - - @Test("Save page image creates directory and writes single page data") - func savePageImageCreatesDirectoryAndWritesSinglePageData() throws { - let pageInfo = PageInfo(chapter: 5, pageNumber: 10, secondPageNumber: nil, imageData: Data("page-data".utf8)) + + @Test("Handles corrupted metadata gracefully") + func handlesCorruptedMetadataGracefully() throws { + let chapter = 1 + let page = 1 let (sut, _, fileSystem, _) = makeSUT() - + + fileSystem.fileContents["/Users/test/Library/Caches/Chapters/Chapter_1/metadata.json"] = Data("invalid json".utf8) + + let result = try sut.loadCachedImage(chapter: chapter, page: page) + + #expect(result == nil) + } +} + +// MARK: - Save Page Image +extension ComicImageCacheManagerTests { + @Test("Creates directory and writes single page data") + func createsDirectoryAndWritesSinglePageData() throws { + let pageInfo = makePageInfo(chapter: 5, pageNumber: 10) + let (sut, _, fileSystem, _) = makeSUT() + try sut.savePageImage(pageInfo: pageInfo) - + let expectedDir = URL(fileURLWithPath: "/Users/test/Library/Caches/Chapters/Chapter_5/") let expectedFile = URL(fileURLWithPath: "/Users/test/Library/Caches/Chapters/Chapter_5/Page_10.jpg") - + #expect(fileSystem.createdDirectories.contains(expectedDir)) #expect(fileSystem.writtenData[expectedFile] == pageInfo.imageData) } - - @Test("Save page image saves double page with metadata") - func savePageImageSavesDoublePageWithMetadata() throws { - let pageInfo = PageInfo(chapter: 3, pageNumber: 20, secondPageNumber: 21, imageData: Data("double-page".utf8)) + + @Test("Saves double page with metadata") + func savesDoublePageWithMetadata() throws { + let pageInfo = makePageInfo(chapter: 3, pageNumber: 20, secondPageNumber: 21) let (sut, _, fileSystem, _) = makeSUT() - + try sut.savePageImage(pageInfo: pageInfo) - + let expectedImageFile = URL(fileURLWithPath: "/Users/test/Library/Caches/Chapters/Chapter_3/Page_20-21.jpg") let expectedMetadataFile = URL(fileURLWithPath: "/Users/test/Library/Caches/Chapters/Chapter_3/metadata.json") - + #expect(fileSystem.writtenData[expectedImageFile] == pageInfo.imageData) - - // Verify metadata was written + guard let metadataData = fileSystem.writtenData[expectedMetadataFile], let metadata = try? JSONSerialization.jsonObject(with: metadataData) as? [String: Any], let pages = metadata["pages"] as? [[String: Any]], @@ -161,27 +169,27 @@ final class ComicImageCacheManagerTests: TrackingMemoryLeaks { #expect(Bool(false), "Metadata not properly saved") return } - + #expect(firstPage["pageNumber"] as? Int == 20) #expect(firstPage["secondPageNumber"] as? Int == 21) #expect(firstPage["fileName"] as? String == "Page_20-21.jpg") } - - @Test("Save page image appends to existing metadata") - func savePageImageAppendsToExistingMetadata() throws { + + @Test("Appends to existing metadata") + func appendsToExistingMetadata() throws { let existingMetadata: [String: Any] = [ "pages": [ ["pageNumber": 8, "secondPageNumber": 9, "fileName": "Page_8-9.jpg"] ] ] let existingData = try JSONSerialization.data(withJSONObject: existingMetadata) - - let pageInfo = PageInfo(chapter: 3, pageNumber: 20, secondPageNumber: 21, imageData: Data("new-double-page".utf8)) + + let pageInfo = makePageInfo(chapter: 3, pageNumber: 20, secondPageNumber: 21, imageData: Data("new-double-page".utf8)) let (sut, _, fileSystem, _) = makeSUT() fileSystem.fileContents["/Users/test/Library/Caches/Chapters/Chapter_3/metadata.json"] = existingData - + try sut.savePageImage(pageInfo: pageInfo) - + let metadataFile = URL(fileURLWithPath: "/Users/test/Library/Caches/Chapters/Chapter_3/metadata.json") guard let updatedData = fileSystem.writtenData[metadataFile], let metadata = try? JSONSerialization.jsonObject(with: updatedData) as? [String: Any], @@ -189,59 +197,47 @@ final class ComicImageCacheManagerTests: TrackingMemoryLeaks { #expect(Bool(false), "Updated metadata not properly saved") return } - + #expect(pages.count == 2) - - // Verify new page was added + let newPage = pages.first { $0["pageNumber"] as? Int == 20 } #expect(newPage?["secondPageNumber"] as? Int == 21) } - - // MARK: - Error Handling Tests - - @Test("Save page image handles file system errors gracefully") - func savePageImageHandlesFileSystemErrors() { - let pageInfo = PageInfo(chapter: 1, pageNumber: 1, secondPageNumber: nil, imageData: Data("test".utf8)) +} + +// MARK: - Error Handling +extension ComicImageCacheManagerTests { + @Test("Handles file system errors gracefully") + func handlesFileSystemErrorsGracefully() { + let pageInfo = makePageInfo(chapter: 1, pageNumber: 1) let (sut, _, fileSystem, _) = makeSUT() fileSystem.shouldThrowError = true - + #expect(throws: (any Error).self) { try sut.savePageImage(pageInfo: pageInfo) } } - - @Test("Load cached image handles corrupted metadata gracefully") - func loadCachedImageHandlesCorruptedMetadata() throws { - let chapter = 1 - let page = 1 - let (sut, _, fileSystem, _) = makeSUT() - - // Setup corrupted metadata - fileSystem.fileContents["/Users/test/Library/Caches/Chapters/Chapter_1/metadata.json"] = Data("invalid json".utf8) - - let result = try sut.loadCachedImage(chapter: chapter, page: page) - - #expect(result == nil) - } - +} + +// MARK: - Comic Types +extension ComicImageCacheManagerTests { @Test("Handles different comic types correctly") - func handlesDifferentComicTypesCorrectly() async { + func handlesDifferentComicTypesCorrectly() async throws { let pageNumber = 10 let readProgress = 75 let storyType = ComicType.story let specialsType = ComicType.specials - + let (storySUT, storyStore, _, _) = makeSUT(comicType: storyType) let (specialsSUT, specialsStore, _, _) = makeSUT(comicType: specialsType) - + storySUT.updateCurrentPageNumber(pageNumber, readProgress: readProgress) specialsSUT.updateCurrentPageNumber(pageNumber, readProgress: readProgress) - - // Wait for async dispatch - try? await Task.sleep(for: .milliseconds(100)) - - #expect(storyStore.lastUpdateInfo?.comicType == storyType) - #expect(specialsStore.lastUpdateInfo?.comicType == specialsType) + + let expectedStoryUpdate = MockComicPageStore.UpdateInfo(pageNumber: pageNumber, comicType: storyType) + let expectedSpecialsUpdate = MockComicPageStore.UpdateInfo(pageNumber: pageNumber, comicType: specialsType) + try await storyStore.$lastUpdateInfo.waitUntil { $0 == expectedStoryUpdate } + try await specialsStore.$lastUpdateInfo.waitUntil { $0 == expectedSpecialsUpdate } } } @@ -263,36 +259,66 @@ private extension ComicImageCacheManagerTests { fileSystemOperations: fileSystem, coverImageDelegate: coverDelegate ) - + trackForMemoryLeaks(store, fileID: fileID, filePath: filePath, line: line, column: column) trackForMemoryLeaks(fileSystem, fileID: fileID, filePath: filePath, line: line, column: column) trackForMemoryLeaks(coverDelegate, fileID: fileID, filePath: filePath, line: line, column: column) - + return (sut, store, fileSystem, coverDelegate) } } -// MARK: - Mock Classes +// MARK: - Test Helpers +private extension ComicImageCacheManagerTests { + func makeCoverImageMetadata( + chapterName: String = "Chapter 1", + chapterNumber: Int = 1, + readProgress: Int = 25 + ) -> CoverImageMetaData { + CoverImageMetaData(chapterName: chapterName, chapterNumber: chapterNumber, readProgress: readProgress) + } + + func makePageInfo( + chapter: Int, + pageNumber: Int, + secondPageNumber: Int? = nil, + imageData: Data = Data("page-data".utf8) + ) -> PageInfo { + PageInfo(chapter: chapter, pageNumber: pageNumber, secondPageNumber: secondPageNumber, imageData: imageData) + } +} + +// MARK: - Mocks @MainActor private final class MockComicPageStore: ComicPageStore { - var lastUpdateInfo: (pageNumber: Int, comicType: ComicType)? - + @Published var lastUpdateInfo: UpdateInfo? + func updateCurrentPageNumber(_ pageNumber: Int, comicType: ComicType) { - lastUpdateInfo = (pageNumber, comicType) + lastUpdateInfo = UpdateInfo(pageNumber: pageNumber, comicType: comicType) + } + + struct UpdateInfo: Equatable, Sendable { + let pageNumber: Int + let comicType: ComicType } } @MainActor private final class MockCoverImageDelegate: CoverImageDelegate { var lastProgressUpdate: Int? - var lastSavedChapterData: (imageData: Data, metadata: CoverImageMetaData)? - + var lastSavedChapterData: SavedChapterData? + func updateProgress(to newProgress: Int) { lastProgressUpdate = newProgress } - + func saveCurrentChapterData(imageData: Data, metadata: CoverImageMetaData) { - lastSavedChapterData = (imageData, metadata) + lastSavedChapterData = SavedChapterData(imageData: imageData, metadata: metadata) + } + + struct SavedChapterData: Equatable { + let imageData: Data + let metadata: CoverImageMetaData } } diff --git a/DBMultiverseUnitTests/UnitTests/ComicPageManagerTests.swift b/DBMultiverseUnitTests/UnitTests/ComicPageManagerTests.swift index bb37a12..01a24be 100644 --- a/DBMultiverseUnitTests/UnitTests/ComicPageManagerTests.swift +++ b/DBMultiverseUnitTests/UnitTests/ComicPageManagerTests.swift @@ -12,149 +12,156 @@ import DBMultiverseComicKit @testable import DBMultiverse @MainActor -final class ComicPageManagerTests: TrackingMemoryLeaks { - +@LeakTracked +final class ComicPageManagerTests { @Test("Starting values are empty") func startingValuesAreEmpty() { - let (_, delegate) = makeSUT() - + let delegate = makeSUT().delegate + #expect(delegate.savedMetadata == nil) #expect(delegate.lastPageReadInfo == nil) #expect(delegate.readProgressInfo == nil) #expect(delegate.chapterMarkedAsRead == nil) #expect(delegate.savedPageInfoList.isEmpty) } - +} + +// MARK: - Save Cover Image +extension ComicPageManagerTests { @Test("Saves cover image with metadata containing chapter name and progress") func savesCoverImageWithMetadata() { let chapter = makeChapter(name: "Test Chapter", startPage: 1, endPage: 20) let pageInfo = PageInfo(chapter: chapter.number, pageNumber: 10, secondPageNumber: nil, imageData: Data("image".utf8)) let (sut, delegate) = makeSUT(chapter: chapter) - + sut.saveChapterCoverPage(pageInfo) - + #expect(delegate.savedMetadata?.chapterName == chapter.name) #expect(delegate.savedMetadata?.chapterNumber == chapter.number) #expect(delegate.savedMetadata?.readProgress == 50) } - +} + +// MARK: - Load Pages +extension ComicPageManagerTests { @Test("Loads cached pages and fetches missing pages from network") func loadsCachedPagesAndFetchesMissingPages() async throws { let cachedPageInfo = makePageInfo(page: 2) let chapter = makeChapter(startPage: 1, endPage: 20) let (sut, delegate) = makeSUT(chapter: chapter, cachedPages: [cachedPageInfo]) let pages = try await sut.loadPages([2, 3]) - + #expect(pages.map(\.pageNumber).sorted() == [2, 3]) #expect(delegate.savedPageInfoList.map(\.pageNumber) == [3]) #expect(!delegate.savedPageInfoList.map(\.pageNumber).contains(2)) } - + + @Test("Skips second pages when loading") + func skipsSecondPagesWhenLoading() async throws { + let chapter = makeChapter(startPage: 1, endPage: 20) + let sut = makeSUT(chapter: chapter).sut + + let pages = try await sut.loadPages([8, 9, 20, 21]) + + #expect(pages.count == 2) + #expect(pages.map(\.pageNumber).sorted() == [8, 20]) + } + + @Test("Saves fetched pages to cache") + func savesFetchedPagesToCache() async throws { + let chapter = makeChapter(startPage: 1, endPage: 20) + let (sut, delegate) = makeSUT(chapter: chapter) + + _ = try await sut.loadPages([1, 2, 3]) + + #expect(delegate.savedPageInfoList.count == 3) + #expect(delegate.savedPageInfoList.map(\.pageNumber).sorted() == [1, 2, 3]) + } +} + +// MARK: - Update Page Number +extension ComicPageManagerTests { @Test("Does not mark chapter as complete when end page is not read") func doesNotMarkChapterAsCompleteBeforeEnd() { let start = 1 let end = 5 let chapter = makeChapter(startPage: start, endPage: end) - + for page in start.. Chapter { return .init( name: name, @@ -193,33 +203,42 @@ private extension ComicPageManagerTests { didFinishReading: false ) } - + func makePageInfo(page: Int, secondPage: Int? = nil) -> PageInfo { return .init(chapter: 1, pageNumber: page, secondPageNumber: secondPage, imageData: .init()) } } - -// MARK: - Helper Classes +// MARK: - Mocks private class MockDelegate { private let throwError: Bool private var cachedPages: [PageInfo] private(set) var savedPageInfoList: [PageInfo] = [] private(set) var chapterMarkedAsRead: Chapter? - private(set) var lastPageReadInfo: (Int, Chapter)? - private(set) var readProgressInfo: (Int, Int)? + private(set) var lastPageReadInfo: LastPageReadInfo? + private(set) var readProgressInfo: ReadProgressInfo? private(set) var savedMetadata: CoverImageMetaData? - + init(throwError: Bool, cachedPages: [PageInfo]) { self.throwError = throwError self.cachedPages = cachedPages } + + struct LastPageReadInfo: Equatable { + let page: Int + let chapter: Chapter + } + + struct ReadProgressInfo: Equatable { + let pageNumber: Int + let progress: Int + } } extension MockDelegate: ComicPageNetworkService { func fetchImageData(from url: URL?) async throws -> Data { if throwError { throw NSError(domain: "Test", code: 0) } - + return .init() } } @@ -228,32 +247,32 @@ extension MockDelegate: ChapterProgressHandler { func markChapterAsRead(_ chapter: Chapter) { chapterMarkedAsRead = chapter } - + func updateLastReadPage(page: Int, chapter: Chapter) { - lastPageReadInfo = (page, chapter) + lastPageReadInfo = LastPageReadInfo(page: page, chapter: chapter) } } extension MockDelegate: ComicImageCache { func savePageImage(pageInfo: PageInfo) throws { if throwError { throw NSError(domain: "Test", code: 0) } - + savedPageInfoList.append(pageInfo) } - + func loadCachedImage(chapter: Int, page: Int) throws -> PageInfo? { if throwError { throw NSError(domain: "Test", code: 0) } - + return cachedPages.popLast() } - + func updateCurrentPageNumber(_ pageNumber: Int, readProgress: Int) { - readProgressInfo = (pageNumber, readProgress) + readProgressInfo = ReadProgressInfo(pageNumber: pageNumber, progress: readProgress) } - + func saveChapterCoverImage(imageData: Data, metadata: CoverImageMetaData) throws { if throwError { throw NSError(domain: "Test", code: 0) } - + savedMetadata = metadata } -} \ No newline at end of file +} diff --git a/DBMultiverseUnitTests/UnitTests/MainFeaturesViewModelTests.swift b/DBMultiverseUnitTests/UnitTests/MainFeaturesViewModelTests.swift index 81f586e..df23d6c 100644 --- a/DBMultiverseUnitTests/UnitTests/MainFeaturesViewModelTests.swift +++ b/DBMultiverseUnitTests/UnitTests/MainFeaturesViewModelTests.swift @@ -12,143 +12,149 @@ import DBMultiverseComicKit @testable import DBMultiverse @MainActor -final class MainFeaturesViewModelTests: TrackingMemoryLeaks { +@LeakTracked +final class MainFeaturesViewModelTests { @Test("Starting values are empty") func startingValuesAreEmpty() { let (sut, loader) = makeSUT() - + #expect(loader.urlPath == nil) #expect(sut.chapters.isEmpty) #expect(sut.nextChapterToRead == nil) #expect(sut.lastReadMainStoryPage == 0) #expect(sut.lastReadSpecialPage != sut.lastReadMainStoryPage) } - +} + +// MARK: - Load Data +extension MainFeaturesViewModelTests { @Test("URL contains correct language parameter for all languages") func urlContainsCorrectLanguageParameter() async throws { for language in ComicLanguage.allCases { let (sut, loader) = makeSUT() - + try await sut.loadData(language: language) - + guard let urlPath = loader.urlPath else { #expect(Bool(false), "URL path should be set after loading data") continue } - + #expect(urlPath.contains(language.rawValue)) } } - + @Test("Loads and sets chapters correctly") func loadsAndSetsChaptersCorrectly() async throws { let chaptersToLoad = [makeChapter(name: "Chapter 1"), makeChapter(name: "Chapter 2")] let sut = makeSUT(chaptersToLoad: chaptersToLoad).sut - + try await sut.loadData(language: .english) - + #expect(sut.chapters.count == chaptersToLoad.count) #expect(sut.chapters.map(\.name).sorted() == chaptersToLoad.map(\.name).sorted()) } - + + @Test("Chapters remain consistent after multiple load operations") + func chaptersRemainConsistentAfterMultipleLoads() async throws { + let initialChapters = [makeChapter(name: "Chapter 1"), makeChapter(name: "Chapter 2")] + let sut = makeSUT(chaptersToLoad: initialChapters).sut + + try await sut.loadData(language: .english) + let firstLoadCount = sut.chapters.count + + try await sut.loadData(language: .french) + let secondLoadCount = sut.chapters.count + + #expect(firstLoadCount == secondLoadCount) + #expect(sut.chapters.count == initialChapters.count) + } +} + +// MARK: - Current Page Number +extension MainFeaturesViewModelTests { @Test("Updates current page number based on comic type") func updatesCurrentPageNumberBasedOnComicType() { - let (sut, _) = makeSUT() - + let sut = makeSUT().sut + sut.updateCurrentPageNumber(42, comicType: .story) #expect(sut.lastReadMainStoryPage == 42) #expect(sut.lastReadSpecialPage != 42) - + sut.updateCurrentPageNumber(99, comicType: .specials) #expect(sut.lastReadSpecialPage == 99) #expect(sut.lastReadMainStoryPage != 99) } - + @Test("Returns current page number based on comic type") func returnsCurrentPageNumberBasedOnComicType() { - let (sut, _) = makeSUT() - + let sut = makeSUT().sut + sut.updateCurrentPageNumber(21, comicType: .story) #expect(sut.getCurrentPageNumber(for: .story) == 21) #expect(sut.getCurrentPageNumber(for: .specials) == sut.lastReadSpecialPage) - + sut.updateCurrentPageNumber(84, comicType: .specials) #expect(sut.getCurrentPageNumber(for: .specials) == 84) #expect(sut.getCurrentPageNumber(for: .story) == 21) } - - @Test("Sets next chapter to read") - func setsNextChapterToRead() { - let (sut, _) = makeSUT() - let chapter = makeChapter(name: "Next Chapter") - - sut.startNextChapter(chapter) - - #expect(sut.nextChapterToRead == chapter) - } - - @Test("Handles loading errors gracefully") - func handlesLoadingErrorsGracefully() async { - let (sut, _) = makeSUT(throwError: true) - - await #expect(throws: (any Error).self) { - try await sut.loadData(language: .english) - } - - #expect(sut.chapters.isEmpty) - } - + @Test("Preserves page numbers across different comic types") func preservesPageNumbersAcrossDifferentComicTypes() { - let (sut, _) = makeSUT() - - // Set different page numbers for each comic type + let sut = makeSUT().sut + sut.updateCurrentPageNumber(10, comicType: .story) sut.updateCurrentPageNumber(25, comicType: .specials) - - // Verify both are preserved independently + #expect(sut.getCurrentPageNumber(for: .story) == 10) #expect(sut.getCurrentPageNumber(for: .specials) == 25) - - // Update one type and verify the other remains unchanged + sut.updateCurrentPageNumber(15, comicType: .story) #expect(sut.getCurrentPageNumber(for: .story) == 15) #expect(sut.getCurrentPageNumber(for: .specials) == 25) } - - @Test("Chapters remain consistent after multiple load operations") - func chaptersRemainConsistentAfterMultipleLoads() async throws { - let initialChapters = [makeChapter(name: "Chapter 1"), makeChapter(name: "Chapter 2")] - let (sut, _) = makeSUT(chaptersToLoad: initialChapters) - - // Load data multiple times - try await sut.loadData(language: .english) - let firstLoadCount = sut.chapters.count - - try await sut.loadData(language: .french) - let secondLoadCount = sut.chapters.count - - #expect(firstLoadCount == secondLoadCount) - #expect(sut.chapters.count == initialChapters.count) +} + +// MARK: - Next Chapter +extension MainFeaturesViewModelTests { + @Test("Sets next chapter to read") + func setsNextChapterToRead() { + let sut = makeSUT().sut + let chapter = makeChapter(name: "Next Chapter") + + sut.startNextChapter(chapter) + + #expect(sut.nextChapterToRead == chapter) } - + @Test("Next chapter to read resets properly") func nextChapterToReadResetsProperly() { - let (sut, _) = makeSUT() + let sut = makeSUT().sut let firstChapter = makeChapter(name: "First Chapter") let secondChapter = makeChapter(name: "Second Chapter") - - // Set first chapter + sut.startNextChapter(firstChapter) #expect(sut.nextChapterToRead == firstChapter) - - // Set second chapter (should replace first) + sut.startNextChapter(secondChapter) #expect(sut.nextChapterToRead == secondChapter) #expect(sut.nextChapterToRead != firstChapter) } } +// MARK: - Error Handling +extension MainFeaturesViewModelTests { + @Test("Handles loading errors gracefully") + func handlesLoadingErrorsGracefully() async { + let sut = makeSUT(throwError: true).sut + + await #expect(throws: (any Error).self) { + try await sut.loadData(language: .english) + } + + #expect(sut.chapters.isEmpty) + } +} // MARK: - SUT private extension MainFeaturesViewModelTests { @@ -163,19 +169,22 @@ private extension MainFeaturesViewModelTests { let defaults = makeTestDefaults() let loader = MockLoader(throwError: throwError, chaptersToLoad: chaptersToLoad) let sut = MainFeaturesViewModel(loader: loader, userDefaults: defaults) - + trackForMemoryLeaks(sut, fileID: fileID, filePath: filePath, line: line, column: column) trackForMemoryLeaks(loader, fileID: fileID, filePath: filePath, line: line, column: column) - + return (sut, loader) } - +} + +// MARK: - Test Helpers +private extension MainFeaturesViewModelTests { func makeTestDefaults(name: String = "testSuite") -> UserDefaults? { let defaults = UserDefaults(suiteName: name) defaults?.removePersistentDomain(forName: name) return defaults } - + func makeChapter(name: String = "Test Chapter", number: Int = 1, startPage: Int = 1, endPage: Int = 20) -> Chapter { return .init( name: name, @@ -190,24 +199,23 @@ private extension MainFeaturesViewModelTests { } } - -// MARK: - Helper Classes +// MARK: - Mocks private extension MainFeaturesViewModelTests { final class MockLoader: ChapterLoader, @unchecked Sendable { private let throwError: Bool private let chaptersToLoad: [Chapter] private(set) var urlPath: String? - + init(throwError: Bool, chaptersToLoad: [Chapter]) { self.throwError = throwError self.chaptersToLoad = chaptersToLoad } - + func loadChapters(url: URL?) async throws -> [Chapter] { if throwError { throw NSError(domain: "Test", code: 0) } - + urlPath = url?.path - + return chaptersToLoad } } diff --git a/DBMultiverseUnitTests/UnitTests/SettingsViewModelTests.swift b/DBMultiverseUnitTests/UnitTests/SettingsViewModelTests.swift index 4bcb67d..f920ae5 100644 --- a/DBMultiverseUnitTests/UnitTests/SettingsViewModelTests.swift +++ b/DBMultiverseUnitTests/UnitTests/SettingsViewModelTests.swift @@ -12,42 +12,49 @@ import DBMultiverseComicKit @testable import DBMultiverse @MainActor -final class SettingsViewModelTests: TrackingMemoryLeaks { +@LeakTracked +final class SettingsViewModelTests { @Test("Initial state has default values") func initialStateHasDefaultValues() { - let (sut, _) = makeSUT() - + let sut = makeSUT().sut + #expect(sut.route == nil) #expect(sut.showingErrorAlert == false) #expect(sut.showingClearedCacheAlert == false) #expect(sut.cachedChapters.isEmpty) } - +} + +// MARK: - Show View +extension SettingsViewModelTests { @Test("Show view updates route correctly") func showViewUpdatesRouteCorrectly() { - let (sut, _) = makeSUT() - + let sut = makeSUT().sut + sut.showView(.cacheList) #expect(sut.route == .cacheList) - + sut.showView(.languageSelection) #expect(sut.route == .languageSelection) - + sut.showView(.disclaimer) #expect(sut.route == .disclaimer) } - +} + +// MARK: - Make URL +extension SettingsViewModelTests { @Test("Make URL returns correct URL for all link items") func makeURLReturnsCorrectURLForAllLinkItems() { - let (sut, _) = makeSUT() + let sut = makeSUT().sut let language = ComicLanguage.english - + for linkItem in SettingsLinkItem.allCases { let url = sut.makeURL(for: linkItem, language: language) - + #expect(url != nil) #expect(url?.absoluteString.contains(language.rawValue) == true) - + switch linkItem { case .authors: #expect(url?.absoluteString.contains("the-authors.html") == true) @@ -58,7 +65,10 @@ final class SettingsViewModelTests: TrackingMemoryLeaks { } } } - +} + +// MARK: - Clear Cache +extension SettingsViewModelTests { @Test("Clear cache removes all files successfully") func clearCacheRemovesAllFilesSuccessfully() { let mockFiles = [ @@ -67,32 +77,49 @@ final class SettingsViewModelTests: TrackingMemoryLeaks { URL(fileURLWithPath: "/cache/file3.jpg") ] let (sut, mockFileManager) = makeSUT(cacheContents: mockFiles) - + sut.cachedChapters = [ CachedChapter(number: "1", imageCount: 10), CachedChapter(number: "2", imageCount: 15) ] - + sut.clearCache() - + #expect(mockFileManager.removeItemCallCount == mockFiles.count) #expect(mockFileManager.removedURLs == mockFiles) #expect(sut.cachedChapters.isEmpty) #expect(sut.showingClearedCacheAlert == true) #expect(sut.showingErrorAlert == false) } - + @Test("Clear cache shows error alert on failure") func clearCacheShowsErrorAlertOnFailure() { let (sut, mockFileManager) = makeSUT(shouldThrowError: true) - + sut.clearCache() - + #expect(sut.showingErrorAlert == true) #expect(sut.showingClearedCacheAlert == false) #expect(mockFileManager.removeItemCallCount == 0) } - + + @Test("Clear cache preserves cached chapters on error") + func clearCachePreservesCachedChaptersOnError() { + let sut = makeSUT(shouldThrowError: true).sut + let initialChapters = [ + CachedChapter(number: "1", imageCount: 10), + CachedChapter(number: "2", imageCount: 15) + ] + sut.cachedChapters = initialChapters + + sut.clearCache() + + #expect(sut.cachedChapters == initialChapters) + } +} + +// MARK: - Load Cached Chapters +extension SettingsViewModelTests { @Test("Load cached chapters reads chapter folders correctly") func loadCachedChaptersReadsChapterFoldersCorrectly() { let chapter1Images = [ @@ -105,63 +132,48 @@ final class SettingsViewModelTests: TrackingMemoryLeaks { URL(fileURLWithPath: "/cache/Chapters/Chapter_2/page2.jpg"), URL(fileURLWithPath: "/cache/Chapters/Chapter_2/page3.jpg") ] - + let chapterFolders = [ URL(fileURLWithPath: "/cache/Chapters/Chapter_1"), URL(fileURLWithPath: "/cache/Chapters/Chapter_2") ] - + let (sut, mockFileManager) = makeSUT() mockFileManager.setupChapterData(folders: chapterFolders, folderContents: [ "/cache/Chapters/Chapter_1": chapter1Images, "/cache/Chapters/Chapter_2": chapter2Images ]) - + sut.loadCachedChapters() - + #expect(sut.cachedChapters.count == 2) #expect(sut.cachedChapters[0].number == "1") #expect(sut.cachedChapters[0].imageCount == 2) #expect(sut.cachedChapters[1].number == "2") #expect(sut.cachedChapters[1].imageCount == 3) } - + @Test("Load cached chapters handles empty cache directory") func loadCachedChaptersHandlesEmptyCacheDirectory() { let (sut, mockFileManager) = makeSUT() mockFileManager.setupChapterData(folders: [], folderContents: [:]) - + sut.loadCachedChapters() - + #expect(sut.cachedChapters.isEmpty) } - + @Test("Load cached chapters handles error gracefully") func loadCachedChaptersHandlesErrorGracefully() { let (sut, mockFileManager) = makeSUT() mockFileManager.shouldThrowOnContentsOfDirectory = true - + sut.loadCachedChapters() - + #expect(sut.cachedChapters.isEmpty) } - - @Test("Clear cache preserves cached chapters on error") - func clearCachePreservesCachedChaptersOnError() { - let sut = makeSUT(shouldThrowError: true).sut - let initialChapters = [ - CachedChapter(number: "1", imageCount: 10), - CachedChapter(number: "2", imageCount: 15) - ] - sut.cachedChapters = initialChapters - - sut.clearCache() - - #expect(sut.cachedChapters == initialChapters) - } } - // MARK: - SUT private extension SettingsViewModelTests { func makeSUT( @@ -177,69 +189,70 @@ private extension SettingsViewModelTests { cacheContents: cacheContents ) let sut = SettingsViewModel(fileManager: mockFileManager) - + trackForMemoryLeaks(sut, fileID: fileID, filePath: filePath, line: line, column: column) trackForMemoryLeaks(mockFileManager, fileID: fileID, filePath: filePath, line: line, column: column) - + return (sut, mockFileManager) } } - -// MARK: - Mock File Manager -private final class MockFileManager: FileManaging, @unchecked Sendable { - private let shouldThrowError: Bool - private let cacheContents: [URL] - private var chapterFolders: [URL] = [] - private var folderContents: [String: [URL]] = [:] - - var shouldThrowOnContentsOfDirectory = false - private(set) var removeItemCallCount = 0 - private(set) var removedURLs: [URL] = [] - - init(shouldThrowError: Bool = false, cacheContents: [URL] = []) { - self.shouldThrowError = shouldThrowError - self.cacheContents = cacheContents - } - - func setupChapterData(folders: [URL], folderContents: [String: [URL]]) { - self.chapterFolders = folders - self.folderContents = folderContents - } - - func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { - return [URL(fileURLWithPath: "/cache")] - } - - func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?) throws -> [URL] { - if shouldThrowError || shouldThrowOnContentsOfDirectory { - throw MockError.testError +// MARK: - Mocks +private extension SettingsViewModelTests { + final class MockFileManager: FileManaging, @unchecked Sendable { + private let shouldThrowError: Bool + private let cacheContents: [URL] + private var chapterFolders: [URL] = [] + private var folderContents: [String: [URL]] = [:] + + var shouldThrowOnContentsOfDirectory = false + private(set) var removeItemCallCount = 0 + private(set) var removedURLs: [URL] = [] + + init(shouldThrowError: Bool = false, cacheContents: [URL] = []) { + self.shouldThrowError = shouldThrowError + self.cacheContents = cacheContents } - if url.path == "/cache" { - return cacheContents + func setupChapterData(folders: [URL], folderContents: [String: [URL]]) { + self.chapterFolders = folders + self.folderContents = folderContents } - if url.path == "/cache/Chapters" { - return chapterFolders + func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { + return [URL(fileURLWithPath: "/cache")] } - if let contents = folderContents[url.path] { - return contents + func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?) throws -> [URL] { + if shouldThrowError || shouldThrowOnContentsOfDirectory { + throw MockError.testError + } + + if url.path == "/cache" { + return cacheContents + } + + if url.path == "/cache/Chapters" { + return chapterFolders + } + + if let contents = folderContents[url.path] { + return contents + } + + return [] } - return [] + func removeItem(at URL: URL) throws { + if shouldThrowError { + throw MockError.testError + } + removeItemCallCount += 1 + removedURLs.append(URL) + } } - func removeItem(at URL: URL) throws { - if shouldThrowError { - throw MockError.testError - } - removeItemCallCount += 1 - removedURLs.append(URL) + enum MockError: Error { + case testError } } - -private enum MockError: Error { - case testError -}