diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index ddd1dbb74..b47ea117a 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -1663,3 +1663,400 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - DownloadManagerProtocol + +open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { + addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadData] + do { + __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + } + return __value + } + + open func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { + addInvocation(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) as? (String, [CourseBlock]) -> Void + perform?(`courseId`, `blocks`) + do { + _ = try methodReturnValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func pauseDownloading() { + addInvocation(.m_pauseDownloading) + let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void + perform?() + } + + open func deleteFile(blocks: [CourseBlock]) { + addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + } + + open func fileUrl(for blockId: String) -> URL? { + addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: URL? = nil + do { + __value = try methodReturnValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + + fileprivate enum MethodType { + case m_publisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_getDownloadsForCourse__courseId(Parameter) + case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) + case m_resumeDownloading + case m_pauseDownloading + case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_fileUrl__for_blockId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + + case (.m_cancelDownloading__courseId_courseIdblocks_blocks(let lhsCourseid, let lhsBlocks), .m_cancelDownloading__courseId_courseIdblocks_blocks(let rhsCourseid, let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + + case (.m_pauseDownloading, .m_pauseDownloading): return .match + + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_publisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue + case .m_resumeDownloading: return 0 + case .m_pauseDownloading: return 0 + case let .m_deleteFile__blocks_blocks(p0): return p0.intValue + case let .m_fileUrl__for_blockId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .m_pauseDownloading: return ".pauseDownloading()" + case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { + return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { + return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { + let willReturn: [[DownloadData]] = [] + let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadData]).self) + willProduce(stubber) + return given + } + public static func fileUrl(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [URL?] = [] + let given: Given = { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (URL?).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func resumeDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func resumeDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} + public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } + public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_pauseDownloading, performs: perform) + } + public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) + } + public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + diff --git a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index c6a7f60f6..79f74bbed 100644 --- a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,6 +1,7 @@ - + + diff --git a/Core/Core/Data/Persistence/CorePersistence.swift b/Core/Core/Data/Persistence/CorePersistence.swift index 131f69e0d..d465caabe 100644 --- a/Core/Core/Data/Persistence/CorePersistence.swift +++ b/Core/Core/Data/Persistence/CorePersistence.swift @@ -11,8 +11,8 @@ import Combine public protocol CorePersistenceProtocol { func publisher() -> AnyPublisher func addToDownloadQueue(blocks: [CourseBlock]) - func getBlocksForDownloading() -> [DownloadData] - func getAllDownloads() -> [DownloadData] + func getNextBlockForDownloading() -> DownloadData? + func getDownloadsForCourse(_ courseId: String) -> [DownloadData] func downloadData(by blockId: String) -> DownloadData? func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) func deleteDownloadData(id: String) throws @@ -67,6 +67,7 @@ public class CorePersistence: CorePersistenceProtocol { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newDownloadData.id = block.id + newDownloadData.courseId = block.courseId newDownloadData.url = url newDownloadData.fileName = fileName newDownloadData.progress = .zero @@ -77,32 +78,38 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func getBlocksForDownloading() -> [DownloadData] { + public func getNextBlockForDownloading() -> DownloadData? { let request = CDDownloadData.fetchRequest() request.predicate = NSPredicate(format: "state != %@", DownloadState.finished.rawValue) - guard let downloadData = try? context.fetch(request) else { return [] } - return downloadData.map { - DownloadData(id: $0.id ?? "", - url: $0.url ?? "", - fileName: $0.fileName ?? "", - progress: $0.progress, - resumeData: $0.resumeData, - state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, - type: DownloadType(rawValue: $0.type ?? "") ?? .video) - } + request.fetchLimit = 1 + guard let data = try? context.fetch(request).first else { return nil } + return DownloadData( + id: data.id ?? "", + courseId: data.courseId ?? "", + url: data.url ?? "", + fileName: data.fileName ?? "", + progress: data.progress, + resumeData: data.resumeData, + state: DownloadState(rawValue: data.state ?? "") ?? .waiting, + type: DownloadType(rawValue: data.type ?? "" ) ?? .video + ) } - public func getAllDownloads() -> [DownloadData] { + public func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { let request = CDDownloadData.fetchRequest() + request.predicate = NSPredicate(format: "courseId = %@", courseId) guard let downloadData = try? context.fetch(request) else { return [] } return downloadData.map { - DownloadData(id: $0.id ?? "", - url: $0.url ?? "", - fileName: $0.fileName ?? "", - progress: $0.progress, - resumeData: $0.resumeData, - state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, - type: DownloadType(rawValue: $0.type ?? "") ?? .video) + DownloadData( + id: $0.id ?? "", + courseId: $0.courseId ?? "", + url: $0.url ?? "", + fileName: $0.fileName ?? "", + progress: $0.progress, + resumeData: $0.resumeData, + state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, + type: DownloadType(rawValue: $0.type ?? "") ?? .video + ) } } @@ -110,14 +117,16 @@ public class CorePersistence: CorePersistenceProtocol { let request = CDDownloadData.fetchRequest() request.predicate = NSPredicate(format: "id = %@", blockId) guard let downloadData = try? context.fetch(request).first else { return nil } - return DownloadData(id: downloadData.id ?? "", - url: downloadData.url ?? "", - fileName: downloadData.fileName ?? "", - progress: downloadData.progress, - resumeData: downloadData.resumeData, - state: DownloadState(rawValue: downloadData.state ?? "") ?? .paused, - type: DownloadType(rawValue: downloadData.type ?? "" ) ?? .video) - + return DownloadData( + id: downloadData.id ?? "", + courseId: downloadData.courseId ?? "", + url: downloadData.url ?? "", + fileName: downloadData.fileName ?? "", + progress: downloadData.progress, + resumeData: downloadData.resumeData, + state: DownloadState(rawValue: downloadData.state ?? "") ?? .paused, + type: DownloadType(rawValue: downloadData.type ?? "" ) ?? .video + ) } public func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { @@ -155,6 +164,7 @@ public class CorePersistence: CorePersistenceProtocol { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newDownloadData.id = data.id + newDownloadData.courseId = data.courseId newDownloadData.url = data.url newDownloadData.progress = data.progress newDownloadData.fileName = data.fileName diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 0165d9ffb..c22907af4 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -8,8 +8,6 @@ import Foundation public struct CourseStructure: Equatable { - - public let courseID: String public let id: String public let graded: Bool public let completion: Double @@ -18,21 +16,21 @@ public struct CourseStructure: Equatable { public let displayName: String public let topicID: String? public let childs: [CourseChapter] - public let media: DataLayer.CourseMedia + public let media: DataLayer.CourseMedia //FIXME Domain model public let certificate: Certificate? - public init(courseID: String, - id: String, - graded: Bool, - completion: Double, - viewYouTubeUrl: String, - encodedVideo: String, - displayName: String, - topicID: String? = nil, - childs: [CourseChapter], - media: DataLayer.CourseMedia, - certificate: Certificate?) { - self.courseID = courseID + public init( + id: String, + graded: Bool, + completion: Double, + viewYouTubeUrl: String, + encodedVideo: String, + displayName: String, + topicID: String? = nil, + childs: [CourseChapter], + media: DataLayer.CourseMedia, + certificate: Certificate? + ) { self.id = id self.graded = graded self.completion = completion @@ -52,39 +50,29 @@ public struct CourseStructure: Equatable { } public struct CourseChapter { - public init(blockId: String, - id: String, - displayName: String, - type: BlockType, - childs: [CourseSequential]) { - self.blockId = blockId - self.id = id - self.displayName = displayName - self.type = type - self.childs = childs - } public let blockId: String public let id: String public let displayName: String public let type: BlockType public let childs: [CourseSequential] -} - -public struct CourseSequential { - public init(blockId: String, - id: String, - displayName: String, - type: BlockType, - completion: Double, - childs: [CourseVertical]) { + + public init( + blockId: String, + id: String, + displayName: String, + type: BlockType, + childs: [CourseSequential] + ) { self.blockId = blockId self.id = id self.displayName = displayName self.type = type - self.completion = completion self.childs = childs } +} + +public struct CourseSequential { public let blockId: String public let id: String @@ -96,15 +84,15 @@ public struct CourseSequential { public var isDownloadable: Bool { return childs.first(where: { $0.isDownloadable }) != nil } -} - -public struct CourseVertical { - public init(blockId: String, - id: String, - displayName: String, - type: BlockType, - completion: Double, - childs: [CourseBlock]) { + + public init( + blockId: String, + id: String, + displayName: String, + type: BlockType, + completion: Double, + childs: [CourseVertical] + ) { self.blockId = blockId self.id = id self.displayName = displayName @@ -112,9 +100,12 @@ public struct CourseVertical { self.completion = completion self.childs = childs } - +} + +public struct CourseVertical { public let blockId: String public let id: String + public let courseId: String public let displayName: String public let type: BlockType public let completion: Double @@ -123,6 +114,24 @@ public struct CourseVertical { public var isDownloadable: Bool { return childs.first(where: { $0.isDownloadable }) != nil } + + public init( + blockId: String, + id: String, + courseId: String, + displayName: String, + type: BlockType, + completion: Double, + childs: [CourseBlock] + ) { + self.blockId = blockId + self.id = id + self.courseId = courseId + self.displayName = displayName + self.type = type + self.completion = completion + self.childs = childs + } } public struct SubtitleUrl: Equatable { @@ -138,6 +147,7 @@ public struct SubtitleUrl: Equatable { public struct CourseBlock: Equatable { public let blockId: String public let id: String + public let courseId: String public let topicId: String? public let graded: Bool public let completion: Double @@ -147,23 +157,28 @@ public struct CourseBlock: Equatable { public let subtitles: [SubtitleUrl]? public let videoUrl: String? public let youTubeUrl: String? + public var isDownloadable: Bool { return videoUrl != nil } - public init(blockId: String, - id: String, - topicId: String? = nil, - graded: Bool, - completion: Double, - type: BlockType, - displayName: String, - studentUrl: String, - subtitles: [SubtitleUrl]? = nil, - videoUrl: String? = nil, - youTubeUrl: String? = nil) { + public init( + blockId: String, + id: String, + courseId: String, + topicId: String? = nil, + graded: Bool, + completion: Double, + type: BlockType, + displayName: String, + studentUrl: String, + subtitles: [SubtitleUrl]? = nil, + videoUrl: String? = nil, + youTubeUrl: String? = nil + ) { self.blockId = blockId self.id = id + self.courseId = courseId self.topicId = topicId self.graded = graded self.completion = completion diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 7166f5b90..e635d1541 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -22,6 +22,7 @@ public enum DownloadType: String { public struct DownloadData { public let id: String + public let courseId: String public let url: String public let fileName: String public let progress: Double @@ -34,11 +35,12 @@ public class NoWiFiError: LocalizedError { public init() {} } +//sourcery: AutoMockable public protocol DownloadManagerProtocol { func publisher() -> AnyPublisher func addToDownloadQueue(blocks: [CourseBlock]) throws - func getAllDownloads() -> [DownloadData] - func cancelDownloading(blocks: [CourseBlock]) throws + func getDownloadsForCourse(_ courseId: String) -> [DownloadData] + func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws func resumeDownloading() throws func pauseDownloading() func deleteFile(blocks: [CourseBlock]) @@ -54,9 +56,10 @@ public class DownloadManager: DownloadManagerProtocol { private var currentDownload: DownloadData? private var isDownloadingInProgress: Bool = false - public init(persistence: CorePersistenceProtocol, - appStorage: Core.AppStorage, - connectivity: ConnectivityProtocol + public init( + persistence: CorePersistenceProtocol, + appStorage: Core.AppStorage, + connectivity: ConnectivityProtocol ) { self.persistence = persistence self.appStorage = appStorage @@ -70,15 +73,16 @@ public class DownloadManager: DownloadManagerProtocol { public func addToDownloadQueue(blocks: [CourseBlock]) throws { if userCanDownload() { persistence.addToDownloadQueue(blocks: blocks) + guard !isDownloadingInProgress else { return } try newDownload() } else { - throw NoWiFiError() - } + throw NoWiFiError() + } } private func newDownload() throws { if userCanDownload() { - guard let download = persistence.getBlocksForDownloading().first else { + guard let download = persistence.getNextBlockForDownloading() else { isDownloadingInProgress = false return } @@ -101,14 +105,14 @@ public class DownloadManager: DownloadManagerProtocol { } } - public func getAllDownloads() -> [DownloadData] { - return persistence.getAllDownloads() + public func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { + return persistence.getDownloadsForCourse(courseId) } - public func cancelDownloading(blocks: [CourseBlock]) throws { + public func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { downloadRequest?.cancel() - let downloaded = getAllDownloads().filter { $0.state == .finished } + let downloaded = getDownloadsForCourse(courseId).filter { $0.state == .finished } let blocksForDelete = blocks.filter { block in downloaded.first(where: { $0.id == block.id }) == nil } deleteFile(blocks: blocksForDelete) @@ -117,9 +121,11 @@ public class DownloadManager: DownloadManagerProtocol { private func downloadFileWithProgress(_ download: DownloadData) throws { if let url = URL(string: download.url) { - persistence.updateDownloadState(id: download.id, - state: .inProgress, - resumeData: download.resumeData) + persistence.updateDownloadState( + id: download.id, + state: .inProgress, + resumeData: download.resumeData + ) self.isDownloadingInProgress = true let fileName = url.lastPathComponent if let resumeData = download.resumeData { @@ -127,17 +133,19 @@ public class DownloadManager: DownloadManagerProtocol { } else { downloadRequest = AF.download(url) } - // downloadRequest?.downloadProgress { prog in - // let completed = Double(prog.fractionCompleted * 100) - // print(">>>>> Downloading", download.url, completed, "%") - // } +// downloadRequest?.downloadProgress { prog in +// let completed = Double(prog.fractionCompleted * 100) +// print(">>>>> Downloading", download.url, completed, "%") +// } downloadRequest?.responseData(completionHandler: { [weak self] data in guard let self else { return } if let data = data.value, let url = self.videosFolderUrl() { self.saveFile(file: fileName, data: data, url: url) - self.persistence.updateDownloadState(id: download.id, - state: .finished, - resumeData: nil) + self.persistence.updateDownloadState( + id: download.id, + state: .finished, + resumeData: nil + ) try? self.newDownload() } }) @@ -151,9 +159,11 @@ public class DownloadManager: DownloadManagerProtocol { public func pauseDownloading() { guard let currentDownload else { return } downloadRequest?.cancel(byProducingResumeData: { resumeData in - self.persistence.updateDownloadState(id: currentDownload.id, - state: .paused, - resumeData: resumeData) + self.persistence.updateDownloadState( + id: currentDownload.id, + state: .paused, + resumeData: resumeData + ) }) } @@ -230,11 +240,11 @@ public class DownloadManagerMock: DownloadManagerProtocol { } - public func getAllDownloads() -> [DownloadData] { + public func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { return [] } - public func cancelDownloading(blocks: [CourseBlock]) { + public func cancelDownloading(courseId: String, blocks: [CourseBlock]) { } diff --git a/Core/Core/View/Base/WebUnitView.swift b/Core/Core/View/Base/WebUnitView.swift index e1108b3dc..7b4ed8157 100644 --- a/Core/Core/View/Base/WebUnitView.swift +++ b/Core/Core/View/Base/WebUnitView.swift @@ -58,12 +58,12 @@ public struct WebUnitView: View { isLoading: $isWebViewLoading, refreshCookies: { await viewModel.updateCookies(force: true) }) - .introspect(.scrollView, on: .iOS(.v14, .v15, .v16, .v17), customize: { scrollView in - scrollView.isScrollEnabled = false - }) .frame(width: reader.size.width, height: reader.size.height) } } + .introspect(.scrollView, on: .iOS(.v14, .v15, .v16, .v17), customize: { scrollView in + scrollView.isScrollEnabled = false + }) if viewModel.updatingCookies || isWebViewLoading { VStack { ProgressBar(size: 40, lineWidth: 8) diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 55f97a869..6be4d39e9 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -51,17 +51,17 @@ public class CourseRepository: CourseRepositoryProtocol { } public func getCourseBlocks(courseID: String) async throws -> CourseStructure { - let structure = try await api.requestData( + let course = try await api.requestData( CourseDetailsEndpoint.getCourseBlocks(courseID: courseID, userName: appStorage.user?.username ?? "") ).mapResponse(DataLayer.CourseStructure.self) - persistence.saveCourseStructure(structure: structure) - let parsedStructure = parseCourseStructure(structure: structure) + persistence.saveCourseStructure(structure: course) + let parsedStructure = parseCourseStructure(course: course) return parsedStructure } public func getCourseBlocksOffline(courseID: String) throws -> CourseStructure { let localData = try persistence.loadCourseStructure(courseID: courseID) - return parseCourseStructure(structure: localData) + return parseCourseStructure(course: localData) } public func enrollToCourse(courseID: String) async throws -> Bool { @@ -112,35 +112,36 @@ public class CourseRepository: CourseRepositoryProtocol { } } - private func parseCourseStructure(structure: DataLayer.CourseStructure) -> CourseStructure { - let blocks = Array(structure.dict.values) - let course = blocks.first(where: {$0.type == BlockType.course.rawValue })! - let descendants = course.descendants ?? [] + private func parseCourseStructure(course: DataLayer.CourseStructure) -> CourseStructure { + let blocks = Array(course.dict.values) + let courseBlock = blocks.first(where: {$0.type == BlockType.course.rawValue })! + let descendants = courseBlock.descendants ?? [] var childs: [CourseChapter] = [] for descend in descendants { - let chapter = parseChapters(id: descend, blocks: blocks) + let chapter = parseChapters(id: descend, courseId: course.id, blocks: blocks) childs.append(chapter) } - return CourseStructure(courseID: structure.id, - id: course.id, - graded: course.graded, - completion: course.completion ?? 0, - viewYouTubeUrl: course.userViewData?.encodedVideo?.youTube?.url ?? "", - encodedVideo: course.userViewData?.encodedVideo?.fallback?.url ?? "", - displayName: course.displayName, - topicID: course.userViewData?.topicID, - childs: childs, - media: structure.media, - certificate: structure.certificate?.domain) + return CourseStructure( + id: course.id, + graded: courseBlock.graded, + completion: courseBlock.completion ?? 0, + viewYouTubeUrl: courseBlock.userViewData?.encodedVideo?.youTube?.url ?? "", + encodedVideo: courseBlock.userViewData?.encodedVideo?.fallback?.url ?? "", + displayName: courseBlock.displayName, + topicID: courseBlock.userViewData?.topicID, + childs: childs, + media: course.media, + certificate: course.certificate?.domain + ) } - private func parseChapters(id: String, blocks: [DataLayer.CourseBlock]) -> CourseChapter { + private func parseChapters(id: String, courseId: String, blocks: [DataLayer.CourseBlock]) -> CourseChapter { let chapter = blocks.first(where: {$0.id == id })! let descendants = chapter.descendants ?? [] var childs: [CourseSequential] = [] for descend in descendants { - let chapter = parseSequential(id: descend, blocks: blocks) + let chapter = parseSequential(id: descend, courseId: courseId, blocks: blocks) childs.append(chapter) } return CourseChapter(blockId: chapter.blockId, @@ -151,39 +152,44 @@ public class CourseRepository: CourseRepositoryProtocol { } - private func parseSequential(id: String, blocks: [DataLayer.CourseBlock]) -> CourseSequential { + private func parseSequential(id: String, courseId: String, blocks: [DataLayer.CourseBlock]) -> CourseSequential { let sequential = blocks.first(where: {$0.id == id })! let descendants = sequential.descendants ?? [] var childs: [CourseVertical] = [] for descend in descendants { - let vertical = parseVerticals(id: descend, blocks: blocks) + let vertical = parseVerticals(id: descend, courseId: courseId, blocks: blocks) childs.append(vertical) } - return CourseSequential(blockId: sequential.blockId, - id: sequential.id, - displayName: sequential.displayName, - type: BlockType(rawValue: sequential.type) ?? .unknown, - completion: sequential.completion ?? 0, - childs: childs) + return CourseSequential( + blockId: sequential.blockId, + id: sequential.id, + displayName: sequential.displayName, + type: BlockType(rawValue: sequential.type) ?? .unknown, + completion: sequential.completion ?? 0, + childs: childs + ) } - private func parseVerticals(id: String, blocks: [DataLayer.CourseBlock]) -> CourseVertical { + private func parseVerticals(id: String, courseId: String, blocks: [DataLayer.CourseBlock]) -> CourseVertical { let sequential = blocks.first(where: {$0.id == id })! let descendants = sequential.descendants ?? [] var childs: [CourseBlock] = [] for descend in descendants { - let block = parseBlock(id: descend, blocks: blocks) + let block = parseBlock(id: descend, courseId: courseId, blocks: blocks) childs.append(block) } - return CourseVertical(blockId: sequential.blockId, - id: sequential.id, - displayName: sequential.displayName, - type: BlockType(rawValue: sequential.type) ?? .unknown, - completion: sequential.completion ?? 0, - childs: childs) + return CourseVertical( + blockId: sequential.blockId, + id: sequential.id, + courseId: courseId, + displayName: sequential.displayName, + type: BlockType(rawValue: sequential.type) ?? .unknown, + completion: sequential.completion ?? 0, + childs: childs + ) } - private func parseBlock(id: String, blocks: [DataLayer.CourseBlock]) -> CourseBlock { + private func parseBlock(id: String, courseId: String, blocks: [DataLayer.CourseBlock]) -> CourseBlock { let block = blocks.first(where: {$0.id == id })! let subtitles = block.userViewData?.transcripts?.map { let url = $0.value @@ -192,24 +198,27 @@ public class CourseRepository: CourseRepositoryProtocol { return SubtitleUrl(language: $0.key, url: url) } - return CourseBlock(blockId: block.blockId, - id: block.id, - topicId: block.userViewData?.topicID, - graded: block.graded, - completion: block.completion ?? 0, - type: BlockType(rawValue: block.type) ?? .unknown, - displayName: block.displayName, - studentUrl: block.studentUrl, - subtitles: subtitles, - videoUrl: block.userViewData?.encodedVideo?.fallback?.url, - youTubeUrl: block.userViewData?.encodedVideo?.youTube?.url) + return CourseBlock( + blockId: block.blockId, + id: block.id, + courseId: courseId, + topicId: block.userViewData?.topicID, + graded: block.graded, + completion: block.completion ?? 0, + type: BlockType(rawValue: block.type) ?? .unknown, + displayName: block.displayName, + studentUrl: block.studentUrl, + subtitles: subtitles, + videoUrl: block.userViewData?.encodedVideo?.fallback?.url, + youTubeUrl: block.userViewData?.encodedVideo?.youTube?.url + ) } } // Mark - For testing and SwiftUI preview -#if DEBUG // swiftlint:disable all +#if DEBUG class CourseRepositoryMock: CourseRepositoryProtocol { func resumeBlock(courseID: String) async throws -> ResumeBlock { ResumeBlock(blockID: "123") @@ -245,7 +254,7 @@ class CourseRepositoryMock: CourseRepositoryProtocol { let decoder = JSONDecoder() let jsonData = Data(courseStructureJson.utf8) let courseBlocks = try decoder.decode(DataLayer.CourseStructure.self, from: jsonData) - return parseCourseStructure(structure: courseBlocks) + return parseCourseStructure(course: courseBlocks) } public func getCourseDetails(courseID: String) async throws -> CourseDetails { @@ -267,12 +276,8 @@ class CourseRepositoryMock: CourseRepositoryProtocol { public func getCourseBlocks(courseID: String) async throws -> CourseStructure { do { -// let decoder = JSONDecoder() -// let jsonData = Data(courseStructureJson.utf8) let courseBlocks = try courseStructureJson.data(using: .utf8)!.mapResponse(DataLayer.CourseStructure.self) - -// let courseBlocks = try decoder.decode(DataLayer.CourseStructure.self, from: jsonData) - return parseCourseStructure(structure: courseBlocks) + return parseCourseStructure(course: courseBlocks) } catch { throw error } @@ -309,87 +314,94 @@ And there are various ways of describing it-- call it oral poetry or """ } - private func parseCourseStructure(structure: DataLayer.CourseStructure) -> CourseStructure { - let blocks = Array(structure.dict.values) - let course = blocks.first(where: {$0.type == BlockType.course.rawValue })! - let descendants = course.descendants ?? [] + private func parseCourseStructure(course: DataLayer.CourseStructure) -> CourseStructure { + let blocks = Array(course.dict.values) + let courseBlock = blocks.first(where: {$0.type == BlockType.course.rawValue })! + let descendants = courseBlock.descendants ?? [] var childs: [CourseChapter] = [] for descend in descendants { - let chapter = parseChapters(id: descend, blocks: blocks) + let chapter = parseChapters(id: descend, courseId: course.id, blocks: blocks) childs.append(chapter) } - return CourseStructure(courseID: structure.id, - id: course.id, - graded: course.graded, - completion: course.completion ?? 0, - viewYouTubeUrl: course.userViewData?.encodedVideo?.youTube?.url ?? "", - encodedVideo: course.userViewData?.encodedVideo?.fallback?.url ?? "", - displayName: course.displayName, - topicID: course.userViewData?.topicID, - childs: childs, - media: structure.media, - certificate: structure.certificate?.domain) + return CourseStructure( + id: course.id, + graded: courseBlock.graded, + completion: courseBlock.completion ?? 0, + viewYouTubeUrl: courseBlock.userViewData?.encodedVideo?.youTube?.url ?? "", + encodedVideo: courseBlock.userViewData?.encodedVideo?.fallback?.url ?? "", + displayName: courseBlock.displayName, + topicID: courseBlock.userViewData?.topicID, + childs: childs, + media: course.media, + certificate: course.certificate?.domain + ) } - private func parseChapters(id: String, blocks: [DataLayer.CourseBlock]) -> CourseChapter { + private func parseChapters(id: String, courseId: String, blocks: [DataLayer.CourseBlock]) -> CourseChapter { let chapter = blocks.first(where: {$0.id == id })! let descendants = chapter.descendants ?? [] var childs: [CourseSequential] = [] for descend in descendants { - let chapter = parseSequential(id: descend, blocks: blocks) + let chapter = parseSequential(id: descend, courseId: courseId, blocks: blocks) childs.append(chapter) } - return CourseChapter(blockId: chapter.blockId, - id: chapter.id, - displayName: chapter.displayName, - type: BlockType(rawValue: chapter.type) ?? .unknown, - childs: childs) + return CourseChapter( + blockId: chapter.blockId, + id: chapter.id, + displayName: chapter.displayName, + type: BlockType(rawValue: chapter.type) ?? .unknown, + childs: childs + ) } - private func parseSequential(id: String, blocks: [DataLayer.CourseBlock]) -> CourseSequential { + private func parseSequential(id: String, courseId: String, blocks: [DataLayer.CourseBlock]) -> CourseSequential { let sequential = blocks.first(where: {$0.id == id })! let descendants = sequential.descendants ?? [] var childs: [CourseVertical] = [] for descend in descendants { - let vertical = parseVerticals(id: descend, blocks: blocks) + let vertical = parseVerticals(id: descend, courseId: courseId, blocks: blocks) childs.append(vertical) } - return CourseSequential(blockId: sequential.blockId, - id: sequential.id, - displayName: sequential.displayName, - type: BlockType(rawValue: sequential.type) ?? .unknown, - completion: sequential.completion ?? 0, - childs: childs) + return CourseSequential( + blockId: sequential.blockId, + id: sequential.id, + displayName: sequential.displayName, + type: BlockType(rawValue: sequential.type) ?? .unknown, + completion: sequential.completion ?? 0, + childs: childs + ) } - private func parseVerticals(id: String, blocks: [DataLayer.CourseBlock]) -> CourseVertical { + private func parseVerticals(id: String, courseId: String, blocks: [DataLayer.CourseBlock]) -> CourseVertical { let sequential = blocks.first(where: {$0.id == id })! let descendants = sequential.descendants ?? [] var childs: [CourseBlock] = [] for descend in descendants { - let block = parseBlock(id: descend, blocks: blocks) + let block = parseBlock(id: descend, courseId: courseId, blocks: blocks) childs.append(block) } - return CourseVertical(blockId: sequential.blockId, - id: sequential.id, - displayName: sequential.displayName, - type: BlockType(rawValue: sequential.type) ?? .unknown, - completion: sequential.completion ?? 0, - childs: childs) + return CourseVertical( + blockId: sequential.blockId, + id: sequential.id, + courseId: courseId, + displayName: sequential.displayName, + type: BlockType(rawValue: sequential.type) ?? .unknown, + completion: sequential.completion ?? 0, + childs: childs + ) } - private func parseBlock(id: String, blocks: [DataLayer.CourseBlock]) -> CourseBlock { + private func parseBlock(id: String, courseId: String, blocks: [DataLayer.CourseBlock]) -> CourseBlock { let block = blocks.first(where: {$0.id == id })! let subtitles = block.userViewData?.transcripts?.map { let url = $0.value -// .replacingOccurrences(of: config.baseURL.absoluteString, with: "") -// .replacingOccurrences(of: "?lang=\($0.key)", with: "") return SubtitleUrl(language: $0.key, url: url) } return CourseBlock(blockId: block.blockId, id: block.id, + courseId: courseId, topicId: block.userViewData?.topicID, graded: block.graded, completion: block.completion ?? 0, @@ -1023,5 +1035,5 @@ And there are various ways of describing it-- call it oral poetry or } """ } - #endif +// swiftlint:enable all diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index 3bcfdd574..df58f05a9 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -48,7 +48,6 @@ public class CourseInteractor: CourseInteractorProtocol { } } return CourseStructure( - courseID: course.courseID, id: course.id, graded: course.graded, completion: course.completion, @@ -135,6 +134,7 @@ public class CourseInteractor: CourseInteractorProtocol { return CourseVertical( blockId: vertical.blockId, id: vertical.id, + courseId: vertical.courseId, displayName: vertical.displayName, type: vertical.type, completion: vertical.completion, diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 4f948bfa9..938c187a0 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -27,10 +27,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } - private let interactor: CourseInteractorProtocol - private let authInteractor: AuthInteractorProtocol let router: CourseRouter - let analytics: CourseAnalytics let config: Config let connectivity: ConnectivityProtocol @@ -40,6 +37,10 @@ public class CourseContainerViewModel: BaseCourseViewModel { let enrollmentStart: Date? let enrollmentEnd: Date? + private let interactor: CourseInteractorProtocol + private let authInteractor: AuthInteractorProtocol + private let analytics: CourseAnalytics + public init( interactor: CourseInteractorProtocol, authInteractor: AuthInteractorProtocol, @@ -88,8 +89,10 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseStructure = try await interactor.getCourseBlocks(courseID: courseID) isShowProgress = false if let courseStructure { - let continueWith = try await getResumeBlock(courseID: courseID, - courseStructure: courseStructure) + let continueWith = try await getResumeBlock( + courseID: courseID, + courseStructure: courseStructure + ) withAnimation { self.continueWith = continueWith } @@ -139,7 +142,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { try manager.addToDownloadQueue(blocks: blocks) downloadState[blockId] = .downloading case .downloading: - try manager.cancelDownloading(blocks: blocks) + try manager.cancelDownloading(courseId: courseStructure?.id ?? "", blocks: blocks) downloadState[blockId] = .available case .finished: manager.deleteFile(blocks: blocks) @@ -169,12 +172,31 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } + func trackSequentialClicked(_ sequential: CourseSequential) { + guard let course = courseStructure else { return } + analytics.sequentialClicked( + courseId: course.id, + courseName: course.displayName, + blockId: sequential.blockId, + blockName: sequential.displayName + ) + } + + func trackResumeCourseTapped(blockId: String) { + guard let course = courseStructure else { return } + analytics.resumeCourseTapped( + courseId: course.id, + courseName: course.displayName, + blockId: blockId + ) + } + @MainActor private func setDownloadsStates() { - guard let courseStructure else { return } - let downloads = manager.getAllDownloads() + guard let course = courseStructure else { return } + let downloads = manager.getDownloadsForCourse(course.id) var states: [String: DownloadViewState] = [:] - for chapter in courseStructure.childs { + for chapter in course.childs { for sequential in chapter.childs where sequential.isDownloadable { var childs: [DownloadViewState] = [] for vertical in sequential.childs where vertical.isDownloadable { diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index 914774946..6ad6e0389 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -37,7 +37,12 @@ class CourseAnalyticsMock: CourseAnalytics { public func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} public func prevBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} public func finishVerticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} - public func finishVerticalNextSectionClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} + public func finishVerticalNextSectionClicked( + courseId: String, + courseName: String, + blockId: String, + blockName: String + ) {} public func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) {} public func courseOutlineCourseTabClicked(courseId: String, courseName: String) {} public func courseOutlineVideosTabClicked(courseId: String, courseName: String) {} diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index b6e2832f3..2c9a60982 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -22,7 +22,6 @@ public protocol CourseRouter: BaseRouter { func showCourseUnit( courseName: String, - id: String, blockId: String, courseID: String, sectionName: String, @@ -33,7 +32,6 @@ public protocol CourseRouter: BaseRouter { ) func replaceCourseUnit( - id: String, courseName: String, blockId: String, courseID: String, @@ -45,7 +43,6 @@ public protocol CourseRouter: BaseRouter { ) func showCourseVerticalView( - id: String, courseID: String, courseName: String, title: String, @@ -80,7 +77,6 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { public func showCourseUnit( courseName: String, - id: String, blockId: String, courseID: String, sectionName: String, @@ -91,7 +87,6 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { ) {} public func replaceCourseUnit( - id: String, courseName: String, blockId: String, courseID: String, @@ -103,7 +98,6 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { ) {} public func showCourseVerticalView( - id: String, courseID: String, courseName: String, title: String, diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index b7556292e..c6ba3b2e1 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -349,4 +349,5 @@ struct CourseDetailsView_Previews: PreviewProvider { .previewDisplayName("CourseDetailsView Dark") } } +// swiftlint:enable all #endif diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index 998ddeca2..14bc3b874 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -15,18 +15,16 @@ struct ContinueWith { } struct ContinueWithView: View { - let data: ContinueWith - let courseStructure: CourseStructure - let router: CourseRouter - let analytics: CourseAnalytics + private let data: ContinueWith + private let courseStructure: CourseStructure + private let action: () -> Void private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - init(data: ContinueWith, courseStructure: CourseStructure, router: CourseRouter, analytics: CourseAnalytics) { + init(data: ContinueWith, courseStructure: CourseStructure, action: @escaping () -> Void) { self.data = data self.courseStructure = courseStructure - self.router = router - self.analytics = analytics + self.action = action } var body: some View { @@ -39,19 +37,8 @@ struct ContinueWithView: View { ContinueTitle(vertical: vertical) }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) Spacer() - UnitButtonView(type: .continueLesson, action: { - analytics.resumeCourseTapped(courseId: courseStructure.courseID, - courseName: courseStructure.displayName, - blockId: chapter.childs[data.sequentialIndex] - .childs[data.verticalIndex].blockId) - router.showCourseVerticalView(id: courseStructure.id, - courseID: courseStructure.courseID, - courseName: courseStructure.displayName, - title: chapter.childs[data.sequentialIndex].displayName, - chapters: courseStructure.childs, - chapterIndex: data.chapterIndex, - sequentialIndex: data.sequentialIndex) - }).frame(width: 200) + UnitButtonView(type: .continueLesson, action: action) + .frame(width: 200) } .padding(.horizontal, 24) .padding(.top, 32) } else { @@ -59,23 +46,11 @@ struct ContinueWithView: View { ContinueTitle(vertical: vertical) .foregroundColor(CoreAssets.textPrimary.swiftUIColor) } - UnitButtonView(type: .continueLesson, action: { - analytics.resumeCourseTapped(courseId: courseStructure.courseID, - courseName: courseStructure.displayName, - blockId: chapter.childs[data.sequentialIndex] - .childs[data.verticalIndex].blockId) - router.showCourseVerticalView(id: courseStructure.id, - courseID: courseStructure.courseID, - courseName: courseStructure.displayName, - title: chapter.childs[data.sequentialIndex].displayName, - chapters: courseStructure.childs, - chapterIndex: data.chapterIndex, - sequentialIndex: data.sequentialIndex) - }) + UnitButtonView(type: .continueLesson, action: action) } } - } .padding(.horizontal, 24) + }.padding(.horizontal, 24) .padding(.top, 32) } } @@ -120,12 +95,15 @@ struct ContinueWithView_Previews: PreviewProvider { CourseVertical( blockId: "1", id: "1", + courseId: "123", displayName: "Vertical", type: .vertical, completion: 0, childs: [ CourseBlock( - blockId: "2", id: "2", + blockId: "2", + id: "2", + courseId: "123", graded: true, completion: 0, type: .html, @@ -134,20 +112,22 @@ struct ContinueWithView_Previews: PreviewProvider { ])])]) ] - ContinueWithView(data: ContinueWith(chapterIndex: 0, sequentialIndex: 0, verticalIndex: 0), - courseStructure: CourseStructure(courseID: "v1-course", - id: "123", - graded: true, - completion: 0, - viewYouTubeUrl: "", - encodedVideo: "", - displayName: "Namaste", - childs: childs, - media: DataLayer.CourseMedia.init(image: - .init(raw: "", small: "", large: "")), - certificate: nil), - router: CourseRouterMock(), - analytics: CourseAnalyticsMock()) + ContinueWithView( + data: ContinueWith(chapterIndex: 0, sequentialIndex: 0, verticalIndex: 0), + courseStructure: CourseStructure( + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "Namaste", + childs: childs, + media: DataLayer.CourseMedia( + image: .init(raw: "", small: "", large: "") + ), + certificate: nil) + ) { + } } } #endif diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index b0f06ef29..9a31759c9 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -33,12 +33,13 @@ public struct CourseOutlineView: View { public var body: some View { ZStack(alignment: .top) { - // MARK: - Page name GeometryReader { proxy in VStack(alignment: .center) { - NavigationBar(title: title, - leftButtonAction: { viewModel.router.back() }) + NavigationBar( + title: title, + leftButtonAction: { viewModel.router.back() } + ) // MARK: - Page Body RefreshableScrollViewCompat(action: { @@ -68,15 +69,21 @@ public struct CourseOutlineView: View { Text(CourseLocalization.Outline.passedTheCourse) .font(Theme.Fonts.bodyMedium) .multilineTextAlignment(.center) - StyledButton(CourseLocalization.Outline.viewCertificate, - action: { openCertificateView = true }, - isTransparent: true) + StyledButton( + CourseLocalization.Outline.viewCertificate, + action: { openCertificateView = true }, + isTransparent: true + ) .frame(width: 141) .padding(.top, 8) - .fullScreenCover(isPresented: $openCertificateView, - content: { - WebBrowser(url: url, pageTitle: CourseLocalization.Outline.certificate) - }) + .fullScreenCover( + isPresented: $openCertificateView, + content: { + WebBrowser( + url: url, + pageTitle: CourseLocalization.Outline.certificate + ) + }) }.padding(.horizontal, 24) .padding(.top, 8) .foregroundColor(.white) @@ -89,117 +96,42 @@ public struct CourseOutlineView: View { .padding(.top, 7) .fixedSize(horizontal: false, vertical: true) - if !isVideo { - if let continueWith = viewModel.continueWith, - let courseStructure = viewModel.courseStructure { - ContinueWithView( - data: continueWith, - courseStructure: courseStructure, - router: viewModel.router, - analytics: viewModel.analytics + if let continueWith = viewModel.continueWith, + let courseStructure = viewModel.courseStructure, + !isVideo { + + // MARK: - ContinueWith button + ContinueWithView( + data: continueWith, + courseStructure: courseStructure + ) { + let chapter = courseStructure.childs[continueWith.chapterIndex] + let sequential = chapter.childs[continueWith.sequentialIndex] + + viewModel.trackResumeCourseTapped( + blockId: sequential.childs[continueWith.verticalIndex].blockId + ) + viewModel.router.showCourseVerticalView( + courseID: courseStructure.id, + courseName: courseStructure.displayName, + title: sequential.displayName, + chapters: courseStructure.childs, + chapterIndex: continueWith.chapterIndex, + sequentialIndex: continueWith.sequentialIndex ) } } - if let courseStructure = isVideo + if let course = isVideo ? viewModel.courseVideosStructure : viewModel.courseStructure { - // MARK: - Sections list - let chapters = courseStructure.childs - ForEach(chapters, id: \.id) { chapter in - let chapterIndex = chapters.firstIndex(where: { $0.id == chapter.id }) - Text(chapter.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) - .padding(.horizontal, 24) - .padding(.top, 40) - ForEach(chapter.childs, id: \.id) { child in - let sequentialIndex = chapter.childs.firstIndex(where: { $0.id == child.id }) - VStack(alignment: .leading) { - Button(action: { - if let chapterIndex, let sequentialIndex { - viewModel.analytics - .sequentialClicked(courseId: courseID, - courseName: self.title, - blockId: child.blockId, - blockName: child.displayName) - viewModel.router.showCourseVerticalView( - id: courseID, - courseID: courseStructure.courseID, - courseName: viewModel.courseStructure?.displayName ?? "", - title: child.displayName, - chapters: chapters, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex - ) - } - }, label: { - Group { - child.type.image - Text(child.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .lineLimit(1) - .frame( - maxWidth: idiom == .pad - ? proxy.size.width * 0.5 - : proxy.size.width * 0.6, - alignment: .leading - ) - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) - Spacer() - if let state = viewModel.downloadState[child.id] { - switch state { - case .available: - DownloadAvailableView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - .onForeground { - viewModel.onForeground() - } - case .downloading: - DownloadProgressView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - .onBackground { - viewModel.onBackground() - } - case .finished: - DownloadFinishedView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - } - } - Image(systemName: "chevron.right") - .foregroundColor(CoreAssets.accentColor.swiftUIColor) - }).padding(.horizontal, 36) - .padding(.vertical, 20) - if chapterIndex != chapters.count - 1 { - Divider() - .frame(height: 1) - .overlay(CoreAssets.cardViewStroke.swiftUIColor) - .padding(.horizontal, 24) - } - } - } - } + // MARK: - Sections + CourseStructureView( + proxy: proxy, + course: course, + viewModel: viewModel + ) } else { if let courseStart = viewModel.courseStart { Text(courseStart > Date() ? CourseLocalization.Outline.courseHasntStarted : "") @@ -255,6 +187,114 @@ public struct CourseOutlineView: View { } } +struct CourseStructureView: View { + + private let proxy: GeometryProxy + private let course: CourseStructure + private let viewModel: CourseContainerViewModel + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + init(proxy: GeometryProxy, course: CourseStructure, viewModel: CourseContainerViewModel) { + self.proxy = proxy + self.course = course + self.viewModel = viewModel + } + + var body: some View { + let chapters = course.childs + ForEach(chapters, id: \.id) { chapter in + let chapterIndex = chapters.firstIndex(where: { $0.id == chapter.id }) + Text(chapter.displayName) + .font(Theme.Fonts.titleMedium) + .multilineTextAlignment(.leading) + .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .padding(.horizontal, 24) + .padding(.top, 40) + ForEach(chapter.childs, id: \.id) { child in + let sequentialIndex = chapter.childs.firstIndex(where: { $0.id == child.id }) + VStack(alignment: .leading) { + Button( + action: { + if let chapterIndex, let sequentialIndex { + viewModel.trackSequentialClicked(child) + viewModel.router.showCourseVerticalView( + courseID: viewModel.courseStructure?.id ?? "", + courseName: viewModel.courseStructure?.displayName ?? "", + title: child.displayName, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + } + }, + label: { + Group { + child.type.image + Text(child.displayName) + .font(Theme.Fonts.titleMedium) + .multilineTextAlignment(.leading) + .lineLimit(1) + .frame( + maxWidth: idiom == .pad + ? proxy.size.width * 0.5 + : proxy.size.width * 0.6, + alignment: .leading + ) + }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) + Spacer() + if let state = viewModel.downloadState[child.id] { + switch state { + case .available: + DownloadAvailableView() + .onTapGesture { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: child.id, + state: state + ) + } + .onForeground { + viewModel.onForeground() + } + case .downloading: + DownloadProgressView() + .onTapGesture { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: child.id, + state: state + ) + } + .onBackground { + viewModel.onBackground() + } + case .finished: + DownloadFinishedView() + .onTapGesture { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: child.id, + state: state + ) + } + } + } + Image(systemName: "chevron.right") + .foregroundColor(CoreAssets.accentColor.swiftUIColor) + }).padding(.horizontal, 36) + .padding(.vertical, 20) + if chapterIndex != chapters.count - 1 { + Divider() + .frame(height: 1) + .overlay(CoreAssets.cardViewStroke.swiftUIColor) + .padding(.horizontal, 24) + } + } + } + } + } +} + #if DEBUG struct CourseOutlineView_Previews: PreviewProvider { static var previews: some View { diff --git a/Course/Course/Presentation/Outline/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVerticalView.swift index cf72ea53a..40ccd5153 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVerticalView.swift @@ -15,7 +15,6 @@ public struct CourseVerticalView: View { private var title: String private var courseName: String private var courseID: String - private let id: String @ObservedObject private var viewModel: CourseVerticalViewModel private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @@ -24,13 +23,11 @@ public struct CourseVerticalView: View { title: String, courseName: String, courseID: String, - id: String, viewModel: CourseVerticalViewModel ) { self.title = title self.courseName = courseName self.courseID = courseID - self.id = id self.viewModel = viewModel } @@ -50,19 +47,21 @@ public struct CourseVerticalView: View { Button(action: { let vertical = viewModel.verticals[index] if let block = vertical.childs.first { - viewModel.analytics.verticalClicked(courseId: courseID, - courseName: courseName, - blockId: vertical.blockId, - blockName: vertical.displayName) - viewModel.router.showCourseUnit(courseName: courseName, - id: id, - blockId: block.id, - courseID: courseID, - sectionName: block.displayName, - verticalIndex: index, - chapters: viewModel.chapters, - chapterIndex: viewModel.chapterIndex, - sequentialIndex: viewModel.sequentialIndex) + viewModel.trackVerticalClicked( + courseId: courseID, + courseName: courseName, + vertical: vertical + ) + viewModel.router.showCourseUnit( + courseName: courseName, + blockId: block.id, + courseID: courseID, + sectionName: block.displayName, + verticalIndex: index, + chapters: viewModel.chapters, + chapterIndex: viewModel.chapterIndex, + sequentialIndex: viewModel.sequentialIndex + ) } }, label: { HStack { @@ -188,6 +187,7 @@ struct CourseVerticalView_Previews: PreviewProvider { CourseVertical( blockId: "4", id: "4", + courseId: "1", displayName: "Vertical", type: .vertical, completion: 0, @@ -207,13 +207,23 @@ struct CourseVerticalView_Previews: PreviewProvider { ) return Group { - CourseVerticalView(title: "Course title", courseName: "CourseName", courseID: "1", id: "1", viewModel: viewModel) - .preferredColorScheme(.light) - .previewDisplayName("CourseVerticalView Light") + CourseVerticalView( + title: "Course title", + courseName: "CourseName", + courseID: "1", + viewModel: viewModel + ) + .preferredColorScheme(.light) + .previewDisplayName("CourseVerticalView Light") - CourseVerticalView(title: "Course title", courseName: "CourseName", courseID: "1", id: "1", viewModel: viewModel) - .preferredColorScheme(.dark) - .previewDisplayName("CourseVerticalView Dark") + CourseVerticalView( + title: "Course title", + courseName: "CourseName", + courseID: "1", + viewModel: viewModel + ) + .preferredColorScheme(.dark) + .previewDisplayName("CourseVerticalView Dark") } } diff --git a/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift b/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift index 793fb7519..9f2ae30b7 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift +++ b/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift @@ -67,7 +67,7 @@ public class CourseVerticalViewModel: BaseCourseViewModel { try manager.addToDownloadQueue(blocks: blocks) downloadState[vertical.id] = .downloading case .downloading: - try manager.cancelDownloading(blocks: blocks) + try manager.cancelDownloading(courseId: vertical.courseId, blocks: blocks) downloadState[vertical.id] = .available case .finished: manager.deleteFile(blocks: blocks) @@ -81,8 +81,22 @@ public class CourseVerticalViewModel: BaseCourseViewModel { } } + func trackVerticalClicked( + courseId: String, + courseName: String, + vertical: CourseVertical + ) { + analytics.verticalClicked( + courseId: courseId, + courseName: courseName, + blockId: vertical.blockId, + blockName: vertical.displayName + ) + } + private func setDownloadsStates() { - let downloads = manager.getAllDownloads() + guard let courseId = verticals.first?.courseId else { return } + let downloads = manager.getDownloadsForCourse(courseId) var states: [String: DownloadViewState] = [:] for vertical in verticals where vertical.isDownloadable { var childs: [DownloadViewState] = [] diff --git a/Course/Course/Presentation/Unit/CourseNavigationView.swift b/Course/Course/Presentation/Unit/CourseNavigationView.swift index 97db77c0d..ba9d34a5a 100644 --- a/Course/Course/Presentation/Unit/CourseNavigationView.swift +++ b/Course/Course/Presentation/Unit/CourseNavigationView.swift @@ -72,9 +72,8 @@ struct CourseNavigationView: View { okTapped: { playerStateSubject.send(VideoPlayerState.pause) playerStateSubject.send(VideoPlayerState.kill) - viewModel.analytics - .finishVerticalBackToOutlineClicked(courseId: viewModel.courseID, - courseName: viewModel.courseName) + + viewModel.trackFinishVerticalBackToOutlineClicked() viewModel.router.dismiss(animated: false) viewModel.router.back(animated: true) }, @@ -113,7 +112,6 @@ struct CourseNavigationView: View { ) viewModel.router.replaceCourseUnit( - id: viewModel.id, courseName: viewModel.courseName, blockId: viewModel.lessonID, courseID: viewModel.courseID, @@ -156,7 +154,6 @@ struct CourseNavigationView_Previews: PreviewProvider { let viewModel = CourseUnitViewModel( lessonID: "1", courseID: "1", - id: "1", courseName: "Name", chapters: [], chapterIndex: 1, diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index d8b06640e..1479844a4 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -96,7 +96,7 @@ public struct CourseUnitView: View { VStack { if showDiscussion { DiscussionView( - id: viewModel.id, + id: viewModel.courseID, blockID: blockID, blockKey: blockKey, title: title, @@ -105,7 +105,7 @@ public struct CourseUnitView: View { Spacer(minLength: 100) } else { DiscussionView( - id: viewModel.id, + id: viewModel.courseID, blockID: blockID, blockKey: blockKey, title: title, @@ -218,6 +218,7 @@ struct CourseUnitView_Previews: PreviewProvider { CourseBlock( blockId: "1", id: "1", + courseId: "123", topicId: "1", graded: false, completion: 0, @@ -230,6 +231,7 @@ struct CourseUnitView_Previews: PreviewProvider { CourseBlock( blockId: "2", id: "2", + courseId: "123", topicId: "2", graded: false, completion: 0, @@ -242,6 +244,7 @@ struct CourseUnitView_Previews: PreviewProvider { CourseBlock( blockId: "3", id: "3", + courseId: "123", topicId: "3", graded: false, completion: 0, @@ -254,6 +257,7 @@ struct CourseUnitView_Previews: PreviewProvider { CourseBlock( blockId: "4", id: "4", + courseId: "123", topicId: "4", graded: false, completion: 0, @@ -280,7 +284,9 @@ struct CourseUnitView_Previews: PreviewProvider { completion: 0, childs: [ CourseVertical( - blockId: "6", id: "6", + blockId: "6", + id: "6", + courseId: "123", displayName: "6", type: .vertical, completion: 0, @@ -304,7 +310,9 @@ struct CourseUnitView_Previews: PreviewProvider { completion: 0, childs: [ CourseVertical( - blockId: "4", id: "4", + blockId: "4", + id: "4", + courseId: "1", displayName: "4", type: .vertical, completion: 0, @@ -319,7 +327,6 @@ struct CourseUnitView_Previews: PreviewProvider { return CourseUnitView(viewModel: CourseUnitViewModel( lessonID: "", courseID: "", - id: "1", courseName: "courseName", chapters: chapters, chapterIndex: 0, diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index cca727927..e48e3ab32 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -63,7 +63,6 @@ public class CourseUnitViewModel: ObservableObject { var lessonID: String var courseID: String - var id: String private let interactor: CourseInteractorProtocol let router: CourseRouter @@ -82,7 +81,6 @@ public class CourseUnitViewModel: ObservableObject { public init( lessonID: String, courseID: String, - id: String, courseName: String, chapters: [CourseChapter], chapterIndex: Int, @@ -96,7 +94,6 @@ public class CourseUnitViewModel: ObservableObject { ) { self.lessonID = lessonID self.courseID = courseID - self.id = id self.courseName = courseName self.chapters = chapters self.chapterIndex = chapterIndex @@ -127,25 +124,29 @@ public class CourseUnitViewModel: ObservableObject { if index != verticals[verticalIndex].childs.count - 1 { index += 1 } let nextBlock = verticals[verticalIndex].childs[index] nextTitles() - analytics.nextBlockClicked(courseId: courseID, - courseName: courseName, - blockId: nextBlock.blockId, - blockName: nextBlock.displayName) + analytics.nextBlockClicked( + courseId: courseID, + courseName: courseName, + blockId: nextBlock.blockId, + blockName: nextBlock.displayName + ) case .previous: if index != 0 { index -= 1 } nextTitles() let prevBlock = verticals[verticalIndex].childs[index] - analytics.prevBlockClicked(courseId: courseID, - courseName: courseName, - blockId: prevBlock.blockId, - blockName: prevBlock.displayName) + analytics.prevBlockClicked( + courseId: courseID, + courseName: courseName, + blockId: prevBlock.blockId, + blockName: prevBlock.displayName + ) } } @MainActor func blockCompletionRequest(blockID: String) async { do { - try await interactor.blockCompletionRequest(courseID: self.id, blockID: blockID) + try await interactor.blockCompletionRequest(courseID: courseID, blockID: blockID) } catch let error { if error.isInternetError || error is NoCachedDataError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection @@ -175,4 +176,8 @@ public class CourseUnitViewModel: ObservableObject { return URL(string: url) } } + + func trackFinishVerticalBackToOutlineClicked() { + analytics.finishVerticalBackToOutlineClicked(courseId: courseID, courseName: courseName) + } } diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index ff0dc4ced..bdf04ef71 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -2055,3 +2055,400 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { } } +// MARK: - DownloadManagerProtocol + +open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { + addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadData] + do { + __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + } + return __value + } + + open func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { + addInvocation(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) as? (String, [CourseBlock]) -> Void + perform?(`courseId`, `blocks`) + do { + _ = try methodReturnValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func pauseDownloading() { + addInvocation(.m_pauseDownloading) + let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void + perform?() + } + + open func deleteFile(blocks: [CourseBlock]) { + addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + } + + open func fileUrl(for blockId: String) -> URL? { + addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: URL? = nil + do { + __value = try methodReturnValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + + fileprivate enum MethodType { + case m_publisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_getDownloadsForCourse__courseId(Parameter) + case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) + case m_resumeDownloading + case m_pauseDownloading + case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_fileUrl__for_blockId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + + case (.m_cancelDownloading__courseId_courseIdblocks_blocks(let lhsCourseid, let lhsBlocks), .m_cancelDownloading__courseId_courseIdblocks_blocks(let rhsCourseid, let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + + case (.m_pauseDownloading, .m_pauseDownloading): return .match + + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_publisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue + case .m_resumeDownloading: return 0 + case .m_pauseDownloading: return 0 + case let .m_deleteFile__blocks_blocks(p0): return p0.intValue + case let .m_fileUrl__for_blockId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .m_pauseDownloading: return ".pauseDownloading()" + case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { + return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { + return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { + let willReturn: [[DownloadData]] = [] + let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadData]).self) + willProduce(stubber) + return given + } + public static func fileUrl(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [URL?] = [] + let given: Given = { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (URL?).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func resumeDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func resumeDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} + public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } + public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_pauseDownloading, performs: perform) + } + public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) + } + public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index bae62298c..86c12aa7a 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -11,6 +11,7 @@ import XCTest @testable import Course import Alamofire import SwiftUI +import Combine final class CourseContainerViewModelTests: XCTestCase { @@ -42,6 +43,7 @@ final class CourseContainerViewModelTests: XCTestCase { let block = CourseBlock( blockId: "", id: "", + courseId: "123", topicId: "", graded: true, completion: 0, @@ -54,6 +56,7 @@ final class CourseContainerViewModelTests: XCTestCase { let vertical = CourseVertical( blockId: "", id: "", + courseId: "123", displayName: "", type: .vertical, completion: 0, @@ -78,7 +81,6 @@ final class CourseContainerViewModelTests: XCTestCase { let childs = [chapter] let courseStructure = CourseStructure( - courseID: "1", id: "123", graded: true, completion: 0, @@ -140,19 +142,20 @@ final class CourseContainerViewModelTests: XCTestCase { enrollmentEnd: nil ) - let courseStructure = CourseStructure(courseID: "1", - id: "123", - graded: true, - completion: 0, - viewYouTubeUrl: "", - encodedVideo: "", - displayName: "", - topicID: nil, - childs: [], - media: DataLayer.CourseMedia(image: DataLayer.Image(raw: "", - small: "", - large: "")), - certificate: nil) + let courseStructure = CourseStructure( + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "", + topicID: nil, + childs: [], + media: DataLayer.CourseMedia(image: DataLayer.Image(raw: "", + small: "", + large: "")), + certificate: nil + ) Given(interactor, .getCourseBlocksOffline(courseID: .any, willReturn: courseStructure)) Given(interactor, .getCourseVideoBlocks(fullStructure: .any, @@ -319,4 +322,579 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.trackSelectedTab(selection: .handounds, courseId: "1", courseName: "name") Verify(analytics, .courseOutlineHandoutsTabClicked(courseId: .value("1"), courseName: .value("name"))) } + + func testOnDownloadViewAvailableTap() { + let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() + let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() + let config = ConfigMock() + let connectivity = ConnectivityProtocolMock() + let downloadManager = DownloadManagerProtocolMock() + + Given(connectivity, .isInternetAvaliable(getter: true)) + + Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) + + let viewModel = CourseContainerViewModel( + interactor: interactor, + authInteractor: authInteractor, + router: router, + analytics: analytics, + config: config, + connectivity: connectivity, + manager: downloadManager, + isActive: true, + courseStart: Date(), + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil + ) + + let blockId = "chapter:block:1" + + let chapter = CourseChapter( + blockId: blockId, + id: "1", + displayName: "Chapter 1", + type: .chapter, + childs: [] + ) + + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: blockId, + state: .available + ) + + XCTAssertEqual(viewModel.downloadState[blockId], .downloading) + } + + func testOnDownloadViewDownloadingTap() { + let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() + let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() + let config = ConfigMock() + let connectivity = ConnectivityProtocolMock() + let downloadManager = DownloadManagerProtocolMock() + + Given(connectivity, .isInternetAvaliable(getter: true)) + + Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) + + let viewModel = CourseContainerViewModel( + interactor: interactor, + authInteractor: authInteractor, + router: router, + analytics: analytics, + config: config, + connectivity: connectivity, + manager: downloadManager, + isActive: true, + courseStart: Date(), + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil + ) + + let blockId = "chapter:block:1" + + let chapter = CourseChapter( + blockId: blockId, + id: "1", + displayName: "Chapter 1", + type: .chapter, + childs: [] + ) + + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: blockId, + state: .downloading + ) + + XCTAssertEqual(viewModel.downloadState[blockId], .available) + } + + func testOnDownloadViewFinishedTap() { + let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() + let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() + let config = ConfigMock() + let connectivity = ConnectivityProtocolMock() + let downloadManager = DownloadManagerProtocolMock() + + Given(connectivity, .isInternetAvaliable(getter: true)) + + Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) + + let viewModel = CourseContainerViewModel( + interactor: interactor, + authInteractor: authInteractor, + router: router, + analytics: analytics, + config: config, + connectivity: connectivity, + manager: downloadManager, + isActive: true, + courseStart: Date(), + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil + ) + + let blockId = "chapter:block:1" + + let chapter = CourseChapter( + blockId: blockId, + id: "1", + displayName: "Chapter 1", + type: .chapter, + childs: [] + ) + + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: blockId, + state: .finished + ) + + XCTAssertEqual(viewModel.downloadState[blockId], .available) + } + + func testSetDownloadsStatesAvailable() { + let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() + let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() + let config = ConfigMock() + let connectivity = ConnectivityProtocolMock() + let downloadManager = DownloadManagerProtocolMock() + + let block = CourseBlock( + blockId: "block:1", + id: "1", + courseId: "123", + topicId: "", + graded: false, + completion: 0, + type: .video, + displayName: "", + studentUrl: "", + videoUrl: "https://example.com/file.mp4", + youTubeUrl: nil + ) + let vertical = CourseVertical( + blockId: "block:vertical1", + id: "vertical1", + courseId: "123", + displayName: "", + type: .vertical, + completion: 0, + childs: [block] + ) + let sequential = CourseSequential( + blockId: "block:sequential1", + id: "sequential1", + displayName: "", + type: .chapter, + completion: 0, + childs: [vertical] + ) + let chapter = CourseChapter( + blockId: "", + id: "", + displayName: "", + type: .chapter, + childs: [sequential] + ) + + let childs = [chapter] + + let courseStructure = CourseStructure( + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "", + topicID: nil, + childs: childs, + media: DataLayer.CourseMedia(image: DataLayer.Image( + raw: "", + small: "", + large: "" + )), + certificate: nil + ) + + Given(connectivity, .isInternetAvaliable(getter: true)) + + Given(downloadManager, .publisher(willReturn: Just(1).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadsForCourse(.any, willReturn: [])) + + let viewModel = CourseContainerViewModel( + interactor: interactor, + authInteractor: authInteractor, + router: router, + analytics: analytics, + config: config, + connectivity: connectivity, + manager: downloadManager, + isActive: true, + courseStart: Date(), + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil + ) + viewModel.courseStructure = courseStructure + + let exp = expectation(description: "DispatchQueue.main.async Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + + XCTAssertEqual(viewModel.downloadState[sequential.id], .available) + } + + func testSetDownloadsStatesDownloading() { + let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() + let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() + let config = ConfigMock() + let connectivity = ConnectivityProtocolMock() + let downloadManager = DownloadManagerProtocolMock() + + let block = CourseBlock( + blockId: "block:1", + id: "1", + courseId: "123", + topicId: "", + graded: false, + completion: 0, + type: .video, + displayName: "", + studentUrl: "", + videoUrl: "https://example.com/file.mp4", + youTubeUrl: nil + ) + let vertical = CourseVertical( + blockId: "block:vertical1", + id: "vertical1", + courseId: "123", + displayName: "", + type: .vertical, + completion: 0, + childs: [block] + ) + let sequential = CourseSequential( + blockId: "block:sequential1", + id: "sequential1", + displayName: "", + type: .chapter, + completion: 0, + childs: [vertical] + ) + let chapter = CourseChapter( + blockId: "", + id: "", + displayName: "", + type: .chapter, + childs: [sequential] + ) + + let childs = [chapter] + + let courseStructure = CourseStructure( + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "", + topicID: nil, + childs: childs, + media: DataLayer.CourseMedia(image: DataLayer.Image( + raw: "", + small: "", + large: "" + )), + certificate: nil + ) + + let downloadData = DownloadData( + id: "1", + courseId: "course123", + url: "https://example.com/file.mp4", + fileName: "file.mp4", + progress: 0, + resumeData: nil, + state: .waiting, + type: .video + ) + + Given(connectivity, .isInternetAvaliable(getter: true)) + + Given(downloadManager, .publisher(willReturn: Just(1).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadsForCourse(.any, willReturn: [downloadData])) + + let viewModel = CourseContainerViewModel( + interactor: interactor, + authInteractor: authInteractor, + router: router, + analytics: analytics, + config: config, + connectivity: connectivity, + manager: downloadManager, + isActive: true, + courseStart: Date(), + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil + ) + viewModel.courseStructure = courseStructure + + let exp = expectation(description: "DispatchQueue.main.async Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + + XCTAssertEqual(viewModel.downloadState[sequential.id], .downloading) + } + + func testSetDownloadsStatesFinished() { + let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() + let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() + let config = ConfigMock() + let connectivity = ConnectivityProtocolMock() + let downloadManager = DownloadManagerProtocolMock() + + let block = CourseBlock( + blockId: "block:1", + id: "1", + courseId: "123", + topicId: "", + graded: false, + completion: 0, + type: .video, + displayName: "", + studentUrl: "", + videoUrl: "https://example.com/file.mp4", + youTubeUrl: nil + ) + let vertical = CourseVertical( + blockId: "block:vertical1", + id: "vertical1", + courseId: "123", + displayName: "", + type: .vertical, + completion: 0, + childs: [block] + ) + let sequential = CourseSequential( + blockId: "block:sequential1", + id: "sequential1", + displayName: "", + type: .chapter, + completion: 0, + childs: [vertical] + ) + let chapter = CourseChapter( + blockId: "", + id: "", + displayName: "", + type: .chapter, + childs: [sequential] + ) + + let childs = [chapter] + + let courseStructure = CourseStructure( + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "", + topicID: nil, + childs: childs, + media: DataLayer.CourseMedia(image: DataLayer.Image( + raw: "", + small: "", + large: "" + )), + certificate: nil + ) + + let downloadData = DownloadData( + id: "1", + courseId: "course123", + url: "https://example.com/file.mp4", + fileName: "file.mp4", + progress: 0, + resumeData: nil, + state: .finished, + type: .video + ) + + Given(connectivity, .isInternetAvaliable(getter: true)) + + Given(downloadManager, .publisher(willReturn: Just(1).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadsForCourse(.any, willReturn: [downloadData])) + + let viewModel = CourseContainerViewModel( + interactor: interactor, + authInteractor: authInteractor, + router: router, + analytics: analytics, + config: config, + connectivity: connectivity, + manager: downloadManager, + isActive: true, + courseStart: Date(), + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil + ) + viewModel.courseStructure = courseStructure + + let exp = expectation(description: "DispatchQueue.main.async Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + + XCTAssertEqual(viewModel.downloadState[sequential.id], .finished) + } + + func testSetDownloadsStatesPartiallyFinished() { + let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() + let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() + let config = ConfigMock() + let connectivity = ConnectivityProtocolMock() + let downloadManager = DownloadManagerProtocolMock() + + let block1 = CourseBlock( + blockId: "block:1", + id: "1", + courseId: "123", + topicId: "", + graded: false, + completion: 0, + type: .video, + displayName: "", + studentUrl: "", + videoUrl: "https://example.com/file.mp4", + youTubeUrl: nil + ) + let block2 = CourseBlock( + blockId: "block:2", + id: "2", + courseId: "123", + topicId: "", + graded: false, + completion: 0, + type: .video, + displayName: "", + studentUrl: "", + videoUrl: "https://example.com/file2.mp4", + youTubeUrl: nil + ) + let vertical = CourseVertical( + blockId: "block:vertical1", + id: "vertical1", + courseId: "123", + displayName: "", + type: .vertical, + completion: 0, + childs: [block1, block2] + ) + let sequential = CourseSequential( + blockId: "block:sequential1", + id: "sequential1", + displayName: "", + type: .chapter, + completion: 0, + childs: [vertical] + ) + let chapter = CourseChapter( + blockId: "", + id: "", + displayName: "", + type: .chapter, + childs: [sequential] + ) + + let childs = [chapter] + + let courseStructure = CourseStructure( + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "", + topicID: nil, + childs: childs, + media: DataLayer.CourseMedia(image: DataLayer.Image( + raw: "", + small: "", + large: "" + )), + certificate: nil + ) + + let downloadData = DownloadData( + id: "1", + courseId: "course123", + url: "https://example.com/file.mp4", + fileName: "file.mp4", + progress: 0, + resumeData: nil, + state: .finished, + type: .video + ) + + Given(connectivity, .isInternetAvaliable(getter: true)) + + Given(downloadManager, .publisher(willReturn: Just(1).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadsForCourse(.any, willReturn: [downloadData])) + + let viewModel = CourseContainerViewModel( + interactor: interactor, + authInteractor: authInteractor, + router: router, + analytics: analytics, + config: config, + connectivity: connectivity, + manager: downloadManager, + isActive: true, + courseStart: Date(), + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil + ) + viewModel.courseStructure = courseStructure + + let exp = expectation(description: "DispatchQueue.main.async Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + + XCTAssertEqual(viewModel.downloadState[sequential.id], .available) + } } diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index 7c5be54e4..25d731196 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -17,6 +17,7 @@ final class CourseUnitViewModelTests: XCTestCase { static let blocks = [ CourseBlock(blockId: "1", id: "1", + courseId: "123", topicId: "1", graded: false, completion: 0, @@ -27,6 +28,7 @@ final class CourseUnitViewModelTests: XCTestCase { youTubeUrl: nil), CourseBlock(blockId: "2", id: "2", + courseId: "123", topicId: "2", graded: false, completion: 0, @@ -37,6 +39,7 @@ final class CourseUnitViewModelTests: XCTestCase { youTubeUrl: nil), CourseBlock(blockId: "3", id: "3", + courseId: "123", topicId: "3", graded: false, completion: 0, @@ -47,6 +50,7 @@ final class CourseUnitViewModelTests: XCTestCase { youTubeUrl: nil), CourseBlock(blockId: "4", id: "4", + courseId: "123", topicId: "4", graded: false, completion: 0, @@ -70,7 +74,9 @@ final class CourseUnitViewModelTests: XCTestCase { type: .sequential, completion: 0, childs: [ - CourseVertical(blockId: "6", id: "6", + CourseVertical(blockId: "6", + id: "6", + courseId: "123", displayName: "6", type: .vertical, completion: 0, @@ -90,7 +96,9 @@ final class CourseUnitViewModelTests: XCTestCase { type: .sequential, completion: 0, childs: [ - CourseVertical(blockId: "4", id: "4", + CourseVertical(blockId: "4", + id: "4", + courseId: "123", displayName: "4", type: .vertical, completion: 0, @@ -106,19 +114,20 @@ final class CourseUnitViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let analytics = CourseAnalyticsMock() - let viewModel = CourseUnitViewModel(lessonID: "123", - courseID: "456", - id: "789", - courseName: "name", - chapters: chapters, - chapterIndex: 0, - sequentialIndex: 0, - verticalIndex: 0, - interactor: interactor, - router: router, - analytics: analytics, - connectivity: connectivity, - manager: DownloadManagerMock()) + let viewModel = CourseUnitViewModel( + lessonID: "123", + courseID: "456", + courseName: "name", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, + interactor: interactor, + router: router, + analytics: analytics, + connectivity: connectivity, + manager: DownloadManagerMock() + ) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willProduce: {_ in})) @@ -133,19 +142,20 @@ final class CourseUnitViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let analytics = CourseAnalyticsMock() - let viewModel = CourseUnitViewModel(lessonID: "123", - courseID: "456", - id: "789", - courseName: "name", - chapters: chapters, - chapterIndex: 0, - sequentialIndex: 0, - verticalIndex: 0, - interactor: interactor, - router: router, - analytics: analytics, - connectivity: connectivity, - manager: DownloadManagerMock()) + let viewModel = CourseUnitViewModel( + lessonID: "123", + courseID: "456", + courseName: "name", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, + interactor: interactor, + router: router, + analytics: analytics, + connectivity: connectivity, + manager: DownloadManagerMock() + ) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, @@ -165,19 +175,20 @@ final class CourseUnitViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let analytics = CourseAnalyticsMock() - let viewModel = CourseUnitViewModel(lessonID: "123", - courseID: "456", - id: "789", - courseName: "name", - chapters: chapters, - chapterIndex: 0, - sequentialIndex: 0, - verticalIndex: 0, - interactor: interactor, - router: router, - analytics: analytics, - connectivity: connectivity, - manager: DownloadManagerMock()) + let viewModel = CourseUnitViewModel( + lessonID: "123", + courseID: "456", + courseName: "name", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, + interactor: interactor, + router: router, + analytics: analytics, + connectivity: connectivity, + manager: DownloadManagerMock() + ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -199,19 +210,20 @@ final class CourseUnitViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let analytics = CourseAnalyticsMock() - let viewModel = CourseUnitViewModel(lessonID: "123", - courseID: "456", - id: "789", - courseName: "name", - chapters: chapters, - chapterIndex: 0, - sequentialIndex: 0, - verticalIndex: 0, - interactor: interactor, - router: router, - analytics: analytics, - connectivity: connectivity, - manager: DownloadManagerMock()) + let viewModel = CourseUnitViewModel( + lessonID: "123", + courseID: "456", + courseName: "name", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, + interactor: interactor, + router: router, + analytics: analytics, + connectivity: connectivity, + manager: DownloadManagerMock() + ) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, @@ -232,19 +244,20 @@ final class CourseUnitViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let analytics = CourseAnalyticsMock() - let viewModel = CourseUnitViewModel(lessonID: "123", - courseID: "456", - id: "789", - courseName: "name", - chapters: chapters, - chapterIndex: 0, - sequentialIndex: 0, - verticalIndex: 0, - interactor: interactor, - router: router, - analytics: analytics, - connectivity: connectivity, - manager: DownloadManagerMock()) + let viewModel = CourseUnitViewModel( + lessonID: "123", + courseID: "456", + courseName: "name", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, + interactor: interactor, + router: router, + analytics: analytics, + connectivity: connectivity, + manager: DownloadManagerMock() + ) viewModel.loadIndex() diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index 7ddc40b05..274a77ecb 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -72,7 +72,10 @@ public struct DashboardView: View { } } .onTapGesture { - viewModel.dashboardCourseClicked(courseID: course.courseID, courseName: course.name) + viewModel.trackDashboardCourseClicked( + courseID: course.courseID, + courseName: course.name + ) router.showCourseScreens( courseID: course.courseID, isActive: course.isActive, diff --git a/Dashboard/Dashboard/Presentation/DashboardViewModel.swift b/Dashboard/Dashboard/Presentation/DashboardViewModel.swift index 18de4b976..16ddff151 100644 --- a/Dashboard/Dashboard/Presentation/DashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/DashboardViewModel.swift @@ -95,7 +95,7 @@ public class DashboardViewModel: ObservableObject { } } - func dashboardCourseClicked(courseID: String, courseName: String) { + func trackDashboardCourseClicked(courseID: String, courseName: String) { analytics.dashboardCourseClicked(courseID: courseID, courseName: courseName) } } diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 551f38c47..b703eace7 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1413,3 +1413,400 @@ open class DashboardInteractorProtocolMock: DashboardInteractorProtocol, Mock { } } +// MARK: - DownloadManagerProtocol + +open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { + addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadData] + do { + __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + } + return __value + } + + open func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { + addInvocation(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) as? (String, [CourseBlock]) -> Void + perform?(`courseId`, `blocks`) + do { + _ = try methodReturnValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func pauseDownloading() { + addInvocation(.m_pauseDownloading) + let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void + perform?() + } + + open func deleteFile(blocks: [CourseBlock]) { + addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + } + + open func fileUrl(for blockId: String) -> URL? { + addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: URL? = nil + do { + __value = try methodReturnValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + + fileprivate enum MethodType { + case m_publisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_getDownloadsForCourse__courseId(Parameter) + case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) + case m_resumeDownloading + case m_pauseDownloading + case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_fileUrl__for_blockId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + + case (.m_cancelDownloading__courseId_courseIdblocks_blocks(let lhsCourseid, let lhsBlocks), .m_cancelDownloading__courseId_courseIdblocks_blocks(let rhsCourseid, let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + + case (.m_pauseDownloading, .m_pauseDownloading): return .match + + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_publisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue + case .m_resumeDownloading: return 0 + case .m_pauseDownloading: return 0 + case let .m_deleteFile__blocks_blocks(p0): return p0.intValue + case let .m_fileUrl__for_blockId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .m_pauseDownloading: return ".pauseDownloading()" + case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { + return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { + return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { + let willReturn: [[DownloadData]] = [] + let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadData]).self) + willProduce(stubber) + return given + } + public static func fileUrl(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [URL?] = [] + let given: Given = { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (URL?).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func resumeDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func resumeDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} + public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } + public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_pauseDownloading, performs: perform) + } + public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) + } + public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index c0c6c1793..034c7c761 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1490,3 +1490,400 @@ open class DiscoveryInteractorProtocolMock: DiscoveryInteractorProtocol, Mock { } } +// MARK: - DownloadManagerProtocol + +open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { + addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadData] + do { + __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + } + return __value + } + + open func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { + addInvocation(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) as? (String, [CourseBlock]) -> Void + perform?(`courseId`, `blocks`) + do { + _ = try methodReturnValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func pauseDownloading() { + addInvocation(.m_pauseDownloading) + let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void + perform?() + } + + open func deleteFile(blocks: [CourseBlock]) { + addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + } + + open func fileUrl(for blockId: String) -> URL? { + addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: URL? = nil + do { + __value = try methodReturnValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + + fileprivate enum MethodType { + case m_publisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_getDownloadsForCourse__courseId(Parameter) + case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) + case m_resumeDownloading + case m_pauseDownloading + case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_fileUrl__for_blockId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + + case (.m_cancelDownloading__courseId_courseIdblocks_blocks(let lhsCourseid, let lhsBlocks), .m_cancelDownloading__courseId_courseIdblocks_blocks(let rhsCourseid, let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + + case (.m_pauseDownloading, .m_pauseDownloading): return .match + + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_publisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue + case .m_resumeDownloading: return 0 + case .m_pauseDownloading: return 0 + case let .m_deleteFile__blocks_blocks(p0): return p0.intValue + case let .m_fileUrl__for_blockId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .m_pauseDownloading: return ".pauseDownloading()" + case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { + return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { + return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { + let willReturn: [[DownloadData]] = [] + let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadData]).self) + willProduce(stubber) + return given + } + public static func fileUrl(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [URL?] = [] + let given: Given = { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (URL?).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func resumeDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func resumeDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} + public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } + public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_pauseDownloading, performs: perform) + } + public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) + } + public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index c10bb0e7b..a2bc621ec 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -427,4 +427,5 @@ public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { return stringJSON } } +// swiftlint:enable all #endif diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 7533d76d0..6335fb469 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -2411,3 +2411,400 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { } } +// MARK: - DownloadManagerProtocol + +open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { + addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadData] + do { + __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + } + return __value + } + + open func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { + addInvocation(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) as? (String, [CourseBlock]) -> Void + perform?(`courseId`, `blocks`) + do { + _ = try methodReturnValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func pauseDownloading() { + addInvocation(.m_pauseDownloading) + let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void + perform?() + } + + open func deleteFile(blocks: [CourseBlock]) { + addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + } + + open func fileUrl(for blockId: String) -> URL? { + addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: URL? = nil + do { + __value = try methodReturnValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + + fileprivate enum MethodType { + case m_publisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_getDownloadsForCourse__courseId(Parameter) + case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) + case m_resumeDownloading + case m_pauseDownloading + case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_fileUrl__for_blockId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + + case (.m_cancelDownloading__courseId_courseIdblocks_blocks(let lhsCourseid, let lhsBlocks), .m_cancelDownloading__courseId_courseIdblocks_blocks(let rhsCourseid, let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + + case (.m_pauseDownloading, .m_pauseDownloading): return .match + + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_publisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue + case .m_resumeDownloading: return 0 + case .m_pauseDownloading: return 0 + case let .m_deleteFile__blocks_blocks(p0): return p0.intValue + case let .m_fileUrl__for_blockId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .m_pauseDownloading: return ".pauseDownloading()" + case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { + return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { + return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { + let willReturn: [[DownloadData]] = [] + let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadData]).self) + willProduce(stubber) + return given + } + public static func fileUrl(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [URL?] = [] + let given: Given = { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (URL?).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func resumeDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func resumeDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} + public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } + public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_pauseDownloading, performs: perform) + } + public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) + } + public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + diff --git a/Documentation/Documentation.md b/Documentation/Documentation.md new file mode 100644 index 000000000..81c24d4c5 --- /dev/null +++ b/Documentation/Documentation.md @@ -0,0 +1,235 @@ +# Documentation + +Content: +* [Architecture](#architecture) + + [Philosophy](#philosophy) + + [Concept](#concept) + + [What is Clean Architecture?](#what-is-clean-architecture) + + [Layers](#layers) + - [Presentation](#presentation) + - [Domain](#domain) + - [Data](#data) + + [Example](#example) + + [FAQ](#faq) + - [How does Clean Architecture improve software development?](#how-does-clean-architecture-improve-software-development) + - [Can Clean Architecture be used with any programming language?](#can-clean-architecture-be-used-with-any-programming-language) + - [Is Clean Architecture applicable only for large-scale applications?](#is-clean-architecture-applicable-only-for-large-scale-applications) + - [How is data validation handled in Clean Architecture?](#how-is-data-validation-handled-in-clean-architecture) + - [How are errors handled in Clean Architecture?](#how-are-errors-handled-in-clean-architecture) + - [What are Interactors in Clean Architecture?](#what-are-interactors-in-clean-architecture) + - [How do you deal with performance in Clean Architecture?](#how-do-you-deal-with-performance-in-clean-architecture) + - [Is Clean Architecture the same as Hexagonal Architecture?](#is-clean-architecture-the-same-as-hexagonal-architecture) + + [Useful links](#useful-links) +* [Project structure and Modularity](#project-structure-and-modularity) + - [Main rule](#main-rule) + - [Structure](#structure) + - [Responsibility](#responsibility) + - [Benefits](#benefits) + - [How to create a module for your new feature?](#how-to-create-a-module-for-your-new-feature) +* [Tech Stack](#tech-stack) + +# Architecture + +## Philosophy +Remember ☝️ + +Architecture is a crucial part of developing software, but it's not a dogma. + +Architecture is a set of recommendations to save you from trivial mistakes and make life easier, but if for your purpose you need to break it - you can do it. + +## Concept +This project follows the architectural concept of The Clean Architecture: + +[](Resources/CleanArchitecture.jpg) + +## What is Clean Architecture? +It is an architectural approach that emphasizes the separation of concerns, the use of dependency inversion, and the creation of testable and maintainable code. +It promotes flexibility by separating the application into different layers of abstraction. +This allows for easier maintenance and testing of individual components, as well as the ability to swap out underlying technologies without affecting the overall architecture. + +Architecture was popularized by Robert C. Martin ("Uncle Bob"). + +## Layers +Usually, implementation consists of the following three layers/packages/folders: Presentation, Domain, and Data. + +[](Resources/CALayers.png) + +### Presentation +Presentation is all about Displaying data, User input handling, Data validation, and Navigation. +If the Presentation Layer needs data or business logic, it calls Domain Layer. + +### Domain +The Domain layer, also known as the Business layer, is a key component that focuses on the core business logic, rules, and entities specific to the problem domain the application is designed to address. +The Domain layer is **independent of any technology or infrastructure concerns**, such as databases, user interfaces, or external services. +This separation allows the business logic to be more easily understood, maintained, and tested, without being affected by changes in other layers or components of the application. + +### Data +The Data layer, or the Persistence layer, is a component of an application's architecture responsible for managing data storage, retrieval, and manipulation. +This layer serves as an abstraction between the application's core logic and the underlying data storage technology, such as databases, file systems, or external APIs. + +## Example +Examples can be found in most project modules, but let's take a look at the `Course` module. +For instance, here we can find the next structure: +`CourseOutlineView` and `CourseContainerViewModel` as a representation of the Presentation Layer. +`CourseInteractor` as a representation of the Domain Layer. +And `CourseRepository` as a representation of the Data Layer. + +The best way to determine **what a module does** is to open its Interactor. +Here you can find all the business cases covered by the module. + +But if you want to know **how it does it**, you must delve into its Presentation and Data layers. + +## FAQ + +### How does Clean Architecture improve software development? +Clean Architecture separates the concerns of software components, reduces coupling, and improves the independence of software systems. +This makes the system more flexible, maintainable, and testable. It also ensures that the software can survive changes in technology, user interface, and even some business rules. + +### Can Clean Architecture be used with any programming language? +Yes, Clean Architecture is not tied to any specific programming language. +It's a design philosophy that can be applied to any language as long as it supports the principles of object-oriented design. + +### Is Clean Architecture applicable only for large-scale applications? +No, Clean Architecture is not exclusively for large-scale applications. +It can be implemented in small projects as well, providing similar benefits of maintainability, scalability, and testability. +However, for very simple or small-scale applications, implementing Clean Architecture might be overkill and lead to unnecessary complexity. + +### How is data validation handled in Clean Architecture? +Data validation can be considered at various levels in Clean Architecture. Simple, technical validations like checking for null or validating data formats can be done in the outer layers. Business rule validations, on the other hand, should be handled by Entities or Use Cases in the inner layers. This helps to ensure that invalid data never reaches the core business logic. + +### How are errors handled in Clean Architecture? +Error handling in Clean Architecture typically depends on the type of error. +Domain-specific errors (those that relate to the core business rules) should be handled within the Entities or Use Cases. +Technical errors (like a database connection failing) should be handled at the outer layers, with the necessary information relayed back to the inner layers through predefined interfaces. + +### What are Interactors in Clean Architecture? +Interactors, also known as Use Cases, represent specific applications of the business rules. +They orchestrate the flow of data between Entities and the outer layers of the system. +Interactors contain the logic to fulfill each use case of the system and maintain a decoupled relationship with the Entities and outer layers via interfaces. + +### How do you deal with performance in Clean Architecture? +While Clean Architecture is not specifically designed to optimize performance, it does not inherently cause performance issues either. +If performance becomes a concern, then performance optimization strategies can be applied in the relevant layers or components. +However, it is recommended to keep these optimizations isolated from the business rules to preserve the separation of concerns. + +### Is Clean Architecture the same as Hexagonal Architecture? +While there are similarities between Clean Architecture and Hexagonal Architecture (also known as Ports and Adapters) in terms of their goal of separation of concerns and testability, they are not the same. +Both architectures advocate for isolating business logic from external concerns, but they use different metaphors and structures. +Clean Architecture tends to focus more on layers, while Hexagonal Architecture emphasizes the interaction points (ports) and their implementations (adapters). + +## Useful links +If you want to delve deeper into the topic of CA, you can find useful links below. + +Article: [Clean Architecture for mobile: To be, or not to be](https://medium.com/globant/clean-architecture-for-mobile-to-be-or-not-to-be-2ffc8d46e402) + +Video: [Robert C Martin - Clean Architecture and Design](https://www.youtube.com/watch?v=Nsjsiz2A9mg) + +Book: [Clean Architecture: A Craftsman's Guide to Software Structure and Design (Robert C. Martin Series)](https://www.amazon.com/Clean-Architecture-Craftsmans-Software-Structure/dp/0134494164) + +# Project structure and Modularity +Modular architecture in applications pertains to the practice of decomposing an app's elements into smaller, reusable, and interchangeable modules. +Each Feature in the application is represented by a specific module, so you will not have problems finding the right class or functionality. + +## Main rule +Lower modules don't know anything about the higher modules. +It helps avoid conflicts with circular dependencies. + +If you need to pass some logic from one independent module to another, it is not a good idea to link modules to each other, this will eventually lead to circular dependencies and the broken project. +The better way is an abstraction in the core, implementation in the module, and dependency injection. + +## Structure +[](Resources/Modules.jpg) + +## Responsibility +Each module is centered around a specific set of features or functionality. +1. App module - app lifecycle management, routing, dependency control. +2. Auth Module - anything related to the user authorization process. +3. Discovery Module - anything related to the course catalog and search. +4. Dashboard Module - anything related to the course dashboard. +5. Profile Module - anything related to a user profile and settings. +6. Course Module - anything related to course structure, certificates, and assessments. +7. Discussion Module - anything related to course discussions. +8. Core Module - a set of utilities and abstractions required by each module. + +## Benefits +Modular approach has several benefits such as: + +##### Improved code organization +By separating components into modules, developers can better understand and manage the structure of the codebase. +This organization facilitates code navigation and makes it easier to locate specific functionalities. + +##### Scalability +Since modules are self-contained, it's easier to add or remove features as the application grows. +This flexibility allows developers to build scalable apps that can evolve with changing requirements. + +##### Reusability +Modular architecture promotes the creation of reusable components, which can be easily shared across multiple projects or within different parts of the same project. +This reusability can lead to increased productivity and reduced development time. + +##### Faster build time +Modular architecture allows incremental builds, where only the modified modules are recompiled. +This approach can significantly reduce build times and improve the development workflow. + +## How to create a module for your new feature? +1. Go to File -> New -> Project... + +[](Resources/Create_module_step1.png) + +2. Select `Framework` from Templates and click the Next button. + +[](Resources/Create_module_step2.png) + +3. Change your module name, choose Team and click the Next button. + +[](Resources/Create_module_step3.png) + +4. Select your project folder and don't forget to add the new module to the OpenEdX project group. + +[](Resources/Create_module_step4.png) + +5. Add your new module to the Podfile. + +[](Resources/Create_module_step5.png) + +6. Go to the project settings of the new module and add `Configurations` as shown in the screenshot below. + +[](Resources/Create_module_step6.png) + +7. Go to your new module target and click `+` sign to add the `Core` module. + +[](Resources/Create_module_step7.png) + +Choose `Core.framework` from the list. + +[](Resources/Create_module_step7_2.png) + +Change Embed flag to `Do Not Embed`. +The Core module will be embedded via the App module. + +[](Resources/Create_module_step7_3.png) + +8. In the same way, add your new module to the OpenEdX target. + +[](Resources/Create_module_step8.png) + +9. Navigate to the project folder and run `pod install`. + +10. Optional step. Add `.gitignore`, `SwiftGen`, and a Test target to your module. + +# Tech Stack +The project tries to use only the required number of libraries and frameworks and avoid unnecessary dependencies. + +The following technologies are used in the project: +- Swift, the main programming language. +- Swift Concurrency, the main toolkit for managing async jobs. +- SwiftUI, the main toolkit for building UI. +- SwiftUIIntrospect, SwiftUI backward UIKit access. +- Alamofire, HTTP networking library. +- Kingfisher, images loading library. +- Swinject, dependency injection framework. +- CoreData, data persistence. +- Firebase, infrastructure. +- XCTest, unit testing framework. +- SwiftyMocky, framework for automatic mock generation. +- SwiftLint, a tool to enforce Swift style and conventions. +- SwiftGen, the Swift code generator for assets. diff --git a/Documentation/Resources/CALayers.png b/Documentation/Resources/CALayers.png new file mode 100644 index 000000000..c8da60ada Binary files /dev/null and b/Documentation/Resources/CALayers.png differ diff --git a/Documentation/Resources/CleanArchitecture.jpg b/Documentation/Resources/CleanArchitecture.jpg new file mode 100644 index 000000000..3cd44fb58 Binary files /dev/null and b/Documentation/Resources/CleanArchitecture.jpg differ diff --git a/Documentation/Resources/Create_module_step1.png b/Documentation/Resources/Create_module_step1.png new file mode 100644 index 000000000..b2dbcb179 Binary files /dev/null and b/Documentation/Resources/Create_module_step1.png differ diff --git a/Documentation/Resources/Create_module_step2.png b/Documentation/Resources/Create_module_step2.png new file mode 100644 index 000000000..57611cc9d Binary files /dev/null and b/Documentation/Resources/Create_module_step2.png differ diff --git a/Documentation/Resources/Create_module_step3.png b/Documentation/Resources/Create_module_step3.png new file mode 100644 index 000000000..401b9cd02 Binary files /dev/null and b/Documentation/Resources/Create_module_step3.png differ diff --git a/Documentation/Resources/Create_module_step4.png b/Documentation/Resources/Create_module_step4.png new file mode 100644 index 000000000..ae4206f13 Binary files /dev/null and b/Documentation/Resources/Create_module_step4.png differ diff --git a/Documentation/Resources/Create_module_step5.png b/Documentation/Resources/Create_module_step5.png new file mode 100644 index 000000000..890225937 Binary files /dev/null and b/Documentation/Resources/Create_module_step5.png differ diff --git a/Documentation/Resources/Create_module_step6.png b/Documentation/Resources/Create_module_step6.png new file mode 100644 index 000000000..971260c26 Binary files /dev/null and b/Documentation/Resources/Create_module_step6.png differ diff --git a/Documentation/Resources/Create_module_step7.png b/Documentation/Resources/Create_module_step7.png new file mode 100644 index 000000000..bf57d1e7a Binary files /dev/null and b/Documentation/Resources/Create_module_step7.png differ diff --git a/Documentation/Resources/Create_module_step7_2.png b/Documentation/Resources/Create_module_step7_2.png new file mode 100644 index 000000000..1ce86c734 Binary files /dev/null and b/Documentation/Resources/Create_module_step7_2.png differ diff --git a/Documentation/Resources/Create_module_step7_3.png b/Documentation/Resources/Create_module_step7_3.png new file mode 100644 index 000000000..511d75205 Binary files /dev/null and b/Documentation/Resources/Create_module_step7_3.png differ diff --git a/Documentation/Resources/Create_module_step8.png b/Documentation/Resources/Create_module_step8.png new file mode 100644 index 000000000..6ab64efe1 Binary files /dev/null and b/Documentation/Resources/Create_module_step8.png differ diff --git a/Documentation/Resources/Modules.jpg b/Documentation/Resources/Modules.jpg new file mode 100644 index 000000000..102fe495f Binary files /dev/null and b/Documentation/Resources/Modules.jpg differ diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index a4b8fa1b9..4e3d37103 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -244,11 +244,10 @@ class ScreenAssembly: Assembly { container.register( CourseUnitViewModel.self - ) { r, blockId, courseId, id, courseName, chapters, chapterIndex, sequentialIndex, verticalIndex in + ) { r, blockId, courseId, courseName, chapters, chapterIndex, sequentialIndex, verticalIndex in CourseUnitViewModel( lessonID: blockId, courseID: courseId, - id: id, courseName: courseName, chapters: chapters, chapterIndex: chapterIndex, diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 03f48dfee..0e7e332cb 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -159,7 +159,6 @@ public class Router: AuthorizationRouter, } public func showCourseVerticalView( - id: String, courseID: String, courseName: String, title: String, @@ -174,7 +173,12 @@ public class Router: AuthorizationRouter, sequentialIndex )! - let view = CourseVerticalView(title: title, courseName: courseName, courseID: courseID, id: id, viewModel: viewModel) + let view = CourseVerticalView( + title: title, + courseName: courseName, + courseID: courseID, + viewModel: viewModel + ) let controller = SwiftUIHostController(view: view) navigationController.pushViewController(controller, animated: true) } @@ -224,7 +228,6 @@ public class Router: AuthorizationRouter, public func showCourseUnit( courseName: String, - id: String, blockId: String, courseID: String, sectionName: String, @@ -237,7 +240,6 @@ public class Router: AuthorizationRouter, CourseUnitViewModel.self, arguments: blockId, courseID, - id, courseName, chapters, chapterIndex, @@ -250,7 +252,6 @@ public class Router: AuthorizationRouter, } public func replaceCourseUnit( - id: String, courseName: String, blockId: String, courseID: String, @@ -272,18 +273,14 @@ public class Router: AuthorizationRouter, title: chapters[chapterIndex].childs[sequentialIndex].displayName, courseName: courseName, courseID: courseID, - id: id, viewModel: vmVertical ) let controllerVertical = SwiftUIHostController(view: viewVertical) - let verticals = chapters[chapterIndex].childs[sequentialIndex].childs - let viewModel = Container.shared.resolve( CourseUnitViewModel.self, arguments: blockId, courseID, - id, courseName, chapters, chapterIndex, diff --git a/Profile/Profile/Domain/ProfileInteractor.swift b/Profile/Profile/Domain/ProfileInteractor.swift index 525cc8f80..a29d04ad2 100644 --- a/Profile/Profile/Domain/ProfileInteractor.swift +++ b/Profile/Profile/Domain/ProfileInteractor.swift @@ -15,7 +15,7 @@ public protocol ProfileInteractorProtocol { func getMyProfileOffline() throws -> UserProfile func logOut() async throws func getSpokenLanguages() -> [PickerFields.Option] - func getCountries() -> [PickerFields.Option] + func getCountries() -> [PickerFields.Option] func uploadProfilePicture(pictureData: Data) async throws func deleteProfilePicture() async throws -> Bool func updateUserProfile(parameters: [String: Any]) async throws -> UserProfile @@ -48,7 +48,7 @@ public class ProfileInteractor: ProfileInteractorProtocol { return repository.getSpokenLanguages() } - public func getCountries() -> [PickerFields.Option] { + public func getCountries() -> [PickerFields.Option] { return repository.getCountries() } diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 8d71c7ff2..5c111a812 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1002,6 +1002,403 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - DownloadManagerProtocol + +open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { + addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadData] + do { + __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + } + return __value + } + + open func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { + addInvocation(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) as? (String, [CourseBlock]) -> Void + perform?(`courseId`, `blocks`) + do { + _ = try methodReturnValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func pauseDownloading() { + addInvocation(.m_pauseDownloading) + let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void + perform?() + } + + open func deleteFile(blocks: [CourseBlock]) { + addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + } + + open func fileUrl(for blockId: String) -> URL? { + addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: URL? = nil + do { + __value = try methodReturnValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + + fileprivate enum MethodType { + case m_publisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_getDownloadsForCourse__courseId(Parameter) + case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) + case m_resumeDownloading + case m_pauseDownloading + case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_fileUrl__for_blockId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + + case (.m_cancelDownloading__courseId_courseIdblocks_blocks(let lhsCourseid, let lhsBlocks), .m_cancelDownloading__courseId_courseIdblocks_blocks(let rhsCourseid, let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + + case (.m_pauseDownloading, .m_pauseDownloading): return .match + + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_publisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue + case .m_resumeDownloading: return 0 + case .m_pauseDownloading: return 0 + case let .m_deleteFile__blocks_blocks(p0): return p0.intValue + case let .m_fileUrl__for_blockId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .m_pauseDownloading: return ".pauseDownloading()" + case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { + return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { + return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { + let willReturn: [[DownloadData]] = [] + let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadData]).self) + willProduce(stubber) + return given + } + public static func fileUrl(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [URL?] = [] + let given: Given = { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (URL?).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func resumeDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func resumeDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} + public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } + public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_pauseDownloading, performs: perform) + } + public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) + } + public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - ProfileAnalytics open class ProfileAnalyticsMock: ProfileAnalytics, Mock { diff --git a/README.md b/README.md index 9a9afef9b..2495c8fae 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon Gang. +[Documentation](Documentation/Documentation.md) + ## Building 1. Check out the source code: