diff --git a/Sources/Testing/CMakeLists.txt b/Sources/Testing/CMakeLists.txt index 5b84aeaf..fabff7b4 100644 --- a/Sources/Testing/CMakeLists.txt +++ b/Sources/Testing/CMakeLists.txt @@ -95,6 +95,7 @@ add_library(Testing Traits/Comment.swift Traits/Comment+Macro.swift Traits/ConditionTrait.swift + Traits/GroupedConditionTraits.swift Traits/ConditionTrait+Macro.swift Traits/HiddenTrait.swift Traits/IssueHandlingTrait.swift diff --git a/Sources/Testing/Traits/ConditionTrait.swift b/Sources/Testing/Traits/ConditionTrait.swift index 079b64d8..def06612 100644 --- a/Sources/Testing/Traits/ConditionTrait.swift +++ b/Sources/Testing/Traits/ConditionTrait.swift @@ -99,6 +99,14 @@ public struct ConditionTrait: TestTrait, SuiteTrait { public var isRecursive: Bool { true } + + /// Indicates whether the result of the evaluated condition + /// should be logically inverted. + /// + /// This allows the system to track if the condition's result + /// has been negated, + /// which is useful to differ `disabled(_:)` from `enabled(_:)` + internal var isInverted: Bool = false } // MARK: - @@ -126,7 +134,10 @@ extension Trait where Self == ConditionTrait { _ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation ) -> Self { - Self(kind: .conditional(condition), comments: Array(comment), sourceLocation: sourceLocation) + Self(kind: .conditional(condition), + comments: Array(comment), + sourceLocation: sourceLocation, + isInverted: false) } /// Constructs a condition trait that disables a test if it returns `false`. @@ -145,7 +156,10 @@ extension Trait where Self == ConditionTrait { sourceLocation: SourceLocation = #_sourceLocation, _ condition: @escaping @Sendable () async throws -> Bool ) -> Self { - Self(kind: .conditional(condition), comments: Array(comment), sourceLocation: sourceLocation) + Self(kind: .conditional(condition), + comments: Array(comment), + sourceLocation: sourceLocation, + isInverted: false) } /// Constructs a condition trait that disables a test unconditionally. @@ -160,7 +174,10 @@ extension Trait where Self == ConditionTrait { _ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation ) -> Self { - Self(kind: .unconditional(false), comments: Array(comment), sourceLocation: sourceLocation) + Self(kind: .unconditional(false), + comments: Array(comment), + sourceLocation: sourceLocation, + isInverted: true) } /// Constructs a condition trait that disables a test if its value is true. @@ -185,7 +202,10 @@ extension Trait where Self == ConditionTrait { _ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation ) -> Self { - Self(kind: .conditional { !(try condition()) }, comments: Array(comment), sourceLocation: sourceLocation) + Self(kind: .conditional { !(try condition()) }, + comments: Array(comment), + sourceLocation: sourceLocation, + isInverted: true) } /// Constructs a condition trait that disables a test if its value is true. @@ -204,6 +224,88 @@ extension Trait where Self == ConditionTrait { sourceLocation: SourceLocation = #_sourceLocation, _ condition: @escaping @Sendable () async throws -> Bool ) -> Self { - Self(kind: .conditional { !(try await condition()) }, comments: Array(comment), sourceLocation: sourceLocation) + Self(kind: .conditional { !(try await condition()) }, + comments: Array(comment), + sourceLocation: sourceLocation, + isInverted: true) + } +} + + +extension Trait where Self == ConditionTrait { + /// Combines two ``ConditionTrait`` conditions using the AND (`&&`) operator. + /// + /// Use this operator to group two conditions such that + /// the resulting ``GroupedConditionTraits`` + /// evaluates to `true` **only if both** subconditions are `true`. + /// + /// - Example: + /// ```swift + /// struct AppFeature { + /// static let isFeatureEnabled: Bool = true + /// static let osIsAndroid: Bool = true + /// + /// static let featureCondition: ConditionTrait = .disabled(if: isFeatureEnabled) + /// static let osCondition: ConditionTrait = .disabled(if: osIsAndroid) + /// } + /// + /// @Test(AppFeature.featureCondition && AppFeature.osCondition) + /// func foo() {} + /// + /// @Test(.disabled(if: AppFeature.isFeatureEnabled && AppFeature.osIsAndroid)) + /// func bar() {} + /// ``` + /// In this example, both `foo` and `bar` will be disabled only when **both** + /// `AppFeature.isFeatureEnabled` is `true` and the OS is Android. + /// + /// - Parameters: + /// - lhs: The left-hand side condition. + /// - rhs: The right-hand side condition. + /// - Returns: A ``GroupedConditionTraits`` instance + /// representing the AND of the two conditions. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } + public static func &&(lhs: Self, rhs: Self) -> GroupedConditionTraits { + GroupedConditionTraits(.and(.trait(lhs), .trait(rhs))) + } + + /// Combines two ``ConditionTrait`` conditions using the OR (`||`) operator. + /// + /// Use this operator to group two conditions such that + /// the resulting ``GroupedConditionTraits`` + /// evaluates to `true` if **either** of the subconditions is `true`. + /// + /// - Example: + /// ```swift + /// struct AppFeature { + /// static let isInternalBuild: Bool = false + /// static let isSimulator: Bool = true + /// + /// static let buildCondition: ConditionTrait = .enabled(if: isInternalBuild) + /// static let platformCondition: ConditionTrait = .enabled(if: isSimulator) + /// } + /// + /// @Test(AppFeature.buildCondition || AppFeature.platformCondition) + /// func foo() {} + /// + /// @Test(.enabled(if: AppFeature.isInternalBuild || AppFeature.isSimulator)) + /// func bar() {} + /// ``` + /// In this example, both `foo` and `bar` will be enabled when **either** + /// the build is internal or running on a simulator. + /// + /// - Parameters: + /// - lhs: The left-hand side condition. + /// - rhs: The right-hand side condition. + /// - Returns: A ``GroupedConditionTraits`` instance + /// representing the OR of the two conditions. + /// + /// @Metadata { + /// @Available(Swift, introduced: 6.2) + /// } + public static func ||(lhs: Self, rhs: Self) -> GroupedConditionTraits { + GroupedConditionTraits(.or(.trait(lhs), .trait(rhs))) } } diff --git a/Sources/Testing/Traits/GroupedConditionTraits.swift b/Sources/Testing/Traits/GroupedConditionTraits.swift new file mode 100644 index 00000000..dc614441 --- /dev/null +++ b/Sources/Testing/Traits/GroupedConditionTraits.swift @@ -0,0 +1,98 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +/// A type that aggregate sub-conditions in ``ConditionTrait`` which must be +/// satisfied for the testing library to enable a test. +/// +/// To aggregate ``ConditionTrait`` please use following operator: +/// +/// - ``Trait/&&(lhs:rhs)`` +/// - ``Trait/||(lhs:rhs)`` +/// +/// @Metadata { +/// @Available(Swift, introduced: 6.2) +/// } +public struct GroupedConditionTraits: TestTrait, SuiteTrait { + /// + internal let expression: ConditionExpression + + internal init(_ expression: ConditionExpression) { + self.expression = expression + } + + public func prepare(for test: Test) async throws { + try await evaluate() + } + + @discardableResult + public func evaluate() async throws -> Bool { + let (result, skipInfo) = try await expression.evaluate(includeSkipInfo: true) + if let skip = skipInfo, !result { + throw skip + } + return result + } + + internal indirect enum ConditionExpression { + case trait(ConditionTrait) + case and(ConditionExpression, ConditionExpression) + case or(ConditionExpression, ConditionExpression) + } +} +// MARK: - Trait Operator Overloads + +public extension Trait where Self == GroupedConditionTraits { + static func trait(_ t: ConditionTrait) -> Self { + .init(.trait(t)) + } + + static func && (lhs: Self, rhs: ConditionTrait) -> Self { + .init(.and(lhs.expression, .trait(rhs))) + } + + static func && (lhs: Self, rhs: Self) -> Self { + .init(.and(lhs.expression, rhs.expression)) + } + + static func || (lhs: Self, rhs: ConditionTrait) -> Self { + .init(.or(lhs.expression, .trait(rhs))) + } + + static func || (lhs: Self, rhs: Self) -> Self { + .init(.or(lhs.expression, rhs.expression)) + } +} + +extension GroupedConditionTraits.ConditionExpression { + func evaluate(includeSkipInfo: Bool = false) async throws -> (Bool, SkipInfo?) { + switch self { + case .trait(let trait): + var result = try await trait.evaluate() + result = trait.isInverted ? !result : result + let skipInfo = result ? nil : SkipInfo( + comment: trait.comments.first, + sourceContext: SourceContext(backtrace: nil, sourceLocation: trait.sourceLocation) + ) + return (result, skipInfo) + + case .and(let lhs, let rhs): + let (leftResult, leftSkip) = try await lhs.evaluate(includeSkipInfo: includeSkipInfo) + let (rightResult, rightSkip) = try await rhs.evaluate(includeSkipInfo: includeSkipInfo) + let isEnabled = leftResult && rightResult + return (isEnabled, isEnabled ? nil : leftSkip ?? rightSkip) + + case .or(let lhs, let rhs): + let (leftResult, leftSkip) = try await lhs.evaluate(includeSkipInfo: includeSkipInfo) + let (rightResult, rightSkip) = try await rhs.evaluate(includeSkipInfo: includeSkipInfo) + let isEnabled = leftResult || rightResult + return (isEnabled, isEnabled ? nil : leftSkip ?? rightSkip) + } + } +} diff --git a/Tests/TestingTests/Traits/GroupedConditionTraitTests.swift b/Tests/TestingTests/Traits/GroupedConditionTraitTests.swift new file mode 100644 index 00000000..8317d883 --- /dev/null +++ b/Tests/TestingTests/Traits/GroupedConditionTraitTests.swift @@ -0,0 +1,43 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// +@testable @_spi(Experimental) import Testing + +@Suite("Grouped Condition Trait Tests", .tags(.traitRelated)) +struct GroupedConditionTraitTests { + + @Test("evaluate grouped conditions", arguments: [((Conditions.condition1 && Conditions.condition1), true), + (Conditions.condition3 && Conditions.condition1, false), + (Conditions.condition1 || Conditions.condition3, true), + (Conditions.condition4 || Conditions.condition4, true), + (Conditions.condition2 || Conditions.condition2, false), + ((Conditions.condition1 && Conditions.condition2) || (Conditions.condition3 && Conditions.condition4), true)]) + func evaluateCondition(_ condition: GroupedConditionTraits, _ expected: Bool) async throws { + do { + let result = try await condition.evaluate() + #expect( result == expected) + } catch { + print(error) + } + } + + + + @Test("Applying mixed traits", Conditions.condition1 || Conditions.condition2 || Conditions.condition2 || Conditions.condition2) + func applyMixedTraits() { + #expect(true) + } + + private enum Conditions { + static let condition1 = ConditionTrait.enabled(if: true, "Some comment for condition1") + static let condition2 = ConditionTrait.enabled(if: false, "Some comment for condition2") + static let condition3 = ConditionTrait.disabled(if: true, "Some comment for condition3") + static let condition4 = ConditionTrait.disabled(if: false, "Some comment for condition4") + } +}