diff --git a/Package.swift b/Package.swift index 19257f7..8d4127f 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "OversizeArchitecture", - platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)], + platforms: [.macOS(.v14), .iOS(.v16), .tvOS(.v16), .watchOS(.v10)], products: [ .library( name: "OversizeArchitecture", diff --git a/README.md b/README.md new file mode 100644 index 0000000..8994747 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# OversizeArchitecture + +Swift architectural package with macros for automatic MVVM code generation. + +## Macros + +### @ViewModel + +Automatically generates ViewModelProtocol components: + +```swift +@ViewModel(module: ProductList.self) +public actor ProductListViewModel: ViewModelProtocol { + func onRefresh() { + // refresh logic + } + + func onFilterSelected(filter: FilterType) { + // filter logic + } +} +``` + +Generates: +- Typealiases for Input, Output, ViewState +- Properties: state, input, output +- Initializer +- handleAction method +- Action enum with cases based on methods with "on" prefix + +### @View + +Generates View components: + +```swift +@View(module: ProductList.self) +public struct ProductListView: ViewProtocol { + public var body: some View { + VStack { + Text(viewState.title) + Button("Refresh") { + reducer(.onRefresh) + } + } + } +} +``` + +Generates: +- viewState property +- reducer property +- Initializer + +### @Module + +Generates Module typealiases: + +```swift +@Module +public enum ProductList: ModuleProtocol { +} +``` + +Generates: +- typealias Input = ProductListInput +- typealias Output = ProductListOutput +- typealias ViewState = ProductListViewState +- typealias ViewModel = ProductListViewModel +- typealias ViewScene = ProductListView + +## Module Structure + +Each module consists of: + +1. **Input** - input parameters +2. **Output** - callbacks +3. **ViewState** - UI state +4. **ViewModel** - business logic +5. **View** - UI component +6. **Module** - entry point + +## Complete Module Example + +```swift +// ProductListModule.swift +public struct ProductListInput: Sendable { + public let filterType: FilterType + // ... +} + +public struct ProductListOutput: Sendable { + public let onSelection: (Product) -> Void + // ... +} + +@Module +public enum ProductList: ModuleProtocol { +} + +// ProductListViewState.swift +@Observable +public final class ProductListViewState: ViewStateProtocol { + // UI state +} + +// ProductListViewModel.swift +@ViewModel(module: ProductList.self) +public actor ProductListViewModel: ViewModelProtocol { + func onRefresh() { } + func onFilterSelected(filter: FilterType) { } +} + +// ProductListView.swift +@View(module: ProductList.self) +public struct ProductListView: ViewProtocol { + public var body: some View { + // UI code + } +} +``` + +## Requirements + +- Swift 6.0+ +- iOS 17.0+ +- macOS 14.0+ \ No newline at end of file diff --git a/Sources/OversizeArchitecture/Callback.swift b/Sources/OversizeArchitecture/Callback.swift new file mode 100644 index 0000000..b5b0823 --- /dev/null +++ b/Sources/OversizeArchitecture/Callback.swift @@ -0,0 +1,28 @@ +// +// Copyright © 2025 Alexander Romanov +// Callback.swift, created on 17.09.2025 +// + +import Foundation + +public struct Callback: Hashable, Equatable { + public let identifier: String + public let handler: (Value) -> Void + + public init(_ identifier: String = UUID().uuidString, handler: @escaping (Value) -> Void) { + self.identifier = identifier + self.handler = handler + } + + public func callAsFunction(_ value: Value) { + handler(value) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + } + + public static func == (lhs: Callback, rhs: Callback) -> Bool { + lhs.identifier == rhs.identifier + } +} diff --git a/Sources/OversizeArchitecture/Macros.swift b/Sources/OversizeArchitecture/Macros.swift index 413cebe..5165691 100644 --- a/Sources/OversizeArchitecture/Macros.swift +++ b/Sources/OversizeArchitecture/Macros.swift @@ -4,5 +4,11 @@ // @attached(extension, names: named(Action)) -@attached(member, names: named(handleAction)) -public macro ViewModel() = #externalMacro(module: "OversizeArchitectureMacros", type: "ViewModelMacro") +@attached(member, names: named(handleAction), named(state), named(input), named(output), named(init)) +public macro ViewModel(module: Any.Type? = nil) = #externalMacro(module: "OversizeArchitectureMacros", type: "ViewModelMacro") + +@attached(member, names: named(viewState), named(reducer), named(init)) +public macro View(module: Any.Type) = #externalMacro(module: "OversizeArchitectureMacros", type: "ViewMacro") + +@attached(member, names: named(Input), named(Output), named(ViewState), named(ViewModel), named(ViewScene)) +public macro Module(prefix: String? = nil) = #externalMacro(module: "OversizeArchitectureMacros", type: "ModuleMacro") diff --git a/Sources/OversizeArchitecture/Protocols/ModuleProtocol.swift b/Sources/OversizeArchitecture/Protocols/ModuleProtocol.swift new file mode 100644 index 0000000..dcb9883 --- /dev/null +++ b/Sources/OversizeArchitecture/Protocols/ModuleProtocol.swift @@ -0,0 +1,28 @@ +// +// Copyright © 2025 Alexander Romanov +// ModuleProtocol.swift, created on 17.09.2025 +// + +import SwiftUI + +public protocol ModuleProtocol { + associatedtype Input + associatedtype Output + associatedtype ViewState: ViewStateProtocol where ViewState.Input == Input + associatedtype ViewScene: ViewProtocol where ViewScene.ViewState == ViewState, ViewScene.ViewModel == ViewModel + associatedtype ViewModel: ViewModelProtocol where ViewModel.Input == Input, ViewModel.Output == Output, ViewModel.ViewState == ViewState +} + +public extension ModuleProtocol where ViewScene: View { + @MainActor + static func build(input: Input? = nil, output: Output? = nil) -> some View { + let state = ViewState(input: input) + let viewModel = ViewModel( + state: state, + input: input, + output: output + ) + let reducer = Reducer(viewModel: viewModel) + return ViewScene(viewState: state, reducer: reducer) + } +} diff --git a/Sources/OversizeArchitecture/Protocols/ViewModelProtocol.swift b/Sources/OversizeArchitecture/Protocols/ViewModelProtocol.swift index 43d7a46..f29a811 100644 --- a/Sources/OversizeArchitecture/Protocols/ViewModelProtocol.swift +++ b/Sources/OversizeArchitecture/Protocols/ViewModelProtocol.swift @@ -4,11 +4,12 @@ // public protocol ViewModelProtocol: Sendable { + associatedtype Input: Sendable + associatedtype Output: Sendable associatedtype Action: Sendable associatedtype ViewState: ViewStateProtocol - @MainActor - init(state: ViewState) + init(state: ViewState, input: Input?, output: Output?) func handleAction(_ action: Action) async } diff --git a/Sources/OversizeArchitecture/Protocols/ViewProtocol.swift b/Sources/OversizeArchitecture/Protocols/ViewProtocol.swift index 19d8238..dbe0b61 100644 --- a/Sources/OversizeArchitecture/Protocols/ViewProtocol.swift +++ b/Sources/OversizeArchitecture/Protocols/ViewProtocol.swift @@ -9,6 +9,5 @@ public protocol ViewProtocol: View { associatedtype ViewState: ViewStateProtocol associatedtype ViewModel: ViewModelProtocol - @MainActor init(viewState: ViewState, reducer: Reducer) } diff --git a/Sources/OversizeArchitecture/Protocols/ViewStateProtocol.swift b/Sources/OversizeArchitecture/Protocols/ViewStateProtocol.swift index 88791dd..ca6829f 100644 --- a/Sources/OversizeArchitecture/Protocols/ViewStateProtocol.swift +++ b/Sources/OversizeArchitecture/Protocols/ViewStateProtocol.swift @@ -4,7 +4,11 @@ // @MainActor -public protocol ViewStateProtocol: Sendable {} +public protocol ViewStateProtocol: Sendable { + associatedtype Input + + init(input: Input?) +} public extension ViewStateProtocol { func update(_ handler: @Sendable @MainActor (Self) -> Void) async { diff --git a/Sources/OversizeArchitectureMacros/Macros/ModuleMacro.swift b/Sources/OversizeArchitectureMacros/Macros/ModuleMacro.swift new file mode 100644 index 0000000..cad5634 --- /dev/null +++ b/Sources/OversizeArchitectureMacros/Macros/ModuleMacro.swift @@ -0,0 +1,107 @@ +// +// Copyright © 2025 Alexander Romanov +// ModuleMacro.swift, created on 18.09.2025 +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct ModuleMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard declaration.as(EnumDeclSyntax.self) != nil else { + throw ModuleMacroError.onlyApplicableToEnum + } + + let prefix = extractPrefix(from: node) ?? extractPrefixFromName(declaration: declaration) + + let members = generateModuleTypealiases(prefix: prefix) + return members + } +} + +enum ModuleMacroError: Error, CustomStringConvertible { + case onlyApplicableToEnum + + var description: String { + switch self { + case .onlyApplicableToEnum: + "@Module can only be applied to enums" + } + } +} + +private extension ModuleMacro { + static func extractPrefix(from node: AttributeSyntax) -> String? { + guard let arguments = node.arguments?.as(LabeledExprListSyntax.self) else { + return nil + } + + for argument in arguments { + if argument.label?.text == "prefix" { + if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self) { + return stringLiteral.segments.first?.as(StringSegmentSyntax.self)?.content.text + } + } + } + return nil + } + + static func extractPrefixFromName(declaration: some DeclGroupSyntax) -> String { + guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { + return "Unknown" + } + + let fullName = enumDecl.name.text + // Remove "Module" suffix if present + if fullName.hasSuffix("Module") { + return String(fullName.dropLast(6)) + } + return fullName + } + + static func generateModuleTypealiases(prefix: String) -> [DeclSyntax] { + [ + DeclSyntax( + try! TypeAliasDeclSyntax( + """ + public typealias Input = \(raw: prefix)Input + """ + ) + ), + DeclSyntax( + try! TypeAliasDeclSyntax( + """ + public typealias Output = \(raw: prefix)Output + """ + ) + ), + DeclSyntax( + try! TypeAliasDeclSyntax( + """ + public typealias ViewState = \(raw: prefix)ViewState + """ + ) + ), + DeclSyntax( + try! TypeAliasDeclSyntax( + """ + public typealias ViewModel = \(raw: prefix)ViewModel + """ + ) + ), + DeclSyntax( + try! TypeAliasDeclSyntax( + """ + public typealias ViewScene = \(raw: prefix)View + """ + ) + ) + ] + } +} diff --git a/Sources/OversizeArchitectureMacros/Macros/ViewMacro.swift b/Sources/OversizeArchitectureMacros/Macros/ViewMacro.swift new file mode 100644 index 0000000..fcd2ed8 --- /dev/null +++ b/Sources/OversizeArchitectureMacros/Macros/ViewMacro.swift @@ -0,0 +1,98 @@ +// +// Copyright © 2025 Alexander Romanov +// ViewMacro.swift, created on 18.09.2025 +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct ViewMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard declaration.as(StructDeclSyntax.self) != nil else { + throw ViewMacroError.onlyApplicableToStruct + } + + let moduleType = extractModuleType(from: node) + + var members: [DeclSyntax] = [] + + if let module = moduleType { + let properties = generateViewProperties(module: module) + let initializer = generateViewInitializer(module: module) + members.append(contentsOf: properties) + members.append(DeclSyntax(initializer)) + } + + return members + } +} + +enum ViewMacroError: Error, CustomStringConvertible { + case onlyApplicableToStruct + + var description: String { + switch self { + case .onlyApplicableToStruct: + "@View can only be applied to structs" + } + } +} + +private extension ViewMacro { + static func extractModuleType(from node: AttributeSyntax) -> String? { + guard let arguments = node.arguments?.as(LabeledExprListSyntax.self) else { + return nil + } + + for argument in arguments { + if argument.label?.text == "module" { + if let memberAccessExpr = argument.expression.as(MemberAccessExprSyntax.self), + let baseType = memberAccessExpr.base?.as(DeclReferenceExprSyntax.self) + { + return baseType.baseName.text + } + if let declRef = argument.expression.as(DeclReferenceExprSyntax.self) { + return declRef.baseName.text + } + } + } + return nil + } + + static func generateViewProperties(module: String) -> [DeclSyntax] { + [ + DeclSyntax( + try! VariableDeclSyntax( + """ + @Bindable var viewState: \(raw: module).ViewState + """ + ) + ), + DeclSyntax( + try! VariableDeclSyntax( + """ + let reducer: Reducer<\(raw: module).ViewModel> + """ + ) + ) + ] + } + + static func generateViewInitializer(module: String) -> InitializerDeclSyntax { + try! InitializerDeclSyntax( + """ + @MainActor + public init(viewState: \(raw: module).ViewState, reducer: Reducer<\(raw: module).ViewModel>) { + self.viewState = viewState + self.reducer = reducer + } + """ + ) + } +} diff --git a/Sources/OversizeArchitectureMacros/Macros/ViewModelMacro.swift b/Sources/OversizeArchitectureMacros/Macros/ViewModelMacro.swift index 0bacdc5..4221f7f 100644 --- a/Sources/OversizeArchitectureMacros/Macros/ViewModelMacro.swift +++ b/Sources/OversizeArchitectureMacros/Macros/ViewModelMacro.swift @@ -60,10 +60,22 @@ public struct ViewModelMacro: ExtensionMacro, MemberMacro { throw ViewModelMacroError.onlyApplicableToClassOrActor } + let moduleType = extractModuleType(from: node) let onMethods = extractOnMethods(from: declGroup) let handleActionMethod = generateHandleActionMethod(from: onMethods) - return [DeclSyntax(handleActionMethod)] + var members: [DeclSyntax] = [] + + if let module = moduleType { + let properties = generateModuleProperties(module: module) + let initializer = generateInitializer(module: module) + members.append(contentsOf: properties) + members.append(DeclSyntax(initializer)) + } + + members.append(DeclSyntax(handleActionMethod)) + + return members } private static func extractOnMethods(from declaration: some DeclGroupSyntax) -> [FunctionDeclSyntax] { @@ -204,3 +216,65 @@ extension String { return prefix(1).lowercased() + dropFirst() } } + +private extension ViewModelMacro { + static func extractModuleType(from node: AttributeSyntax) -> String? { + guard let arguments = node.arguments?.as(LabeledExprListSyntax.self) else { + return nil + } + + for argument in arguments { + if argument.label?.text == "module" { + if let memberAccessExpr = argument.expression.as(MemberAccessExprSyntax.self), + let baseType = memberAccessExpr.base?.as(DeclReferenceExprSyntax.self) + { + return baseType.baseName.text + } + if let declRef = argument.expression.as(DeclReferenceExprSyntax.self) { + return declRef.baseName.text + } + } + } + return nil + } + + static func generateModuleProperties(module: String) -> [DeclSyntax] { + [ + DeclSyntax( + try! VariableDeclSyntax( + """ + @MainActor + public var state: \(raw: module).ViewState + """ + ) + ), + DeclSyntax( + try! VariableDeclSyntax( + """ + private let input: \(raw: module).Input? + """ + ) + ), + DeclSyntax( + try! VariableDeclSyntax( + """ + private let output: \(raw: module).Output? + """ + ) + ) + ] + } + + static func generateInitializer(module: String) -> InitializerDeclSyntax { + try! InitializerDeclSyntax( + """ + @MainActor + public init(state: \(raw: module).ViewState, input: \(raw: module).Input?, output: \(raw: module).Output?) { + self.state = state + self.input = input + self.output = output + } + """ + ) + } +} diff --git a/Sources/OversizeArchitectureMacros/Plugins.swift b/Sources/OversizeArchitectureMacros/Plugins.swift index dde73e7..117883a 100644 --- a/Sources/OversizeArchitectureMacros/Plugins.swift +++ b/Sources/OversizeArchitectureMacros/Plugins.swift @@ -5,5 +5,7 @@ import SwiftSyntaxMacros struct OversizeMacroPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ ViewModelMacro.self, + ViewMacro.self, + ModuleMacro.self ] } diff --git a/Tests/OversizeArchitectureTests/ArchitectureMacrosTests/ModuleMacroTests.swift b/Tests/OversizeArchitectureTests/ArchitectureMacrosTests/ModuleMacroTests.swift new file mode 100644 index 0000000..c24943d --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureMacrosTests/ModuleMacroTests.swift @@ -0,0 +1,142 @@ +// +// Copyright © 2025 Alexander Romanov +// ModuleMacroTests.swift, created on 18.09.2025 +// + +import Foundation +import OversizeArchitecture +import OversizeArchitectureMacros +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import Testing + +@Suite("Module Macro Tests") +struct ModuleMacroTests { + let testMacros: [String: Macro.Type] = [ + "ModuleMacro": ModuleMacro.self, + ] + + @Test("Module macro generates typealiases from name") + func moduleMacroGeneratesTypealiasesFromName() { + assertMacroExpansion( + """ + @ModuleMacro + public enum ProductEdit: ModuleProtocol { + } + """, + expandedSource: """ + public enum ProductEdit: ModuleProtocol { + public typealias Input = ProductEditInput + public typealias Output = ProductEditOutput + public typealias ViewState = ProductEditViewState + public typealias ViewModel = ProductEditViewModel + public typealias ViewScene = ProductEditView + } + """, + macros: testMacros + ) + } + + @Test("Module macro generates typealiases from explicit prefix") + func moduleMacroGeneratesTypealiasesFromExplicitPrefix() { + assertMacroExpansion( + """ + @ModuleMacro(prefix: "CustomName") + public enum SomeModule: ModuleProtocol { + } + """, + expandedSource: """ + public enum SomeModule: ModuleProtocol { + public typealias Input = CustomNameInput + public typealias Output = CustomNameOutput + public typealias ViewState = CustomNameViewState + public typealias ViewModel = CustomNameViewModel + public typealias ViewScene = CustomNameView + } + """, + macros: testMacros + ) + } + + @Test("Module macro works with ProductDetail") + func moduleMacroWorksWithProductDetail() { + assertMacroExpansion( + """ + @ModuleMacro + public enum ProductDetail: ModuleProtocol { + } + """, + expandedSource: """ + public enum ProductDetail: ModuleProtocol { + public typealias Input = ProductDetailInput + public typealias Output = ProductDetailOutput + public typealias ViewState = ProductDetailViewState + public typealias ViewModel = ProductDetailViewModel + public typealias ViewScene = ProductDetailView + } + """, + macros: testMacros + ) + } + + @Test("Module macro works with ProductList") + func moduleMacroWorksWithProductList() { + assertMacroExpansion( + """ + @ModuleMacro + public enum ProductList: ModuleProtocol { + } + """, + expandedSource: """ + public enum ProductList: ModuleProtocol { + public typealias Input = ProductListInput + public typealias Output = ProductListOutput + public typealias ViewState = ProductListViewState + public typealias ViewModel = ProductListViewModel + public typealias ViewScene = ProductListView + } + """, + macros: testMacros + ) + } + + @Test("Module macro only applies to enum") + func moduleMacroOnlyAppliesToEnum() { + assertMacroExpansion( + """ + @ModuleMacro + public struct ProductEdit: ModuleProtocol { + } + """, + expandedSource: """ + public struct ProductEdit: ModuleProtocol { + } + """, + diagnostics: [ + DiagnosticSpec(message: "@Module can only be applied to enums", line: 1, column: 1, severity: .error) + ], + macros: testMacros + ) + } + + @Test("Module macro handles name without Module suffix") + func moduleMacroHandlesNameWithoutModuleSuffix() { + assertMacroExpansion( + """ + @ModuleMacro + public enum UserProfile: ModuleProtocol { + } + """, + expandedSource: """ + public enum UserProfile: ModuleProtocol { + public typealias Input = UserProfileInput + public typealias Output = UserProfileOutput + public typealias ViewState = UserProfileViewState + public typealias ViewModel = UserProfileViewModel + public typealias ViewScene = UserProfileView + } + """, + macros: testMacros + ) + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureMacrosTests/ViewMacroTests.swift b/Tests/OversizeArchitectureTests/ArchitectureMacrosTests/ViewMacroTests.swift new file mode 100644 index 0000000..1e31e3b --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureMacrosTests/ViewMacroTests.swift @@ -0,0 +1,115 @@ +// +// Copyright © 2025 Alexander Romanov +// ViewMacroTests.swift, created on 18.09.2025 +// + +import Foundation +import OversizeArchitecture +import OversizeArchitectureMacros +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import Testing + +@Suite("View Macro Tests") +struct ViewMacroTests { + let testMacros: [String: Macro.Type] = [ + "ViewMacro": ViewMacro.self, + ] + + @Test("View macro generates properties and initializer") + func viewMacroGeneratesPropertiesAndInitializer() { + assertMacroExpansion( + """ + @ViewMacro(module: ProductDetail.self) + public struct ProductDetailView: ViewProtocol { + public var body: some View { + Text(viewState.name) + } + } + """, + expandedSource: """ + public struct ProductDetailView: ViewProtocol { + @Bindable var viewState: ProductDetail.ViewState + let reducer: Reducer + + @MainActor + public init(viewState: ProductDetail.ViewState, reducer: Reducer) { + self.viewState = viewState + self.reducer = reducer + } + + public var body: some View { + Text(viewState.name) + } + } + """, + macros: testMacros + ) + } + + @Test("View macro only applies to struct") + func viewMacroOnlyAppliesToStruct() { + assertMacroExpansion( + """ + @ViewMacro(module: ProductDetail.self) + public class ProductDetailView { + public var body: some View { + Text("Test") + } + } + """, + expandedSource: """ + public class ProductDetailView { + public var body: some View { + Text("Test") + } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@View can only be applied to structs", line: 1, column: 1, severity: .error) + ], + macros: testMacros + ) + } + + @Test("View macro with complex body") + func viewMacroWithComplexBody() { + assertMacroExpansion( + """ + @ViewMacro(module: ProductEdit.self) + public struct ProductEditView: ViewProtocol { + public var body: some View { + VStack { + Text(viewState.name) + Button("Save") { + reducer.callAsFunction(.onTapSave) + } + } + } + } + """, + expandedSource: """ + public struct ProductEditView: ViewProtocol { + @Bindable var viewState: ProductEdit.ViewState + let reducer: Reducer + + @MainActor + public init(viewState: ProductEdit.ViewState, reducer: Reducer) { + self.viewState = viewState + self.reducer = reducer + } + + public var body: some View { + VStack { + Text(viewState.name) + Button("Save") { + reducer.callAsFunction(.onTapSave) + } + } + } + } + """, + macros: testMacros + ) + } +} diff --git a/Tests/OversizeArchitectureTests/ViewModelMacroTests.swift b/Tests/OversizeArchitectureTests/ArchitectureMacrosTests/ViewModelMacroTests.swift similarity index 85% rename from Tests/OversizeArchitectureTests/ViewModelMacroTests.swift rename to Tests/OversizeArchitectureTests/ArchitectureMacrosTests/ViewModelMacroTests.swift index 40d571d..19f83d5 100644 --- a/Tests/OversizeArchitectureTests/ViewModelMacroTests.swift +++ b/Tests/OversizeArchitectureTests/ArchitectureMacrosTests/ViewModelMacroTests.swift @@ -1,20 +1,23 @@ // // Copyright © 2025 Alexander Romanov -// ViewModelMacroTests.swift, created on 12.09.2025 +// ViewModelMacroTests.swift, created on 18.09.2025 // +import Foundation import OversizeArchitecture import OversizeArchitectureMacros import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport -import XCTest +import Testing -final class ViewModelMacroTests: XCTestCase { +@Suite("ViewModel Macro Tests") +struct ViewModelMacroTests { let testMacros: [String: Macro.Type] = [ "ViewModelMacro": ViewModelMacro.self, ] - func testSimpleOnMethodsGenerateActions() throws { + @Test("Simple on methods generate actions") + func simpleOnMethodsGenerateActions() { assertMacroExpansion( """ @ViewModelMacro @@ -66,7 +69,8 @@ final class ViewModelMacroTests: XCTestCase { ) } - func testOnMethodsWithParametersGenerateActionsWithAssociatedValues() throws { + @Test("On methods with parameters generate actions with associated values") + func onMethodsWithParametersGenerateActionsWithAssociatedValues() { assertMacroExpansion( """ @ViewModelMacro @@ -123,7 +127,8 @@ final class ViewModelMacroTests: XCTestCase { ) } - func testPrivateOnMethodsAreIgnored() throws { + @Test("Private on methods are ignored") + func privateOnMethodsAreIgnored() { assertMacroExpansion( """ @ViewModelMacro @@ -172,7 +177,8 @@ final class ViewModelMacroTests: XCTestCase { ) } - func testNonOnMethodsAreIgnored() throws { + @Test("Non-on methods are ignored") + func nonOnMethodsAreIgnored() { assertMacroExpansion( """ @ViewModelMacro @@ -220,7 +226,8 @@ final class ViewModelMacroTests: XCTestCase { ) } - func testClassSupport() throws { + @Test("Class support") + func classSupport() { assertMacroExpansion( """ @ViewModelMacro @@ -267,7 +274,8 @@ final class ViewModelMacroTests: XCTestCase { ) } - func testEmptyActorGeneratesEmptyEnum() throws { + @Test("Empty actor generates empty enum") + func emptyActorGeneratesEmptyEnum() { assertMacroExpansion( """ @ViewModelMacro @@ -306,7 +314,8 @@ final class ViewModelMacroTests: XCTestCase { ) } - func testMacroOnlyAppliesToClassAndActor() throws { + @Test("Macro only applies to class and actor") + func macroOnlyAppliesToClassAndActor() { assertMacroExpansion( """ @ViewModelMacro @@ -327,7 +336,8 @@ final class ViewModelMacroTests: XCTestCase { ) } - func testComplexParameterLabels() throws { + @Test("Complex parameter labels") + func complexParameterLabels() { assertMacroExpansion( """ @ViewModelMacro @@ -374,7 +384,8 @@ final class ViewModelMacroTests: XCTestCase { ) } - func testRealWorldExample() throws { + @Test("Real world example") + func realWorldExample() { assertMacroExpansion( """ @ViewModelMacro @@ -443,7 +454,8 @@ final class ViewModelMacroTests: XCTestCase { ) } - func testHandleActionMethodGeneration() throws { + @Test("Handle action method generation") + func handleActionMethodGeneration() { assertMacroExpansion( """ @ViewModelMacro @@ -495,7 +507,8 @@ final class ViewModelMacroTests: XCTestCase { ) } - func testEmptyViewModelHandleAction() throws { + @Test("Empty ViewModel handle action") + func emptyViewModelHandleAction() { assertMacroExpansion( """ @ViewModelMacro @@ -527,4 +540,52 @@ final class ViewModelMacroTests: XCTestCase { macros: testMacros ) } + + @Test("ViewModel with module generates properties and initializer") + func viewModelWithModuleGeneratesPropertiesAndInitializer() { + assertMacroExpansion( + """ + @ViewModelMacro(module: ProductEdit.self) + public actor ProductEditViewModel: ViewModelProtocol { + func onAppear() async {} + func onTapSave() async {} + } + """, + expandedSource: """ + public actor ProductEditViewModel: ViewModelProtocol { + @MainActor + public var state: ProductEdit.ViewState + private let input: ProductEdit.Input? + private let output: ProductEdit.Output? + + @MainActor + public init(state: ProductEdit.ViewState, input: ProductEdit.Input?, output: ProductEdit.Output?) { + self.state = state + self.input = input + self.output = output + } + + func onAppear() async {} + func onTapSave() async {} + + public func handleAction(_ action: Action) async { + switch action { + case .onAppear: + await onAppear() + case .onTapSave: + await onTapSave() + } + } + } + + extension ProductEditViewModel { + public enum Action: Sendable { + case onAppear + case onTapSave + } + } + """, + macros: testMacros + ) + } } diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/InputOutputTests.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/InputOutputTests.swift new file mode 100644 index 0000000..5e0cd5f --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/InputOutputTests.swift @@ -0,0 +1,146 @@ +// +// Copyright © 2025 Alexander Romanov +// InputOutputTests.swift, created on 18.09.2025 +// + +import Foundation +@testable import OversizeArchitecture +import Testing + +@Suite("Input Output Tests") +struct InputOutputTests { + // MARK: - Product Detail Input Tests + + @Test("ProductDetailInput with ID") + func productDetailInputWithId() { + let id = UUID() + let input = ProductDetailInput(id: id) + + #expect(input.productId == id) + } + + @Test("ProductDetailInput with Product") + func productDetailInputWithProduct() { + let product = Product(id: UUID(), name: "Test Product") + let input = ProductDetailInput(product: product) + + #expect(input.productId == product.id) + } + + // MARK: - Product Edit Input Tests + + @Test("ProductEditInput empty initialization") + func productEditInputEmpty() { + let input = ProductEditInput() + + #expect(input.productId == nil) + } + + @Test("ProductEditInput with ID") + func productEditInputWithId() { + let id = UUID() + let input = ProductEditInput(id: id) + + #expect(input.productId == id) + } + + @Test("ProductEditInput with Product") + func productEditInputWithProduct() { + let product = Product(id: UUID(), name: "Test Product") + let input = ProductEditInput(product: product) + + #expect(input.productId == product.id) + } + + @Test("ProductEditOutput callback execution") + func productEditOutputCallbackExecution() async { + let capturedProduct = ActorBox(nil) + + let output = ProductEditOutput { product in + Task { await capturedProduct.setValue(product) } + } + + let testProduct = Product(id: UUID(), name: "Test Product") + output.onSave(testProduct) + + await Task.yield() + + let captured = await capturedProduct.getValue() + #expect(captured?.id == testProduct.id) + #expect(captured?.name == testProduct.name) + } + + actor ActorBox { + private var value: T + + init(_ initialValue: T) { + value = initialValue + } + + func setValue(_ newValue: T) { + value = newValue + } + + func getValue() -> T { + value + } + } + + // MARK: - Product List Input Tests + + @Test("ProductListInput default initialization") + func productListInputDefaultInitialization() { + let input = ProductListInput() + + #expect(input.filterType == .all) + } + + @Test("ProductListInput with all filter") + func productListInputWithAllFilter() { + let input = ProductListInput(filterType: .all) + + #expect(input.filterType == .all) + } + + @Test("ProductListInput with favorites filter") + func productListInputWithFavoritesFilter() { + let input = ProductListInput(filterType: .favorites) + + #expect(input.filterType == .favorites) + } + + // MARK: - Filter Type Tests + + @Test("ProductListFilterType case iterable") + func productListFilterTypeCaseIterable() { + let allCases = ProductListInput.FilterType.allCases + + #expect(allCases.contains(.all)) + #expect(allCases.contains(.favorites)) + } + + @Test("ProductListFilterType raw values", arguments: [ + (ProductListInput.FilterType.all, "all"), + (ProductListInput.FilterType.favorites, "favorites") + ]) + func productListFilterTypeRawValues(filterType: ProductListInput.FilterType, expectedRawValue: String) { + #expect(filterType.rawValue == expectedRawValue) + } + + // MARK: - Sendable Conformance Test + + @Test("Sendable conformance") + func sendableConformance() { + // Test that types conform to Sendable + func testSendable(_: (some Sendable).Type) {} + + testSendable(Product.self) + testSendable(ProductDetailInput.self) + testSendable(ProductDetailOutput.self) + testSendable(ProductEditInput.self) + testSendable(ProductEditOutput.self) + testSendable(ProductListInput.self) + testSendable(ProductListOutput.self) + testSendable(ProductListInput.FilterType.self) + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/ModuleTests.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/ModuleTests.swift new file mode 100644 index 0000000..75a9c36 --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/ModuleTests.swift @@ -0,0 +1,56 @@ +// +// Copyright © 2025 Alexander Romanov +// ModuleTests.swift, created on 18.09.2025 +// + +import Foundation +@testable import OversizeArchitecture +import Testing + +@Suite("Module Tests") +struct ModuleTests { + @Test("Module protocol conformance") + func moduleProtocolConformance() { + func testModuleProtocol(_: (some ModuleProtocol).Type) {} + + testModuleProtocol(ProductDetail.self) + testModuleProtocol(ProductEdit.self) + testModuleProtocol(ProductList.self) + } + + @Test("ProductDetailModule typealiases") + func productDetailModuleTypealiases() { + func testTypealias(_: T.Type, _: U.Type) -> Bool { + T.self == U.self + } + + #expect(testTypealias(ProductDetail.Input.self, ProductDetailInput.self)) + #expect(testTypealias(ProductDetail.Output.self, ProductDetailOutput.self)) + #expect(testTypealias(ProductDetail.ViewState.self, ProductDetailViewState.self)) + #expect(testTypealias(ProductDetail.ViewModel.self, ProductDetailViewModel.self)) + } + + @Test("ProductEditModule typealiases") + func productEditModuleTypealiases() { + func testTypealias(_: T.Type, _: U.Type) -> Bool { + T.self == U.self + } + + #expect(testTypealias(ProductEdit.Input.self, ProductEditInput.self)) + #expect(testTypealias(ProductEdit.Output.self, ProductEditOutput.self)) + #expect(testTypealias(ProductEdit.ViewState.self, ProductEditViewState.self)) + #expect(testTypealias(ProductEdit.ViewModel.self, ProductEditViewModel.self)) + } + + @Test("ProductListModule typealiases") + func productListModuleTypealiases() { + func testTypealias(_: T.Type, _: U.Type) -> Bool { + T.self == U.self + } + + #expect(testTypealias(ProductList.Input.self, ProductListInput.self)) + #expect(testTypealias(ProductList.Output.self, ProductListOutput.self)) + #expect(testTypealias(ProductList.ViewState.self, ProductListViewState.self)) + #expect(testTypealias(ProductList.ViewModel.self, ProductListViewModel.self)) + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/ProductDetailViewModelTests.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/ProductDetailViewModelTests.swift new file mode 100644 index 0000000..4e6d915 --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/ProductDetailViewModelTests.swift @@ -0,0 +1,109 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductDetailViewModelTests.swift, created on 18.09.2025 +// + +import Foundation +@testable import OversizeArchitecture +import Testing + +@Suite("Product Detail ViewModel Tests") +struct ProductDetailViewModelTests { + private func createViewModel( + input: ProductDetailInput?, + output: ProductDetailOutput? = nil + ) async -> (ProductDetailViewModel, ProductDetailViewState) { + let state = await MainActor.run { ProductDetailViewState(input: input) } + let viewModel = await MainActor.run { + ProductDetailViewModel( + state: state, + input: input, + output: output + ) + } + return (viewModel, state) + } + + @Test("ViewModel initialization with product ID") + func viewModelInitializationWithProductId() async { + let productId = UUID() + let input = ProductDetailInput(id: productId) + + let (_, state) = await createViewModel(input: input) + + await MainActor.run { + #expect(state.id == productId) + #expect(state.name == "Product") + } + } + + @Test("ViewModel initialization with product") + func viewModelInitializationWithProduct() async { + let product = Product(id: UUID(), name: "Initial Product") + let input = ProductDetailInput(product: product) + + let (_, state) = await createViewModel(input: input) + + await MainActor.run { + #expect(state.id == product.id) + #expect(state.name == "Product") + } + } + + @Test("OnAppear with product ID loads product") + func onAppearWithProductIdLoadsProduct() async { + let productId = UUID() + let input = ProductDetailInput(id: productId) + + let (viewModel, state) = await createViewModel(input: input) + + await viewModel.onAppear() + + await MainActor.run { + #expect(state.id == productId) + #expect(state.name == "Loaded Product") + } + } + + @Test("OnAppear with product keeps product name") + func onAppearWithProductKeepsProductName() async { + let product = Product(id: UUID(), name: "Test Product") + let input = ProductDetailInput(product: product) + + let (viewModel, state) = await createViewModel(input: input) + + await viewModel.onAppear() + + await MainActor.run { + #expect(state.id == product.id) + #expect(state.name == "Test Product") + } + } + + @Test("OnAppear with nil input creates default state") + func onAppearWithNilInputCreatesDefaultState() async { + let (viewModel, state) = await createViewModel(input: nil) + + await viewModel.onAppear() + + await MainActor.run { + #expect(state.name == "Product") + } + } + + @Test("ViewModel works with output") + func viewModelWorksWithOutput() async { + let product = Product(id: UUID(), name: "Output Test Product") + let input = ProductDetailInput(product: product) + let output = ProductDetailOutput() + + let (viewModel, state) = await createViewModel(input: input, output: output) + + await viewModel.onAppear() + + await MainActor.run { + #expect(state.id == product.id) + #expect(state.name == product.name) + } + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/ProductEditViewModelTests.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/ProductEditViewModelTests.swift new file mode 100644 index 0000000..1511058 --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/ProductEditViewModelTests.swift @@ -0,0 +1,211 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductEditViewModelTests.swift, created on 18.09.2025 +// + +import Foundation +@testable import OversizeArchitecture +import Testing + +actor ProductTestCapture { + private var savedProducts: [Product] = [] + private var saveCallCount = 0 + + func setProduct(_ product: Product?) { + saveCallCount += 1 + if let product { + savedProducts.append(product) + } + } + + func getLatestProduct() -> Product? { + savedProducts.last + } + + func getAllProducts() -> [Product] { + savedProducts + } + + func getSaveCallCount() -> Int { + saveCallCount + } + + func reset() { + savedProducts.removeAll() + saveCallCount = 0 + } +} + +@Suite("Product Edit ViewModel Tests") +struct ProductEditViewModelTests { + private func createViewModel( + input: ProductEditInput?, + output: ProductEditOutput? = nil + ) async -> (ProductEditViewModel, ProductEditViewState) { + let state = await MainActor.run { ProductEditViewState(input: input) } + let viewModel = await MainActor.run { + ProductEditViewModel( + state: state, + input: input, + output: output + ) + } + return (viewModel, state) + } + + @Test("Initialization in create mode") + func initializationInCreateMode() async { + let input = ProductEditInput() + + let (_, state) = await createViewModel(input: input) + + await MainActor.run { + #expect(state.mode == ProductEditViewState.EditMode.create) + #expect(state.name == "") + } + } + + @Test("Initialization with existing product") + func initializationWithExistingProduct() async { + let product = Product(id: UUID(), name: "Test Product") + let input = ProductEditInput(product: product) + + let (_, state) = await createViewModel(input: input) + + await MainActor.run { + #expect(state.mode == ProductEditViewState.EditMode.edit) + #expect(state.productId == product.id) + #expect(state.name == "") + } + } + + @Test("Initialization with product ID") + func initializationWithProductId() async { + let productId = UUID() + let input = ProductEditInput(id: productId) + + let (_, state) = await createViewModel(input: input) + + await MainActor.run { + #expect(state.mode == ProductEditViewState.EditMode.edit) + #expect(state.productId == productId) + #expect(state.name == "") + } + } + + @Test("OnTapSave in create mode with output") + func onTapSaveInCreateModeWithOutput() async { + let input = ProductEditInput() + let productCapture = ProductTestCapture() + + let output = ProductEditOutput { product in + Task { await productCapture.setProduct(product) } + } + + let (viewModel, state) = await createViewModel(input: input, output: output) + + await MainActor.run { + state.name = "New Product Name" + } + + await viewModel.onTapSave() + await Task.yield() + + let product = await productCapture.getLatestProduct() + #expect(product != nil) + #expect(product?.name == "New Product Name") + + let stateProductId = await MainActor.run { state.productId } + #expect(product?.id == stateProductId) + } + + @Test("OnTapSave in edit mode") + func onTapSaveInEditMode() async { + let existingProduct = Product(id: UUID(), name: "Original Name") + let input = ProductEditInput(product: existingProduct) + + let (viewModel, state) = await createViewModel(input: input) + + await MainActor.run { + state.name = "Updated Product Name" + } + + await viewModel.onTapSave() + + await MainActor.run { + #expect(state.name == "Updated Product Name") + #expect(state.productId == existingProduct.id) + #expect(state.mode == ProductEditViewState.EditMode.edit) + } + } + + @Test("OnTapSave without output") + func onTapSaveWithoutOutput() async { + let input = ProductEditInput() + + let (viewModel, state) = await createViewModel(input: input) + + await MainActor.run { + state.name = "Test Product" + } + + await viewModel.onTapSave() + + await MainActor.run {} + } + + @Test("OnTapSave with empty name") + func onTapSaveWithEmptyName() async { + let input = ProductEditInput() + let productCapture = ProductTestCapture() + + let output = ProductEditOutput { product in + Task { await productCapture.setProduct(product) } + } + + let (viewModel, state) = await createViewModel(input: input, output: output) + + await MainActor.run { + state.name = "" + } + + await viewModel.onTapSave() + await Task.yield() + + let product = await productCapture.getLatestProduct() + #expect(product != nil) + #expect(product?.name == "") + } + + @Test("Multiple save calls") + func multipleSaveCalls() async { + let input = ProductEditInput() + let productCapture = ProductTestCapture() + + let output = ProductEditOutput { product in + Task { await productCapture.setProduct(product) } + } + + let (viewModel, state) = await createViewModel(input: input, output: output) + + await MainActor.run { + state.name = "First Save" + } + await viewModel.onTapSave() + + await MainActor.run { + state.name = "Second Save" + } + await viewModel.onTapSave() + + await Task.yield() + + let saveCount = await productCapture.getSaveCallCount() + let allProducts = await productCapture.getAllProducts() + + #expect(saveCount == 2) + #expect(allProducts.count == 2) + #expect(allProducts.first?.name == "First Save") + #expect(allProducts.last?.name == "Second Save") + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/ProductListViewModelTests.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/ProductListViewModelTests.swift new file mode 100644 index 0000000..2e533c2 --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/ProductListViewModelTests.swift @@ -0,0 +1,135 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductListViewModelTests.swift, created on 18.09.2025 +// + +import Foundation +@testable import OversizeArchitecture +import Testing + +@Suite("Product List ViewModel Tests") +struct ProductListViewModelTests { + private func createViewModel( + input: ProductListInput?, + output: ProductListOutput? = nil + ) async -> (ProductListViewModel, ProductListViewState) { + let state = await MainActor.run { ProductListViewState(input: input) } + let viewModel = await ProductListViewModel( + state: state, + input: input, + output: output + ) + return (viewModel, state) + } + + @Test("Initialization with all filter") + func initializationWithAllFilter() async { + let input = ProductListInput(filterType: .all) + + let (viewModel, _) = await createViewModel(input: input) + + await MainActor.run { + let state = viewModel.state + #expect(state.filterType == .all) + #expect(state.title == "All Products") + } + } + + @Test("Initialization with favorites filter") + func initializationWithFavoritesFilter() async { + let input = ProductListInput(filterType: .favorites) + + let (viewModel, _) = await createViewModel(input: input) + + await MainActor.run { + let state = viewModel.state + #expect(state.filterType == .favorites) + #expect(state.title == "Favorites") + } + } + + @Test("Initialization with nil input defaults to all") + func initializationWithNilInputDefaultsToAll() async { + let (viewModel, _) = await createViewModel(input: nil) + + await MainActor.run { + let state = viewModel.state + #expect(state.filterType == .all) + #expect(state.title == "All Products") + } + } + + @Test("All filter type cases work correctly", arguments: ProductListInput.FilterType.allCases) + func allFilterTypeCasesWorkCorrectly(filterType: ProductListInput.FilterType) async { + let input = ProductListInput(filterType: filterType) + let (viewModel, _) = await createViewModel(input: input) + + await MainActor.run { + let state = viewModel.state + #expect(state.filterType == filterType) + + let expectedTitle = filterType == .all ? "All Products" : "Favorites" + #expect(state.title == expectedTitle) + } + } + + @Test("OnAppear method maintains state") + func onAppearMethodMaintainsState() async { + let input = ProductListInput(filterType: .all) + let (viewModel, _) = await createViewModel(input: input) + + await viewModel.onAppear() + + await MainActor.run { + let state = viewModel.state + #expect(state.filterType == .all) + #expect(state.title == "All Products") + } + } + + @Test("ViewModel works with output") + func viewModelWorksWithOutput() async { + let input = ProductListInput(filterType: .all) + let output = ProductListOutput() + + let (viewModel, _) = await createViewModel(input: input, output: output) + + await viewModel.onAppear() + + await MainActor.run { + let state = viewModel.state + #expect(state.filterType == .all) + #expect(state.title == "All Products") + } + } + + @Test("ViewModel works without output") + func viewModelWorksWithoutOutput() async { + let input = ProductListInput(filterType: .favorites) + let (viewModel, _) = await createViewModel(input: input) + + await viewModel.onAppear() + + await MainActor.run { + let state = viewModel.state + #expect(state.filterType == .favorites) + #expect(state.title == "Favorites") + } + } + + @Test("Multiple onAppear calls work correctly") + func multipleOnAppearCallsWorkCorrectly() async { + let input = ProductListInput(filterType: .all) + let (viewModel, _) = await createViewModel(input: input, output: ProductListOutput()) + + await viewModel.onAppear() + await viewModel.onAppear() + await viewModel.onAppear() + + await MainActor.run { + let state = viewModel.state + #expect(state.filterType == .all) + #expect(state.title == "All Products") + } + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/ProductTests.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/ProductTests.swift new file mode 100644 index 0000000..c9c5ec0 --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/ProductTests.swift @@ -0,0 +1,154 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductTests.swift, created on 18.09.2025 +// + +import Foundation +@testable import OversizeArchitecture +import Testing + +@Suite("Product Swift Testing Demo") +struct ProductTests { + @Test("Product initialization") + func productInitialization() { + let id = UUID() + let name = "Test Product" + + let product = Product(id: id, name: name) + + #expect(product.id == id) + #expect(product.name == name) + } + + @Test("Product equality") + func productEquality() { + let id = UUID() + let product1 = Product(id: id, name: "Product 1") + let product2 = Product(id: id, name: "Product 2") + let product3 = Product(id: UUID(), name: "Product 1") + + #expect(product1.id == product2.id) // Same id + #expect(product1.id != product3.id) // Different id + #expect(product1.name == "Product 1") + #expect(product2.name == "Product 2") + } + + @Test("Product list filter types", arguments: [ + ProductListInput.FilterType.all, + ProductListInput.FilterType.favorites + ]) + func filterTypeTests(filterType: ProductListInput.FilterType) { + let input = ProductListInput(filterType: filterType) + + #expect(input.filterType == filterType) + + switch filterType { + case .all: + #expect(input.filterType.title == "All Products") + case .favorites: + #expect(input.filterType.title == "Favorites") + } + } + + @Test("Product detail input with different sources") + func productDetailInputSources() async { + let productId = UUID() + let product = Product(id: productId, name: "Test Product") + + // Test with ID + let inputWithId = ProductDetailInput(id: productId) + #expect(inputWithId.productId == productId) + + // Test with product + let inputWithProduct = ProductDetailInput(product: product) + #expect(inputWithProduct.productId == product.id) + } + + @Test("Product edit input modes") + func productEditInputModes() async { + // Create mode + let createInput = ProductEditInput() + let createState = await MainActor.run { + ProductEditViewState(input: createInput) + } + + await MainActor.run { + #expect(createState.mode == ProductEditViewState.EditMode.create) + #expect(createState.name == "") + } + + // Edit mode with product + let product = Product(id: UUID(), name: "Edit Product") + let editInput = ProductEditInput(product: product) + let editState = await MainActor.run { + ProductEditViewState(input: editInput) + } + + await MainActor.run { + #expect(editState.mode == ProductEditViewState.EditMode.edit) + #expect(editState.productId == product.id) + #expect(editState.name == "") + } + } + + @Test("Product list view state initialization") + func productListViewStateInitialization() async { + // Test with all filter + let allInput = ProductListInput(filterType: .all) + let allState = await MainActor.run { + ProductListViewState(input: allInput) + } + + await MainActor.run { + #expect(allState.filterType == .all) + #expect(allState.title == "All Products") + } + + // Test with favorites filter + let favoritesInput = ProductListInput(filterType: .favorites) + let favoritesState = await MainActor.run { + ProductListViewState(input: favoritesInput) + } + + await MainActor.run { + #expect(favoritesState.filterType == .favorites) + #expect(favoritesState.title == "Favorites") + } + } +} + +// MARK: - Tags for test organization + +extension Tag { + @Tag static var product: Self + @Tag static var viewModel: Self + @Tag static var async: Self +} + +@Suite("Tagged Tests Demo", .tags(.product, .async)) +struct TaggedProductTests { + @Test("Async product creation", .tags(.product)) + func asyncProductCreation() async { + let product = await Task { + Product(id: UUID(), name: "Async Product") + }.value + + #expect(product.name == "Async Product") + } + + @Test("Multiple products comparison", .tags(.product)) + func multipleProductsComparison() { + let products = (1 ... 5).map { index in + Product(id: UUID(), name: "Product \(index)") + } + + #expect(products.count == 5) + + let uniqueIds = Set(products.map { $0.id }) + #expect(uniqueIds.count == 5) + + for (index, product) in products.enumerated() { + #expect(product.name == "Product \(index + 1)") + } + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetail.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetail.swift new file mode 100644 index 0000000..54c45fa --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetail.swift @@ -0,0 +1,35 @@ +import Foundation +@testable import OversizeArchitecture + +public struct ProductDetailInput: Sendable { + public let source: Source + + public enum Source: Sendable { + case id(UUID) + case product(Product) + } + + public init(id: UUID) { + source = .id(id) + } + + public init(product: Product) { + source = .product(product) + } + + public var productId: UUID { + switch source { + case let .id(id): + id + case let .product(product): + product.id + } + } +} + +public struct ProductDetailOutput: Sendable { + public init() {} +} + +@Module +public enum ProductDetail: ModuleProtocol {} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetailView.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetailView.swift new file mode 100644 index 0000000..9313fcc --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetailView.swift @@ -0,0 +1,19 @@ +// +// Copyright © 2025 Alexander Romanov +// MealProductDetailScreen.swift, created on 10.07.2025 +// + +@testable import OversizeArchitecture +import SwiftUI + +@View(module: ProductDetail.self) +public struct ProductDetailView: ViewProtocol { + public var body: some View { + Text(viewState.name) + .task { reducer.callAsFunction(.onAppear) } + } +} + +#Preview("Detail") { + ProductDetail.build(input: .init(id: UUID())) +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetailViewModel.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetailViewModel.swift new file mode 100644 index 0000000..9137611 --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetailViewModel.swift @@ -0,0 +1,32 @@ +// +// Copyright © 2025 Alexander Romanov +// MealProductDetailViewModel.swift, created on 10.07.2025 +// + +@testable import OversizeArchitecture +import SwiftUI + +@ViewModel(module: ProductDetail.self) +public actor ProductDetailViewModel: ViewModelProtocol { + func onAppear() async { + guard let input else { return } + + switch input.source { + case let .id(id): + await loadProduct(id: id) + case let .product(product): + await updateState(with: product) + } + } + + private func loadProduct(id: UUID) async { + let product = Product(id: id, name: "Loaded Product") + await updateState(with: product) + } + + private func updateState(with product: Product) async { + await state.update { viewState in + viewState.name = product.name + } + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetailViewState.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetailViewState.swift new file mode 100644 index 0000000..1951230 --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Detail/ProductDetailViewState.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2025 Alexander Romanov +// MealProductDetailViewState.swift, created on 10.07.2025 +// + +@testable import OversizeArchitecture +import SwiftUI + +@Observable +public final class ProductDetailViewState: ViewStateProtocol { + public let id: UUID + public var name: String = "Product" + + public init(input: ProductDetail.Input?) { + id = input?.productId ?? UUID() + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEdit.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEdit.swift new file mode 100644 index 0000000..21b1418 --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEdit.swift @@ -0,0 +1,50 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductEditInput.swift, created on 17.09.2025 +// + +import Foundation +@testable import OversizeArchitecture + +public struct ProductEditInput: Sendable { + public let source: Source? + + public enum Source: Sendable { + case id(UUID) + case product(Product) + } + + public init(id: UUID) { + source = .id(id) + } + + public init(product: Product) { + source = .product(product) + } + + public init() { + source = nil + } + + public var productId: UUID? { + switch source { + case let .id(id): + id + case let .product(product): + product.id + case .none: + nil + } + } +} + +public struct ProductEditOutput: Sendable { + public let onSave: @Sendable (_ product: Product) -> Void + + public init(onSave: @escaping @Sendable (_ product: Product) -> Void) { + self.onSave = onSave + } +} + +@Module +public enum ProductEdit: ModuleProtocol {} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEditView.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEditView.swift new file mode 100644 index 0000000..4f052a4 --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEditView.swift @@ -0,0 +1,32 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductEditView.swift, created on 17.09.2025 +// + +@testable import OversizeArchitecture +import SwiftUI + +@View(module: ProductEdit.self) +public struct ProductEditView: ViewProtocol { + public var body: some View { + VStack { + Text(viewState.mode.title) + + TextField("Name", text: $viewState.name) + + Button("Save", systemImage: "checkmark") { + reducer.callAsFunction(.onTapSave) + } + } + .task { reducer.callAsFunction(.onAppear) } + } +} + +#Preview("Edit") { + ProductEdit.build( + input: ProductEditInput(), + output: ProductEditOutput(onSave: { product in + print("Saved: \(product.name)") + }) + ) +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEditViewModel.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEditViewModel.swift new file mode 100644 index 0000000..d5c867a --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEditViewModel.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductEditViewModel.swift, created on 17.09.2025 +// + +@testable import OversizeArchitecture +import SwiftUI + +@ViewModel(module: ProductEdit.self) +public actor ProductEditViewModel: ViewModelProtocol { + func onAppear() async {} + + func onTapSave() async { + let product = await Product(id: state.productId, name: state.name) + output?.onSave(product) + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEditViewState.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEditViewState.swift new file mode 100644 index 0000000..03d8eed --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Edit/ProductEditViewState.swift @@ -0,0 +1,38 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductEditViewState.swift, created on 17.09.2025 +// + +@testable import OversizeArchitecture +import SwiftUI + +@Observable +public final class ProductEditViewState: ViewStateProtocol { + public var name: String = .init() + + public let mode: EditMode + public let productId: UUID + + public init(input: ProductEdit.Input?) { + if let id = input?.productId { + mode = .edit + productId = id + } else { + mode = .create + productId = UUID() + } + } + + public enum EditMode { + case create, edit + + var title: String { + switch self { + case .create: + "Create Product" + case .edit: + "Edit Product" + } + } + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductList.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductList.swift new file mode 100644 index 0000000..adb13ec --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductList.swift @@ -0,0 +1,35 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductListInput.swift, created on 17.09.2025 +// + +import Foundation +@testable import OversizeArchitecture + +public struct ProductListInput: Sendable { + public let filterType: FilterType + + public enum FilterType: String, CaseIterable, Sendable { + case all, favorites + + public var title: String { + switch self { + case .all: + "All Products" + case .favorites: + "Favorites" + } + } + } + + public init(filterType: FilterType = .all) { + self.filterType = filterType + } +} + +public struct ProductListOutput: Sendable { + public init() {} +} + +@Module +public enum ProductList: ModuleProtocol {} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductListView.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductListView.swift new file mode 100644 index 0000000..56c582e --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductListView.swift @@ -0,0 +1,18 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductListView.swift, created on 17.09.2025 +// + +@testable import OversizeArchitecture +import SwiftUI + +@View(module: ProductList.self) +public struct ProductListView: ViewProtocol { + public var body: some View { + Text(viewState.title) + } +} + +#Preview("List") { + ProductList.build() +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductListViewModel.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductListViewModel.swift new file mode 100644 index 0000000..cee8bdc --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductListViewModel.swift @@ -0,0 +1,12 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductListViewModel.swift, created on 17.09.2025 +// + +@testable import OversizeArchitecture +import SwiftUI + +@ViewModel(module: ProductList.self) +public actor ProductListViewModel: ViewModelProtocol { + func onAppear() async {} +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductListViewState.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductListViewState.swift new file mode 100644 index 0000000..36f8bab --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/List/ProductListViewState.swift @@ -0,0 +1,20 @@ +// +// Copyright © 2025 Alexander Romanov +// ProductListViewState.swift, created on 17.09.2025 +// + +@testable import OversizeArchitecture +import SwiftUI + +@Observable +public final class ProductListViewState: ViewStateProtocol { + public let filterType: ProductListInput.FilterType + + public var title: String { + filterType.title + } + + public init(input: ProductList.Input?) { + filterType = input?.filterType ?? .all + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Product.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Product.swift new file mode 100644 index 0000000..ad7989e --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/Product.swift @@ -0,0 +1,16 @@ +// +// Copyright © 2025 Alexander Romanov +// Product.swift, created on 17.09.2025 +// + +import Foundation + +public struct Product: Sendable { + public let id: UUID + public let name: String + + public init(id: UUID, name: String) { + self.id = id + self.name = name + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/TestBuildDestinations.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/TestBuildDestinations.swift new file mode 100644 index 0000000..4e68191 --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/TestViews/TestBuildDestinations.swift @@ -0,0 +1,30 @@ +// +// Copyright © 2025 Alexander Romanov +// TestBuildDestinations.swift, created on 17.09.2025 +// + +@testable import OversizeArchitecture +import SwiftUI + +public enum TestBuildDestinations { + case productList + case productDetail(id: UUID) + case productEdit(id: UUID? = nil, onSave: @Sendable (_ product: Product) -> Void) +} + +public extension TestBuildDestinations { + @ViewBuilder @MainActor + var view: some View { + switch self { + case .productList: + ProductList.build() + case let .productDetail(id): + ProductDetail.build(input: .init(id: id)) + case let .productEdit(id, onSave): + ProductEdit.build( + input: id.map { .init(id: $0) } ?? .init(), + output: .init(onSave: onSave) + ) + } + } +} diff --git a/Tests/OversizeArchitectureTests/ArchitectureTests/ViewStateTests.swift b/Tests/OversizeArchitectureTests/ArchitectureTests/ViewStateTests.swift new file mode 100644 index 0000000..c6c1d4f --- /dev/null +++ b/Tests/OversizeArchitectureTests/ArchitectureTests/ViewStateTests.swift @@ -0,0 +1,150 @@ +// +// Copyright © 2025 Alexander Romanov +// ViewStateSwiftTests.swift, created on 18.09.2025 +// + +import Foundation +@testable import OversizeArchitecture +import Testing + +@Suite("View State Tests") +struct ViewStateTests { + // MARK: - Product Detail View State Tests + + @Test("ProductDetailViewState with input") + func productDetailViewStateWithInput() async { + let product = Product(id: UUID(), name: "Test Product") + let input = ProductDetailInput(product: product) + + let state = await MainActor.run { + ProductDetailViewState(input: input) + } + + await MainActor.run { + #expect(state.id == product.id) + #expect(state.name == "Product") + } + } + + @Test("ProductDetailViewState with nil input") + func productDetailViewStateWithNilInput() async { + let state = await MainActor.run { + ProductDetailViewState(input: nil) + } + + await MainActor.run { + #expect(state.name == "Product") + } + } + + // MARK: - Product Edit View State Tests + + @Test("ProductEditViewState create mode") + func productEditViewStateCreateMode() async { + let input = ProductEditInput() + + let state = await MainActor.run { + ProductEditViewState(input: input) + } + + await MainActor.run { + #expect(state.mode == ProductEditViewState.EditMode.create) + #expect(state.name == "") + } + } + + @Test("ProductEditViewState edit mode") + func productEditViewStateEditMode() async { + let productId = UUID() + let input = ProductEditInput(id: productId) + + let state = await MainActor.run { + ProductEditViewState(input: input) + } + + await MainActor.run { + #expect(state.mode == ProductEditViewState.EditMode.edit) + #expect(state.productId == productId) + } + } + + @Test("ProductEditViewState with nil input") + func productEditViewStateWithNilInput() async { + let state = await MainActor.run { + ProductEditViewState(input: nil) + } + + await MainActor.run { + #expect(state.mode == ProductEditViewState.EditMode.create) + #expect(state.name == "") + } + } + + @Test("ProductEditViewState with product") + func productEditViewStateWithProduct() async { + let product = Product(id: UUID(), name: "Test Product") + let input = ProductEditInput(product: product) + + let state = await MainActor.run { + ProductEditViewState(input: input) + } + + await MainActor.run { + #expect(state.mode == ProductEditViewState.EditMode.edit) + #expect(state.productId == product.id) + } + } + + // MARK: - Product List View State Tests + + @Test("ProductListViewState with all filter") + func productListViewStateWithAllFilter() async { + let input = ProductListInput(filterType: .all) + + let state = await MainActor.run { + ProductListViewState(input: input) + } + + await MainActor.run { + #expect(state.filterType == .all) + #expect(state.title == "All Products") + } + } + + @Test("ProductListViewState with favorites filter") + func productListViewStateWithFavoritesFilter() async { + let input = ProductListInput(filterType: .favorites) + + let state = await MainActor.run { + ProductListViewState(input: input) + } + + await MainActor.run { + #expect(state.filterType == .favorites) + #expect(state.title == "Favorites") + } + } + + @Test("ProductListViewState with nil input") + func productListViewStateWithNilInput() async { + let state = await MainActor.run { + ProductListViewState(input: nil) + } + + await MainActor.run { + #expect(state.filterType == .all) + #expect(state.title == "All Products") + } + } + + // MARK: - ViewState Protocol Tests + + @Test("ViewState protocol conformance") + func viewStateProtocolConformance() { + func testViewStateProtocol(_: (some ViewStateProtocol).Type) {} + + testViewStateProtocol(ProductDetailViewState.self) + testViewStateProtocol(ProductEditViewState.self) + testViewStateProtocol(ProductListViewState.self) + } +}