diff --git a/Sources/MockoloFramework/Models/ActorModel.swift b/Sources/MockoloFramework/Models/ActorModel.swift new file mode 100644 index 00000000..b4c30445 --- /dev/null +++ b/Sources/MockoloFramework/Models/ActorModel.swift @@ -0,0 +1,52 @@ +// +// ActorModel.swift +// MockoloFramework +// +// Created by treastrain on 2023/03/04. +// + +import Foundation + +final class ActorModel: Model { + var name: String + var offset: Int64 + var type: Type + let attribute: String + let accessLevel: String + let identifier: String + let declType: DeclType + let entities: [(String, Model)] + let initParamCandidates: [Model] + let declaredInits: [MethodModel] + let metadata: AnnotationMetadata? + + var modelType: ModelType { + return .actor + } + + init(identifier: String, + acl: String, + declType: DeclType, + attributes: [String], + offset: Int64, + metadata: AnnotationMetadata?, + initParamCandidates: [Model], + declaredInits: [MethodModel], + entities: [(String, Model)]) { + self.identifier = identifier + self.name = identifier + "Mock" + self.type = Type(.actor) + self.declType = declType + self.entities = entities + self.declaredInits = declaredInits + self.initParamCandidates = initParamCandidates + self.metadata = metadata + self.offset = offset + self.attribute = Set(attributes.filter {$0.contains(String.available)}).joined(separator: " ") + self.accessLevel = acl + } + + func render(with identifier: String, encloser: String, useTemplateFunc: Bool, useMockObservable: Bool, allowSetCallCount: Bool, mockFinal: Bool, enableFuncArgsHistory: Bool, disableCombineDefaultValues: Bool) -> String? { + return applyActorTemplate(name: name, identifier: self.identifier, accessLevel: accessLevel, attribute: attribute, declType: declType, metadata: metadata, useTemplateFunc: useTemplateFunc, useMockObservable: useMockObservable, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, disableCombineDefaultValues: disableCombineDefaultValues, initParamCandidates: initParamCandidates, declaredInits: declaredInits, entities: entities) + } +} diff --git a/Sources/MockoloFramework/Models/Model.swift b/Sources/MockoloFramework/Models/Model.swift index 2743c8e0..ef22faff 100644 --- a/Sources/MockoloFramework/Models/Model.swift +++ b/Sources/MockoloFramework/Models/Model.swift @@ -17,7 +17,7 @@ import Foundation public enum ModelType { - case variable, method, typeAlias, parameter, macro, `class` + case variable, method, typeAlias, parameter, macro, `class`, `actor` } /// Represents a model for an entity such as var, func, class, etc. diff --git a/Sources/MockoloFramework/Models/ParsedEntity.swift b/Sources/MockoloFramework/Models/ParsedEntity.swift index 6f5e525a..5afe1319 100644 --- a/Sources/MockoloFramework/Models/ParsedEntity.swift +++ b/Sources/MockoloFramework/Models/ParsedEntity.swift @@ -59,15 +59,27 @@ struct ResolvedEntity { func model() -> Model { - return ClassModel(identifier: key, - acl: entity.entityNode.accessLevel, - declType: entity.entityNode.declType, - attributes: attributes, - offset: entity.entityNode.offset, - metadata: entity.metadata, - initParamCandidates: initParamCandidates, - declaredInits: declaredInits, - entities: uniqueModels) + if entity.entityNode.inheritedTypes.contains(.actorProtocol) { + return ActorModel(identifier: key, + acl: entity.entityNode.accessLevel, + declType: entity.entityNode.declType, + attributes: attributes, + offset: entity.entityNode.offset, + metadata: entity.metadata, + initParamCandidates: initParamCandidates, + declaredInits: declaredInits, + entities: uniqueModels) + } else { + return ClassModel(identifier: key, + acl: entity.entityNode.accessLevel, + declType: entity.entityNode.declType, + attributes: attributes, + offset: entity.entityNode.offset, + metadata: entity.metadata, + initParamCandidates: initParamCandidates, + declaredInits: declaredInits, + entities: uniqueModels) + } } } diff --git a/Sources/MockoloFramework/Templates/ActorTemplate.swift b/Sources/MockoloFramework/Templates/ActorTemplate.swift new file mode 100644 index 00000000..9788666f --- /dev/null +++ b/Sources/MockoloFramework/Templates/ActorTemplate.swift @@ -0,0 +1,286 @@ +// +// ActorTemplate.swift +// MockoloFramework +// +// Created by treastrain on 2023/03/04. +// + +import Foundation + +extension ActorModel { + func applyActorTemplate(name: String, + identifier: String, + accessLevel: String, + attribute: String, + declType: DeclType, + metadata: AnnotationMetadata?, + useTemplateFunc: Bool, + useMockObservable: Bool, + allowSetCallCount: Bool, + mockFinal: Bool, + enableFuncArgsHistory: Bool, + disableCombineDefaultValues: Bool, + initParamCandidates: [Model], + declaredInits: [MethodModel], + entities: [(String, Model)]) -> String { + + processCombineAliases(entities: entities) + + let acl = accessLevel.isEmpty ? "" : accessLevel + " " + let typealiases = typealiasWhitelist(in: entities) + let renderedEntities = entities + .compactMap { (uniqueId: String, model: Model) -> (String, Int64)? in + if model.modelType == .typeAlias, let _ = typealiases?[model.name] { + // this case will be handlded by typealiasWhitelist look up later + return nil + } + if model.modelType == .variable, model.name == String.hasBlankInit { + return nil + } + if model.modelType == .method, model.isInitializer, !model.processed { + return nil + } + if let ret = model.render(with: uniqueId, encloser: name, useTemplateFunc: useTemplateFunc, useMockObservable: useMockObservable, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, disableCombineDefaultValues: disableCombineDefaultValues) { + return (ret, model.offset) + } + return nil + } + .sorted { (left: (String, Int64), right: (String, Int64)) -> Bool in + if left.1 == right.1 { + return left.0 < right.0 + } + return left.1 < right.1 + } + .map {$0.0} + .joined(separator: "\n") + + var typealiasTemplate = "" + let addAcl = declType == .protocolType ? acl : "" + if let typealiasWhitelist = typealiases { + typealiasTemplate = typealiasWhitelist.map { (arg: (key: String, value: [String])) -> String in + let joinedType = arg.value.sorted().joined(separator: " & ") + return "\(1.tab)\(addAcl)\(String.typealias) \(arg.key) = \(joinedType)" + }.joined(separator: "\n") + } + + var moduleDot = "" + if let moduleName = metadata?.module, !moduleName.isEmpty { + moduleDot = moduleName + "." + } + + let extraInits = extraInitsIfNeeded(initParamCandidates: initParamCandidates, declaredInits: declaredInits, acl: acl, declType: declType, overrides: metadata?.varTypes) + + var body = "" + if !typealiasTemplate.isEmpty { + body += "\(typealiasTemplate)\n" + } + if !extraInits.isEmpty { + body += "\(extraInits)\n" + } + if !renderedEntities.isEmpty { + body += "\(renderedEntities)" + } + + let finalStr = mockFinal ? "\(String.final) " : "" + let template = """ + \(attribute) + \(acl)\(finalStr)actor \(name): \(moduleDot)\(identifier) { + \(body) + } + """ + + return template + } + + private func extraInitsIfNeeded(initParamCandidates: [Model], + declaredInits: [MethodModel], + acl: String, + declType: DeclType, + overrides: [String: String]?) -> String { + + let declaredInitParamsPerInit = declaredInits.map { $0.params } + + var needParamedInit = false + var needBlankInit = false + + if declaredInits.isEmpty, initParamCandidates.isEmpty { + needBlankInit = true + needParamedInit = false + } else { + if declType == .protocolType { + needParamedInit = !initParamCandidates.isEmpty + needBlankInit = true + + let buffer = initParamCandidates.sorted(path: \.fullName, fallback: \.name) + for paramList in declaredInitParamsPerInit { + if paramList.isEmpty { + needBlankInit = false + } else { + let list = paramList.sorted(path: \.fullName, fallback: \.name) + if list.count > 0, list.count == buffer.count { + let dups = zip(list, buffer).filter {$0.0.fullName == $0.1.fullName} + if !dups.isEmpty { + needParamedInit = false + } + } + } + } + } + } + + var initTemplate = "" + if needParamedInit { + var paramsAssign = "" + let params = initParamCandidates + .map { (element: Model) -> String in + if let val = element.type.defaultVal(with: overrides, overrideKey: element.name, isInitParam: true) { + return "\(element.name): \(element.type.typeName) = \(val)" + } + var prefix = "" + if element.type.hasClosure { + if !element.type.isOptional { + prefix = String.escaping + " " + } + } + return "\(element.name): \(prefix)\(element.type.typeName)" + } + .joined(separator: ", ") + + + paramsAssign = initParamCandidates.map { p in + return "\(2.tab)self.\(p.underlyingName) = \(p.name.safeName)" + + }.joined(separator: "\n") + + initTemplate = """ + \(1.tab)\(acl)init(\(params)) { + \(paramsAssign) + \(1.tab)} + """ + } + + let extraInitParamNames = initParamCandidates.map{$0.name} + let extraVarsToDecl = declaredInitParamsPerInit.flatMap{$0}.compactMap { (p: ParamModel) -> String? in + if !extraInitParamNames.contains(p.name) { + return p.asVarDecl + } + return nil + } + .joined(separator: "\n") + + let declaredInitStr = declaredInits.compactMap { (m: MethodModel) -> String? in + if case let .initKind(required, override) = m.kind, !m.processed { + let modifier = required ? "\(String.required) " : (override ? "\(String.override) " : "") + let mAcl = m.accessLevel.isEmpty ? "" : "\(m.accessLevel) " + let genericTypeDeclsStr = m.genericTypeParams.compactMap {$0.render(with: "", encloser: "")}.joined(separator: ", ") + let genericTypesStr = genericTypeDeclsStr.isEmpty ? "" : "<\(genericTypeDeclsStr)>" + let paramDeclsStr = m.params.compactMap{$0.render(with: "", encloser: "")}.joined(separator: ", ") + + if override { + let paramsList = m.params.map { param in + return "\(param.name): \(param.name.safeName)" + }.joined(separator: ", ") + + return """ + \(1.tab)\(modifier)\(mAcl)init\(genericTypesStr)(\(paramDeclsStr)) { + \(2.tab)super.init(\(paramsList)) + \(1.tab)} + """ + } else { + let paramsAssign = m.params.map { param in + let underVars = initParamCandidates.compactMap { return $0.name.safeName == param.name.safeName ? $0.underlyingName : nil} + if let underVar = underVars.first { + return "\(2.tab)self.\(underVar) = \(param.name.safeName)" + } else { + return "\(2.tab)self.\(param.underlyingName) = \(param.name.safeName)" + } + }.joined(separator: "\n") + + return """ + \(1.tab)\(modifier)\(mAcl)init\(genericTypesStr)(\(paramDeclsStr)) { + \(paramsAssign) + \(1.tab)} + """ + } + } + return nil + }.sorted().joined(separator: "\n") + + var template = "" + + if !extraVarsToDecl.isEmpty { + template += "\(1.tab)\(extraVarsToDecl)\n" + } + + if needBlankInit { + // In case of protocol mocking, we want to provide a blank init (if not present already) for convenience, + // where instance vars do not have to be set in init since they all have get/set (see VariableTemplate). + let blankInit = "\(acl)init() { }" + template += "\(1.tab)\(blankInit)\n" + } + + if !initTemplate.isEmpty { + template += "\(initTemplate)\n" + } + + if !declaredInitStr.isEmpty { + template += "\(declaredInitStr)\n" + } + + return template + } + + + /// Returns a map of typealiases with conflicting types to be whitelisted + /// @param models Potentially contains typealias models + /// @returns A map of typealiases with multiple possible types + func typealiasWhitelist(`in` models: [(String, Model)]) -> [String: [String]]? { + let typealiasModels = models.filter{$0.1.modelType == .typeAlias} + var aliasMap = [String: [String]]() + typealiasModels.forEach { (arg: (key: String, value: Model)) in + + let alias = arg.value + if aliasMap[alias.name] == nil { + aliasMap[alias.name] = [alias.type.typeName] + } else { + if let val = aliasMap[alias.name], !val.contains(alias.type.typeName) { + aliasMap[alias.name]?.append(alias.type.typeName) + } + } + } + let aliasDupes = aliasMap.filter {$0.value.count > 1} + return aliasDupes.isEmpty ? nil : aliasDupes + } + + // Finds all combine properties that are attempting to use a property wrapper alias + // and locates the matching property within the actor, if one exists. + // + private func processCombineAliases(entities: [(String, Model)]) { + var variableModels = [VariableModel]() + var nameToVariableModels = [String: VariableModel]() + + for entity in entities { + guard let variableModel = entity.1 as? VariableModel else { + continue + } + variableModels.append(variableModel) + nameToVariableModels[variableModel.name] = variableModel + } + + for variableModel in variableModels { + guard case .property(let wrapper, let name) = variableModel.combineType else { + continue + } + + // If a variable member in this entity already exists, link the two together. + // Otherwise, the user's setup is incorrect and we will fallback to using a PassthroughSubject. + // + if let matchingAliasModel = nameToVariableModels[name] { + variableModel.wrapperAliasModel = matchingAliasModel + matchingAliasModel.propertyWrapper = wrapper + } else { + variableModel.combineType = .passthroughSubject + } + } + } +} diff --git a/Sources/MockoloFramework/Utils/StringExtensions.swift b/Sources/MockoloFramework/Utils/StringExtensions.swift index 9fee0222..27edc792 100644 --- a/Sources/MockoloFramework/Utils/StringExtensions.swift +++ b/Sources/MockoloFramework/Utils/StringExtensions.swift @@ -38,6 +38,7 @@ extension String { static let `static` = "static" static let importSpace = "import " static public let `class` = "class" + static public let `actor` = "actor" static public let `final` = "final" static let override = "override" static let privateSet = "private(set)" @@ -47,6 +48,7 @@ extension String { static let any = "Any" static let some = "some" static let anyObject = "AnyObject" + static let actorProtocol = "Actor" static let fatalError = "fatalError" static let available = "available" static let `public` = "public" diff --git a/Tests/TestActorMocking/FixtureMockableActor.swift b/Tests/TestActorMocking/FixtureMockableActor.swift new file mode 100644 index 00000000..894ce457 --- /dev/null +++ b/Tests/TestActorMocking/FixtureMockableActor.swift @@ -0,0 +1,34 @@ +import MockoloFramework + +let actorProtocol = +""" +/// \(String.mockAnnotation) +protocol Foo: Actor { + func foo(arg: String) async -> Result + var bar: Int { get } +} +""" + +let actorProtocolMock = +""" +actor FooMock: Foo { + init() { } + init(bar: Int = 0) { + self.bar = bar + } + + + private(set) var fooCallCount = 0 + var fooHandler: ((String) async -> (Result))? + func foo(arg: String) async -> Result { + fooCallCount += 1 + if let fooHandler = fooHandler { + return await fooHandler(arg) + } + fatalError("fooHandler returns can't have a default value thus its handler must be set") + } + + private(set) var barSetCallCount = 0 + var bar: Int = 0 { didSet { barSetCallCount += 1 } } +} +""" diff --git a/Tests/TestActorMocking/MockActorTests.swift b/Tests/TestActorMocking/MockActorTests.swift new file mode 100644 index 00000000..cf5d1143 --- /dev/null +++ b/Tests/TestActorMocking/MockActorTests.swift @@ -0,0 +1,11 @@ +import Foundation + +#if compiler(>=5.5.2) && canImport(_Concurrency) +final class MockActorTests: MockoloTestCase { + + func testActorProtocol() { + verify(srcContent: actorProtocol, + dstContent: actorProtocolMock) + } +} +#endif