diff --git a/Package.resolved b/Package.resolved index f367b8c..beba15e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa", "state" : { - "revision" : "d2ced2d961b34573ebd2ea0567a2f1408e90f0ae", - "version" : "8.34.0" + "revision" : "54cc2e3e4fcbf9d4c7708ce00d3b6eee29aecbb1", + "version" : "8.38.0" } }, { diff --git a/Sources/InfomaniakCore/Chunking/ChunkProvider.swift b/Sources/InfomaniakCore/Chunking/ChunkProvider.swift new file mode 100644 index 0000000..f9972ed --- /dev/null +++ b/Sources/InfomaniakCore/Chunking/ChunkProvider.swift @@ -0,0 +1,115 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2021 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation + +/// Something that builds chunks and provide them with an iterator. +public protocol ChunkProvidable: IteratorProtocol { + init?(fileURL: URL, ranges: [DataRange]) +} + +/// Something that can chunk a file part by part, in memory, given specified ranges. +/// +/// Memory considerations: Max memory use ≈sizeOf(one chunk). So from 1Mb to 50Mb +/// Thread safety: Not thread safe +/// +@available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) +public final class ChunkProvider: ChunkProvidable { + public typealias Element = Data + + let fileHandle: FileHandlable + + var ranges: [DataRange] + + deinit { + do { + // For the sake of consistency + try fileHandle.close() + } catch {} + } + + public init?(fileURL: URL, ranges: [DataRange]) { + self.ranges = ranges + + do { + fileHandle = try FileHandle(forReadingFrom: fileURL) + } catch { + return nil + } + } + + /// Internal testing method + init(mockedHandlable: FileHandlable, ranges: [DataRange]) { + self.ranges = ranges + fileHandle = mockedHandlable + } + + /// Will provide chunks one by one, using the IteratorProtocol + /// Starting by the first range available. + public func next() -> Data? { + guard !ranges.isEmpty else { + return nil + } + + let range = ranges.removeFirst() + + do { + let chunk = try readChunk(range: range) + return chunk + } catch { + return nil + } + } + + // MARK: Internal + + func readChunk(range: DataRange) throws -> Data? { + let offset = range.lowerBound + try fileHandle.seek(toOffset: offset) + + let byteCount = Int(range.upperBound - range.lowerBound) + 1 + let chunk = try fileHandle.read(upToCount: byteCount) + return chunk + } +} + +/// Print the FileHandle shows the current offset +extension FileHandle { + override open var description: String { + if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) { + let superDescription = super.description + + let offsetString: String + do { + let offset = try offset() + offsetString = "\(offset)" + } catch { + offsetString = "\(error)" + } + + let buffer = """ + <\(superDescription)> + + """ + + return buffer + } else { + return super.description + } + } +} diff --git a/Sources/InfomaniakCore/Chunking/FileHandlable.swift b/Sources/InfomaniakCore/Chunking/FileHandlable.swift new file mode 100644 index 0000000..64b4d27 --- /dev/null +++ b/Sources/InfomaniakCore/Chunking/FileHandlable.swift @@ -0,0 +1,46 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation + +/// Something that matches most of the FileHandle specification, used for testing +@available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) +protocol FileHandlable { + var availableData: Data { get } + + var description: String { get } + + func seek(toOffset offset: UInt64) throws + + func truncate(atOffset offset: UInt64) throws + + func synchronize() throws + + func close() throws + + func readToEnd() throws -> Data? + + func read(upToCount count: Int) throws -> Data? + + func offset() throws -> UInt64 + + func seekToEnd() throws -> UInt64 +} + +/// Protocol conformance +extension FileHandle: FileHandlable {} diff --git a/Sources/InfomaniakCore/Chunking/RangeProvider.swift b/Sources/InfomaniakCore/Chunking/RangeProvider.swift new file mode 100644 index 0000000..38de8ec --- /dev/null +++ b/Sources/InfomaniakCore/Chunking/RangeProvider.swift @@ -0,0 +1,110 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2021 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation + +/// A range of Bytes on a `Data` buffer +/// - start: start byte index, first index at 0 by convention. +/// - end: end byte index, last index at fileSize -1 by convention. +public typealias DataRange = ClosedRange + +/// Something that can provide a sequence of ranges where the file should be split if necessary. +public protocol RangeProvidable { + /// Computes and return all the contiguous ranges for a file at the moment of calling. + /// + /// Result may change over time if file is modified in between calls. + /// Throws if file too large or too small, also if file system issue. + /// Minimum size support is one byte (low bound == high bound) + var allRanges: [DataRange] { get throws } + + /// Return the file size in bytes at the moment of calling. + var fileSize: UInt64 { get throws } +} + +public struct RangeProvider: RangeProvidable { + /// Encapsulating API parameters used to compute ranges + public enum APIConstants { + static let chunkMinSize: UInt64 = 1 * 1024 * 1024 + static let chunkMaxSizeClient: UInt64 = 50 * 1024 * 1024 + static let chunkMaxSizeServer: UInt64 = 1 * 1024 * 1024 * 1024 + static let optimalChunkCount: UInt64 = 200 + static let maxTotalChunks: UInt64 = 10000 + static let minTotalChunks: UInt64 = 1 + + /// the limit supported by the app + static let fileMaxSizeClient = APIConstants.maxTotalChunks * APIConstants.chunkMaxSizeClient + + /// the limit supported by the server + static let fileMaxSizeServer = APIConstants.maxTotalChunks * APIConstants.chunkMaxSizeServer + } + + enum ErrorDomain: Error { + /// Unable to read file system metadata + case UnableToReadFileAttributes + + /// file is over the supported size + case FileTooLarge + + /// We ask for chunks that do not make sense + case ChunkedSizeLargerThanSourceFile + + /// At least one chunk is expected + case IncorrectTotalChunksCount + + /// A non zero size is expected + case IncorrectChunkSize + } + + /// The internal methods split into another type, make testing easier + var guts: RangeProviderGutsable + + public init(fileURL: URL) { + guts = RangeProviderGuts(fileURL: fileURL) + } + + public var fileSize: UInt64 { + get throws { + let size = try guts.readFileByteSize() + return size + } + } + + public var allRanges: [DataRange] { + get throws { + let size = try fileSize + + // Check for files too large to be processed by mobile app or the server + guard size < APIConstants.fileMaxSizeClient, + size < APIConstants.fileMaxSizeServer else { + // TODO: notify Sentry + throw ErrorDomain.FileTooLarge + } + + let preferredChunkSize = guts.preferredChunkSize(for: size) + + // Make sure an empty file resolves to one chunk + let totalChunksCount = max(size / max(preferredChunkSize, 1), 1) + + let ranges = try guts.buildRanges(fileSize: size, + totalChunksCount: totalChunksCount, + chunkSize: preferredChunkSize) + + return ranges + } + } +} diff --git a/Sources/InfomaniakCore/Chunking/RangeProviderGuts.swift b/Sources/InfomaniakCore/Chunking/RangeProviderGuts.swift new file mode 100644 index 0000000..0406fcd --- /dev/null +++ b/Sources/InfomaniakCore/Chunking/RangeProviderGuts.swift @@ -0,0 +1,135 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakDI + +/// The internal methods of RangeProviderGuts, made testable +public protocol RangeProviderGutsable { + /// Build ranges for a file + /// + /// Empty file will return zero chunk. + /// the range `0...0` represents the first Byte of a file + /// + /// - Parameters: + /// - fileSize: the total file size, in **Bytes** + /// - totalChunksCount: the total number of chunks that should be used + /// - chunkSize: the size of a chunk that should be used + /// - Returns: a collection of contiguous (so ordered) ranges. + /// - Throws: if some preconditions are not met + func buildRanges(fileSize: UInt64, totalChunksCount: UInt64, chunkSize: UInt64) throws -> [DataRange] + + /// Get the size of a file, in **Bytes** + /// - Returns: the file size at the moment of execution + func readFileByteSize() throws -> UInt64 + + /// Mimmic the Android logic and returns what is preferred by the API for a specific file size + /// - Parameter fileSize: the input file size, in **Bytes** + /// - Returns: The _preferred_ size of one chunk + func preferredChunkSize(for fileSize: UInt64) -> UInt64 +} + +/// Subdivided **RangeProvider**, so it is easier to test +public struct RangeProviderGuts: RangeProviderGutsable { + /// The URL of the local file to scan + public let fileURL: URL + + public func buildRanges(fileSize: UInt64, totalChunksCount: UInt64, chunkSize: UInt64) throws -> [DataRange] { + // malformed requests + guard totalChunksCount > 0 else { + throw RangeProvider.ErrorDomain.IncorrectTotalChunksCount + } + guard chunkSize > 0 else { + throw RangeProvider.ErrorDomain.IncorrectChunkSize + } + + // Empty files + guard fileSize > 0 else { + // An empty file is supported but has no range, represented by an empty collection. + return [] + } + + // sanity file size check + let totalChunckedSize = totalChunksCount * chunkSize + guard totalChunckedSize <= fileSize else { + throw RangeProvider.ErrorDomain.ChunkedSizeLargerThanSourceFile + } + + // The high bound for a 0 indexed list of bytes + let chunkBound = chunkSize - 1 + + var ranges: [DataRange] = [] + for index in 0 ... totalChunksCount - 1 { + let startOffset = index * chunkBound + index + let endOffset = startOffset + chunkBound + let range: DataRange = startOffset ... endOffset + + ranges.append(range) + } + + // Add the remainder in a last chuck + let lastChunkSize = fileSize - totalChunckedSize + if lastChunkSize != 0 { + let startOffset = totalChunksCount * chunkSize + assert((startOffset + lastChunkSize) == fileSize, "sanity, this should match") + + let endOfFileoffset = fileSize - 1 + let range: DataRange = startOffset ... endOfFileoffset + + ranges.append(range) + } + + return ranges + } + + public func readFileByteSize() throws -> UInt64 { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + guard let fileSize = fileAttributes[.size] as? UInt64 else { + throw RangeProvider.ErrorDomain.UnableToReadFileAttributes + } + + return fileSize + } + + public func preferredChunkSize(for fileSize: UInt64) -> UInt64 { + // In extension to reduce memory footprint, we reduce drastically chunk size + guard !Bundle.main.isExtension else { + let capChunkSize = min(fileSize, RangeProvider.APIConstants.chunkMinSize) + return capChunkSize + } + + let potentialChunkSize = fileSize / RangeProvider.APIConstants.optimalChunkCount + + let chunkSize: UInt64 + switch potentialChunkSize { + case 0 ..< RangeProvider.APIConstants.chunkMinSize: + chunkSize = RangeProvider.APIConstants.chunkMinSize + + case RangeProvider.APIConstants.chunkMinSize ... RangeProvider.APIConstants.chunkMaxSizeClient: + chunkSize = potentialChunkSize + + /// Strictly higher than `APIConstants.chunkMaxSize` + default: + chunkSize = RangeProvider.APIConstants.chunkMaxSizeClient + } + + /// Set a lower bound to chunk size + let capChunkSize = min(fileSize, chunkSize) + return capChunkSize + } +} diff --git a/Tests/InfomaniakCoreTests/Chunking/ITChunkProvider.swift b/Tests/InfomaniakCoreTests/Chunking/ITChunkProvider.swift new file mode 100644 index 0000000..23ac633 --- /dev/null +++ b/Tests/InfomaniakCoreTests/Chunking/ITChunkProvider.swift @@ -0,0 +1,79 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCore +import XCTest + +/// Integration Tests of the ChunkProvider +@available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) +final class ITChunkProvider: XCTestCase { + /// Image from wikimedia under CC. + static let file = "Matterhorn_as_seen_from_Zermatt,_Wallis,_Switzerland,_2012_August,Wikimedia_Commons" + + func testAllRanges_image() throws { + // GIVEN + let expectedParts = 5 + let bundle = Bundle.module + guard let pathURL = bundle.url(forResource: Self.file, withExtension: "jpg") else { + XCTFail("unexpected") + return + } + + do { + let expectedData = try Data(contentsOf: pathURL) + let rangeProvider = RangeProvider(fileURL: pathURL) + let ranges = try rangeProvider.allRanges + guard let chunkProvider = ChunkProvider(fileURL: pathURL, ranges: ranges) else { + XCTFail("Unexpected") + return + } + + // WHEN + var chunks: [Data] = [] + while let chunk = chunkProvider.next() { + chunks.append(chunk) + } + + // THEN + XCTAssertEqual(chunks.count, expectedParts) + + // ZIP data and ranges, check consistency + let zip = zip(ranges, chunks) + for tuple in zip { + let range = tuple.0 + let data = tuple.1 + print(range) + print(data) + let byteCounts = range.upperBound - range.lowerBound + 1 + XCTAssertEqual(byteCounts, UInt64(data.count)) + } + + // Merge chunks and check file matches the original + let magic = chunks.reduce(Data()) { partialResult, chunk in + var partialResult = partialResult + partialResult.append(chunk) + return partialResult + } + + XCTAssertEqual(magic, expectedData, "files are not matching, something is corrupted") + + } catch { + XCTFail("Unexpected \(error)") + } + } +} diff --git a/Tests/InfomaniakCoreTests/Chunking/ITRangeProvider.swift b/Tests/InfomaniakCoreTests/Chunking/ITRangeProvider.swift new file mode 100644 index 0000000..2f12bcc --- /dev/null +++ b/Tests/InfomaniakCoreTests/Chunking/ITRangeProvider.swift @@ -0,0 +1,61 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCore +import XCTest + +/// Integration Tests of the RangeProvider +final class ITRangeProvider: XCTestCase { + /// Image from wikimedia under CC. + static let file = "Matterhorn_as_seen_from_Zermatt,_Wallis,_Switzerland,_2012_August,Wikimedia_Commons" + + // MARK: - allRanges + + /// Here I test that I can chunk a file and re-glue it together. + func testAllRanges_image() throws { + // GIVEN + let bundle = Bundle.module + guard let path = bundle.path(forResource: Self.file, ofType: "jpg"), + let imageData = NSData(contentsOfFile: path) else { + XCTFail("unexpected") + return + } + + guard let pathURL = bundle.url(forResource: Self.file, withExtension: "jpg") else { + XCTFail("unexpected") + return + } + + let rangeProvider = RangeProvider(fileURL: pathURL) + + // WHEN + do { + let ranges = try rangeProvider.allRanges + + // THEN + XCTAssertNotNil(ranges) + try UTRangeProviderGuts.checkContinuity(ranges: ranges) + } catch { + XCTFail("Unexpected \(error)") + } + + // THEN + XCTAssertNotNil(rangeProvider) + XCTAssertNotNil(imageData) + } +} diff --git a/Tests/InfomaniakCoreTests/Chunking/ITRangeProviderGuts.swift b/Tests/InfomaniakCoreTests/Chunking/ITRangeProviderGuts.swift new file mode 100644 index 0000000..39ada2f --- /dev/null +++ b/Tests/InfomaniakCoreTests/Chunking/ITRangeProviderGuts.swift @@ -0,0 +1,168 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +@testable import InfomaniakCore +import XCTest + +/// Integration Tests of the RangeProviderGuts +final class ITRangeProviderGuts: XCTestCase { + // MARK: - readFileByteSize + + let file = "Matterhorn_as_seen_from_Zermatt,_Wallis,_Switzerland,_2012_August,Wikimedia_Commons" + + func testReadFileByteSize() { + // GIVEN + let expectedFileBytes = UInt64(4_865_229) + let bundle = Bundle.module + let pathURL = bundle.url(forResource: file, withExtension: "jpg")! + let guts = RangeProviderGuts(fileURL: pathURL) + + // WHEN + do { + let size = try guts.readFileByteSize() + + // THEN + XCTAssertEqual(size, expectedFileBytes) + } catch { + XCTFail("Unexpected \(error)") + } + } + + func testReadFileByteSize_FileDoesNotExists() { + // GIVEN + let notThereFileURL = URL(string: "file:///Arcalod_2117.jpg")! + let rangeProvider = RangeProviderGuts(fileURL: notThereFileURL) + + // WHEN + do { + _ = try rangeProvider.readFileByteSize() + + // THEN + XCTFail("Unexpected") + } catch { + // THEN + // "No such file or directory" + XCTAssertEqual((error as NSError).domain, "NSCocoaErrorDomain") + XCTAssertEqual((error as NSError).code, 260) + } + } + + // MARK: - buildRanges(fileSize: totalChunksCount: chunkSize:) + + func testBuildRanges_fromImage() { + // GIVEN + let bundle = Bundle.module + let pathURL = bundle.url(forResource: file, withExtension: "jpg")! + let guts = RangeProviderGuts(fileURL: pathURL) + let fileChunks = UInt64(4) + let expectedChunks = 5 + let chunksSize = UInt64(1 * 1024 * 1024) + + // WHEN + do { + let size = try guts.readFileByteSize() + let chunks = try guts.buildRanges(fileSize: size, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + try UTRangeProviderGuts.checkContinuity(ranges: chunks) + XCTAssertEqual(chunks.count, expectedChunks) + + // Check that last range is the end of the file + let lastChunk = chunks[expectedChunks - 1] + // The last offset is size -1 + let endOfFileOffset = size - 1 + guard lastChunk.upperBound == endOfFileOffset else { + XCTFail("EOF not reached") + return + } + + } catch { + XCTFail("Unexpected \(error)") + } + } + + func testBuildRanges_fromEmptyFile() { + // GIVEN + let guts = RangeProviderGuts(fileURL: URL(string: "http://infomaniak.ch")!) + let emptyFileSize = UInt64(0) + let fileChunks = UInt64(1) + let chunksSize = UInt64(1) + let expectedChunks = 0 + + // WHEN + do { + let chunks = try guts.buildRanges(fileSize: emptyFileSize, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTAssertEqual(chunks.count, expectedChunks) + try UTRangeProviderGuts.checkContinuity(ranges: chunks) + } catch { + XCTFail("Unexpected \(error)") + } + } + + // MARK: - preferredChunkSize(for fileSize:) + + func testPreferredChunkSize_fromImage() { + // GIVEN + let bundle = Bundle.module + let pathURL = bundle.url(forResource: file, withExtension: "jpg")! + let guts = RangeProviderGuts(fileURL: pathURL) + + // WHEN + do { + let size = try guts.readFileByteSize() + let preferredChunkSize = guts.preferredChunkSize(for: size) + + // THEN + XCTAssertTrue(preferredChunkSize > 0) + XCTAssertTrue(preferredChunkSize <= size) + + // this should not be strictly imposed but I can quickly check behaviour here + // XCTAssertTrue(preferredChunkSize >= RangeProvider.APIConstants.chunkMinSize) + // XCTAssertTrue(preferredChunkSize <= RangeProvider.APIConstants.chunkMaxSize) + } catch { + XCTFail("Unexpected \(error)") + } + } + + func testPreferredChunkSize_0() { + // GIVEN + let guts = RangeProviderGuts(fileURL: URL(string: "http://infomaniak.ch")!) + + // WHEN + let preferredChunkSize = guts.preferredChunkSize(for: 0) + + // THEN + XCTAssertTrue(preferredChunkSize == 0) + } + + func testPreferredChunkSize_notLargerThanFileSize() { + // GIVEN + let guts = RangeProviderGuts(fileURL: URL(string: "http://infomaniak.ch")!) + let superSmallFileSize: UInt64 = 10 + + // WHEN + let preferredChunkSize = guts.preferredChunkSize(for: superSmallFileSize) + + // THEN + XCTAssertEqual(preferredChunkSize, + superSmallFileSize, + "we expect the chunk size to be capped at the file size for small files") + } +} diff --git a/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKChunkProvidable.swift b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKChunkProvidable.swift new file mode 100644 index 0000000..31a8950 --- /dev/null +++ b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKChunkProvidable.swift @@ -0,0 +1,47 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakCore + +/// A public mock for the MCKChunkProvidable module +public final class MCKChunkProvidable: ChunkProvidable { + var fileURL: URL + var ranges: [DataRange] + public init?(fileURL: URL, ranges: [DataRange]) { + self.fileURL = fileURL + self.ranges = ranges + } + + // MARK: - IteratorProtocol + + public typealias Element = Data + + var nextCalled: Bool { nextCallCount > 0 } + var nextCallCount = 0 + var nextClosure: (() -> Data?)? + public func next() -> Data? { + nextCallCount += 1 + if let nextClosure { + let data = nextClosure() + return data + } else { + return nil + } + } +} diff --git a/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKFileHandlable.swift b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKFileHandlable.swift new file mode 100644 index 0000000..c1e0752 --- /dev/null +++ b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKFileHandlable.swift @@ -0,0 +1,137 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +@testable import InfomaniakCore + +/// Mocking part of the `FileHandle` API +/// +/// Inherits from NSObject for free description implementation +final class MCKFileHandlable: NSObject, FileHandlable { + var availableData: Data = .init() + + // MARK: - seek(toOffset:) + + var seekToOffsetCalled: Bool { seekToOffsetCallCount > 0 } + var seekToOffsetCallCount = 0 + var seekToOffsetClosure: ((UInt64) -> Void)? + var seekToOffsetError: Error? + func seek(toOffset offset: UInt64) throws { + seekToOffsetCallCount += 1 + if let seekToOffsetError { + throw seekToOffsetError + } else if let seekToOffsetClosure { + seekToOffsetClosure(offset) + } + } + + // MARK: - truncate(atOffset:) + + var truncateCalled: Bool { truncateCallCount > 0 } + var truncateCallCount = 0 + var truncateClosure: ((UInt64) -> Void)? + func truncate(atOffset offset: UInt64) throws { + truncateCallCount += 1 + if let truncateClosure { + truncateClosure(offset) + } + } + + // MARK: - synchronize + + var synchronizeCalled: Bool { synchronizeCallCount > 0 } + var synchronizeCallCount = 0 + var synchronizeClosure: (() -> Void)? + func synchronize() throws { + synchronizeCallCount += 1 + if let synchronizeClosure { + synchronizeClosure() + } + } + + // MARK: - close + + var closeCalled: Bool { closeCallCount > 0 } + var closeCallCount = 0 + var closeClosure: (() -> Void)? + func close() throws { + closeCallCount += 1 + if let closeClosure { + closeClosure() + } + } + + // MARK: - readToEnd + + var readToEndCalled: Bool { readToEndCallCount > 0 } + var readToEndCallCount = 0 + var readToEndClosure: (() -> Data)? + func readToEnd() -> Data? { + readToEndCallCount += 1 + if let readToEndClosure { + return readToEndClosure() + } else { + return nil + } + } + + // MARK: - read(upToCount:) + + var readUpToCountCalled: Bool { readUpToCountCallCount > 0 } + var readUpToCountCallCount = 0 + var readUpToCountClosure: ((Int) -> Data)? + var readUpToCountError: Error? + func read(upToCount count: Int) throws -> Data? { + readUpToCountCallCount += 1 + if let readUpToCountError { + throw readUpToCountError + } else if let readUpToCountClosure { + return readUpToCountClosure(count) + } else { + return nil + } + } + + // MARK: - offset + + var offsetCalled: Bool { offsetCallCount > 0 } + var offsetCallCount = 0 + var offsetClosure: (() -> UInt64)? + func offset() -> UInt64 { + offsetCallCount += 1 + if let offsetClosure { + return offsetClosure() + } else { + return UInt64(NSNotFound) + } + } + + // MARK: - seekToEnd + + var seekToEndCalled: Bool { seekToEndCallCount > 0 } + var seekToEndCallCount = 0 + var seekToEndClosure: (() -> UInt64)? + func seekToEnd() -> UInt64 { + seekToEndCallCount += 1 + if let seekToEndClosure { + return seekToEndClosure() + } else { + return UInt64(NSNotFound) + } + } +} diff --git a/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProvidable.swift b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProvidable.swift new file mode 100644 index 0000000..d02e60c --- /dev/null +++ b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProvidable.swift @@ -0,0 +1,41 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCore + +/// A mock of RangeProvidable +public final class MCKRangeProvidable: RangeProvidable { + var allRangesCalled: Bool { allRangesCallCount > 0 } + var allRangesCallCount = 0 + var allRangesThrows: Error? + var allRangesClosure: (() -> [DataRange])? + public var allRanges: [DataRange] { + get throws { + allRangesCallCount += 1 + if let allRangesThrows { + throw allRangesThrows + } else if let allRangesClosure { + return allRangesClosure() + } else { + return [] + } + } + } + + public var fileSize: UInt64 = 0 +} diff --git a/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProviderGuts.swift b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProviderGuts.swift new file mode 100644 index 0000000..57d6ff5 --- /dev/null +++ b/Tests/InfomaniakCoreTests/Chunking/Mocks/MCKRangeProviderGuts.swift @@ -0,0 +1,43 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCore + +/// A manualy written mock of the RangeProviderGutsable protocol +final class MCKRangeProviderGutsable: RangeProviderGutsable { + var buildRangesCalled = false + var buildRangesReturnValue: [DataRange] = [] + func buildRanges(fileSize: UInt64, totalChunksCount: UInt64, chunkSize: UInt64) -> [DataRange] { + buildRangesCalled = true + return buildRangesReturnValue + } + + var readFileByteSizeCalled = false + var readFileByteSizeReturnValue: UInt64 = 0 + func readFileByteSize() throws -> UInt64 { + readFileByteSizeCalled = true + return readFileByteSizeReturnValue + } + + var preferredChunkSizeCalled = false + var preferredChunkSizeReturnValue: UInt64 = 0 + func preferredChunkSize(for fileSize: UInt64) -> UInt64 { + preferredChunkSizeCalled = true + return preferredChunkSizeReturnValue + } +} diff --git a/Tests/InfomaniakCoreTests/Chunking/UTChunkProvider.swift b/Tests/InfomaniakCoreTests/Chunking/UTChunkProvider.swift new file mode 100644 index 0000000..64db526 --- /dev/null +++ b/Tests/InfomaniakCoreTests/Chunking/UTChunkProvider.swift @@ -0,0 +1,190 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +@testable import InfomaniakCore +import XCTest + +/// Unit Tests of the ChunkProvider +@available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) +final class UTChunkProvider: XCTestCase { + // MARK: - next + + func testNext_emptyRanges() throws { + // GIVEN + let ranges = [DataRange]() + let mckFileHandle = MCKFileHandlable() + + let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: ranges) + + // WHEN + let chunk = chunkProvider.next() + + // THEN + XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 0) + XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 0) + XCTAssertNil(chunk) + } + + func testNext_hasOneRange() throws { + // GIVEN + let ranges: [DataRange] = [ + 0 ... 1024 + ] + let mckFileHandle = MCKFileHandlable() + mckFileHandle.readUpToCountClosure = { _ in Data() } + + let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: ranges) + + // WHEN + let chunk = chunkProvider.next() + + // THEN + XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 1) + XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 1) + XCTAssertNotNil(chunk) + } + + func testNext_hasRanges() throws { + // GIVEN + let ranges: [DataRange] = [ + 0 ... 1, + 1 ... 2, + 2 ... 4 + ] + let mckFileHandle = MCKFileHandlable() + mckFileHandle.readUpToCountClosure = { _ in Data() } + + let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: ranges) + + // WHEN + let chunk = chunkProvider.next() + + // THEN + XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 1) + XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 1) + XCTAssertNotNil(chunk) + } + + func testNext_enumarateAll() throws { + // GIVEN + let ranges: [DataRange] = [ + 0 ... 1, + 1 ... 2, + 2 ... 4 + ] + + let expectedRangesCount = ranges.count + let mckFileHandle = MCKFileHandlable() + mckFileHandle.readUpToCountClosure = { _ in Data() } + + let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: ranges) + + // WHEN + var chunks: [Data] = [] + while let chunk = chunkProvider.next() { + chunks.append(chunk) + } + + // THEN + XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, expectedRangesCount) + XCTAssertEqual(mckFileHandle.readUpToCountCallCount, expectedRangesCount) + XCTAssertEqual(chunks.count, expectedRangesCount) + } + + // MARK: - readChunk(range:) + + func testReadChunk_validChunk() throws { + // GIVEN + let range: DataRange = 0 ... 1 + let mckFileHandle = MCKFileHandlable() + mckFileHandle.readUpToCountClosure = { _ in Data() } + mckFileHandle.seekToOffsetClosure = { index in + XCTAssertEqual(index, range.lowerBound) + } + + let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: []) + + // WHEN + do { + let chunk = try chunkProvider.readChunk(range: range) + XCTAssertNotNil(chunk) + } catch { + XCTFail("Unexpected :\(error)") + return + } + + // THEN + XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 1) + XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 1) + } + + func testReadChunk_throwErrorOnSeek() throws { + // GIVEN + let range: DataRange = 0 ... 1 + let mckFileHandle = MCKFileHandlable() + mckFileHandle.readUpToCountClosure = { _ in Data() } + mckFileHandle.seekToOffsetError = NSError(domain: "k", code: 1337) + + let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: []) + + // WHEN + do { + _ = try chunkProvider.readChunk(range: range) + XCTFail("Unexpected") + return + } catch { + guard (error as NSError).code == 1337 else { + XCTFail("Unexpected") + return + } + // all good + } + + // THEN + XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 1) + XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 0) + } + + func testReadChunk_throwErrorOnRead() throws { + // GIVEN + let range: DataRange = 0 ... 1 + let mckFileHandle = MCKFileHandlable() + mckFileHandle.readUpToCountClosure = { _ in Data() } + mckFileHandle.readUpToCountError = NSError(domain: "k", code: 1337) + + let chunkProvider = ChunkProvider(mockedHandlable: mckFileHandle, ranges: []) + + // WHEN + do { + _ = try chunkProvider.readChunk(range: range) + XCTFail("Unexpected") + return + } catch { + guard (error as NSError).code == 1337 else { + XCTFail("Unexpected") + return + } + // all good + } + + // THEN + XCTAssertEqual(mckFileHandle.seekToOffsetCallCount, 1) + XCTAssertEqual(mckFileHandle.readUpToCountCallCount, 1) + } +} diff --git a/Tests/InfomaniakCoreTests/Chunking/UTRangeProvider.swift b/Tests/InfomaniakCoreTests/Chunking/UTRangeProvider.swift new file mode 100644 index 0000000..37ee35b --- /dev/null +++ b/Tests/InfomaniakCoreTests/Chunking/UTRangeProvider.swift @@ -0,0 +1,92 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +@testable import InfomaniakCore +import XCTest + +/// Unit Tests of the RangeProvider +final class UTRangeProvider: XCTestCase { + func testAllRanges_zeroes() throws { + // GIVEN + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + var rangeProvider = RangeProvider(fileURL: stubURL) + let mckGuts = MCKRangeProviderGutsable( /* all zeroes by default */ ) + + rangeProvider.guts = mckGuts + + // WHEN + do { + _ = try rangeProvider.allRanges + + // THEN + } catch { + XCTFail("Unexpected") + } + } + + func testAllRanges_FileTooLarge() throws { + // GIVEN + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + var rangeProvider = RangeProvider(fileURL: stubURL) + let mckGuts = MCKRangeProviderGutsable() + mckGuts.readFileByteSizeReturnValue = RangeProvider.APIConstants.fileMaxSizeClient + 1 + rangeProvider.guts = mckGuts + + // WHEN + do { + _ = try rangeProvider.allRanges + + // THEN + XCTFail("Unexpected") + } catch { + // Expecting a .FileTooLarge error + guard case .FileTooLarge = error as? RangeProvider.ErrorDomain else { + XCTFail("Unexpected") + return + } + + // success + } + } + + func testAllRanges_Success() throws { + // GIVEN + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + var rangeProvider = RangeProvider(fileURL: stubURL) + let mckGuts = MCKRangeProviderGutsable() + mckGuts.readFileByteSizeReturnValue = RangeProvider.APIConstants.chunkMinSize + 1 + mckGuts.preferredChunkSizeReturnValue = 1 * 1024 * 1024 + + rangeProvider.guts = mckGuts + + // WHEN + do { + let ranges = try rangeProvider.allRanges + + // THEN + XCTAssertNotNil(ranges) + XCTAssertEqual(ranges.count, 0) + + XCTAssertTrue(mckGuts.buildRangesCalled) + XCTAssertTrue(mckGuts.preferredChunkSizeCalled) + XCTAssertTrue(mckGuts.readFileByteSizeCalled) + } catch { + XCTFail("Unexpected \(error)") + } + } +} diff --git a/Tests/InfomaniakCoreTests/Chunking/UTRangeProviderGuts.swift b/Tests/InfomaniakCoreTests/Chunking/UTRangeProviderGuts.swift new file mode 100644 index 0000000..1670e0a --- /dev/null +++ b/Tests/InfomaniakCoreTests/Chunking/UTRangeProviderGuts.swift @@ -0,0 +1,492 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +@testable import InfomaniakCore +@testable import InfomaniakDI +import XCTest + +/// Unit tests of the RangeProviderGuts +final class UTRangeProviderGuts: XCTestCase { + // MARK: - readFileByteSize + + // covered by IT + + // MARK: - buildRanges(fileSize: totalChunksCount: chunkSize:) + + // MARK: zero + + func testBuildRanges_0ChunkSize() { + // GIVEN + let fileBytes = UInt64(4_865_229) + let fileChunks = UInt64(4) + let chunksSize = UInt64(0) + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + _ = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTFail("Expected to throw") + } catch { + guard case .IncorrectChunkSize = error as? RangeProvider.ErrorDomain else { + XCTFail("Unexpected") + return + } + } + } + + func testBuildRanges_0FileLength() { + // GIVEN + let fileBytes = UInt64(0) + let fileChunks = UInt64(4) + let chunksSize = UInt64(10 * 1024 * 1024) + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTAssertEqual(ranges.count, 0, "An empty file has no range") + try Self.checkContinuity(ranges: ranges) + } catch { + XCTFail("Chunks not continuous: \(error)") + } + } + + func testBuildRanges_0Chunk() { + // GIVEN + let fileBytes = UInt64(4_865_229) + let fileChunks = UInt64(0) + let chunksSize = UInt64(10 * 1024 * 1024) + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + _ = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTFail("Expected to throw") + } catch { + guard case .IncorrectTotalChunksCount = error as? RangeProvider.ErrorDomain else { + XCTFail("Unexpected \(error)") + return + } + } + } + + // MARK: one byte + + func testBuildRanges_1ChunkSize() { + // GIVEN + let fileBytes = UInt64(4_865_229) + let fileChunks = UInt64(4) + let chunksSize = UInt64(1) + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTAssertEqual(ranges.count, 5) + + try Self.checkContinuity(ranges: ranges) + } catch { + XCTFail("Unexpected: \(error)") + } + } + + func testBuildRanges_1FileLength_valid() { + // GIVEN + let fileBytes = UInt64(1) + let fileChunks = UInt64(1) + let chunksSize = UInt64(1) + // read as: The first Byte goes from index O to O + let firstByteRange: DataRange = 0 ... 0 + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first, firstByteRange) + + try Self.checkContinuity(ranges: ranges) + } catch { + XCTFail("Unexpected: \(error)") + } + } + + func testBuildRanges_1FileLength_invalid() { + // GIVEN + let fileBytes = UInt64(1) + let fileChunks = UInt64(4) + let chunksSize = UInt64(10 * 1024 * 1024) + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + _ = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTFail("Expected to throw") + } catch { + guard case .ChunkedSizeLargerThanSourceFile = error as? RangeProvider.ErrorDomain else { + XCTFail("Unexpected") + return + } + } + } + + func testBuildRanges_1Chunk() { + // GIVEN + let fileBytes = UInt64(4_865_229) + let fileChunks = UInt64(1) + let chunksSize = UInt64(1 * 1024 * 1024) + let expectedChunks = 2 // One plus remainer + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTAssertEqual(ranges.count, expectedChunks) + + try Self.checkContinuity(ranges: ranges) + } catch { + XCTFail("Unexpected: \(error)") + } + } + + func testBuildRanges_1ChunkBiggerThanFile() { + // GIVEN + let fileBytes = UInt64(4_865_229) + let fileChunks = UInt64(1) + let chunksSize = UInt64(10 * 1024 * 1024) + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + _ = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTFail("Expected to throw") + } catch { + guard case .ChunkedSizeLargerThanSourceFile = error as? RangeProvider.ErrorDomain else { + XCTFail("Unexpected") + return + } + } + } + + // MARK: pseudo arbitrary sizes + + func testBuildRanges_ChunkBiggerThanFile() { + // GIVEN + // Asking for 4 chunks of 10Mi is larger than the file, should exit + let fileBytes = UInt64(4_865_229) + let fileChunks = UInt64(4) + let chunksSize = UInt64(10 * 1024 * 1024) + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + _ = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTFail("Expected to throw") + } catch { + guard case .ChunkedSizeLargerThanSourceFile = error as? RangeProvider.ErrorDomain else { + XCTFail("Unexpected") + return + } + } + } + + func testBuildRanges_ChunksWithoutRemainder() { + // GIVEN + // Asking for exactly 4 chunks of 1Mi + let fileBytes = UInt64(4 * 1024 * 1024) + let fileChunks = UInt64(4) + let chunksSize = UInt64(1 * 1024 * 1024) + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTAssertEqual(ranges.count, 4) + + try Self.checkContinuity(ranges: ranges) + } catch { + XCTFail("Unexpected: \(error)") + } + } + + func testBuildRanges_ChunksWithRemainder() { + // GIVEN + // Asking for 4 chunks of 1Mi + some extra chunk with the remainder + let fileBytes = UInt64(4_865_229) + let fileChunks = UInt64(4) + let chunksSize = UInt64(1 * 1024 * 1024) + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + do { + let ranges = try guts.buildRanges(fileSize: fileBytes, totalChunksCount: fileChunks, chunkSize: chunksSize) + + // THEN + XCTAssertEqual(ranges.count, 5) + + try Self.checkContinuity(ranges: ranges) + } catch { + XCTFail("Unexpected: \(error)") + } + } + + // MARK: - preferredChunkSize(for fileSize:) + + /// This is just testing the android heuristic, the min size in enforced at a higher level + func testPreferredChunkSize_smallerThanMinChunk() { + // GIVEN + let fileBytes = UInt64(769) + let chunkMinSize = RangeProvider.APIConstants.chunkMinSize + XCTAssertTrue(chunkMinSize > fileBytes, "this precondition should be true") + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + let preferredChunkSize = guts.preferredChunkSize(for: fileBytes) + + // THEN + XCTAssertEqual(preferredChunkSize, fileBytes) + } + + func testPreferredChunkSize_equalsMinChunk() { + // GIVEN + let chunkMinSize = RangeProvider.APIConstants.chunkMinSize + let fileBytes = chunkMinSize + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + let preferredChunkSize = guts.preferredChunkSize(for: fileBytes) + + // THEN + XCTAssertEqual(preferredChunkSize, chunkMinSize) + } + + func testPreferredChunkSize_betweensMinAndMax() { + // GIVEN + let fileBytes = UInt64(5 * 1025 * 1024) + XCTAssertGreaterThanOrEqual(fileBytes, RangeProvider.APIConstants.chunkMinSize) + XCTAssertLessThanOrEqual(fileBytes, RangeProvider.APIConstants.chunkMaxSizeClient) + + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + let preferredChunkSize = guts.preferredChunkSize(for: fileBytes) + + // THEN + XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize) + XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient) + } + + func testPreferredChunkSize_EqualMax() { + // GIVEN + let fileBytes = RangeProvider.APIConstants.chunkMaxSizeClient + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + let preferredChunkSize = guts.preferredChunkSize(for: fileBytes) + + // THEN + XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize) + XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient) + } + + func testPreferredChunkSize_10Times() { + // GIVEN + let fileBytes = UInt64(10 * RangeProvider.APIConstants.chunkMaxSizeClient) + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + let preferredChunkSize = guts.preferredChunkSize(for: fileBytes) + + // THEN + XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize) + XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient) + } + + func testPreferredChunkSize_10KTimes() { + // GIVEN + let fileBytes = UInt64(10000 * RangeProvider.APIConstants.chunkMaxSizeClient) + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + let preferredChunkSize = guts.preferredChunkSize(for: fileBytes) + + // THEN + XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize) + XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient) + } + + func testPreferredChunkSize_100KTimes() { + // GIVEN + let fileBytes = UInt64(100_000 * RangeProvider.APIConstants.chunkMaxSizeClient) + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + let preferredChunkSize = guts.preferredChunkSize(for: fileBytes) + + // THEN + XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize) + XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient) + } + + func testPreferredChunkSize_100MTimes() { + // GIVEN + let fileBytes = UInt64(100_000_000 * RangeProvider.APIConstants.chunkMaxSizeClient) + let stubURL = URL(string: "file:///Arcalod_2117.jpg")! + let guts = RangeProviderGuts(fileURL: stubURL) + + // WHEN + let preferredChunkSize = guts.preferredChunkSize(for: fileBytes) + + // THEN + XCTAssertGreaterThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMinSize) + XCTAssertLessThanOrEqual(preferredChunkSize, RangeProvider.APIConstants.chunkMaxSizeClient) + } + + // MARK: - Helpers + + /// Yo dawg you tested the test helper function ? + func testContinuityHelper_continuous() { + // GIVEN + let rangeA = DataRange(uncheckedBounds: (lower: 0, upper: 1337)) + let rangeB = DataRange(uncheckedBounds: (lower: 1338, upper: 2047)) + let continuousRanges = [rangeA, rangeB] + + // WHEN + do { + try Self.checkContinuity(ranges: continuousRanges) + + // THEN + // no exception, all good + } catch { + XCTFail("Unexpected \(error)") + } + } + + func testContinuityHelper_notContinuous() { + // GIVEN + let rangeA = DataRange(uncheckedBounds: (lower: 0, upper: 1337)) + let rangeB = DataRange(uncheckedBounds: (lower: 1339, upper: 2047)) + let notContinuousRanges = [rangeA, rangeB] + + // WHEN + do { + try Self.checkContinuity(ranges: notContinuousRanges) + + // THEN + XCTFail("Unexpected") + } catch { + guard error is UTRangeProviderGuts.DomainError else { + XCTFail("Unexpected") + return + } + + // an exception, all good + } + } + + func testContinuityHelper_empty() { + // GIVEN + // note: Is ø formally continuous? No, but easier this way. + let emptyRanges: [DataRange] = [] + + // WHEN + do { + try Self.checkContinuity(ranges: emptyRanges) + + // THEN + // no exception, all good + } catch { + XCTFail("Unexpected \(error)") + } + } + + enum DomainError: Error { + case gap(leftUpper: UInt64, rightLower: UInt64) + case nonZeroStarted + } + + /// Check that the chunks provided are continuously describing chunks without gaps. + public static func checkContinuity(ranges: [DataRange]) throws { + /// Create an offseted sequence + let offsetedRanges = ranges.dropFirst() + + /// Create a zip to check continuity + let zip = zip(ranges, offsetedRanges) + for tuple in zip { + let leftUpperBound = tuple.0.upperBound + let rightLowerBound = tuple.1.lowerBound + + // print("range [leftUpperBound: \(leftUpperBound), rightLowerBound: \(rightLowerBound)]") + + guard leftUpperBound + 1 == rightLowerBound else { + throw DomainError.gap(leftUpper: leftUpperBound, rightLower: rightLowerBound) + } + } + } +}