Skip to content

[DNM] Introduce conditional trait all/any #1087

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/Testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 107 additions & 5 deletions Sources/Testing/Traits/ConditionTrait.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: -
Expand Down Expand Up @@ -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`.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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)))
}
}
98 changes: 98 additions & 0 deletions Sources/Testing/Traits/GroupedConditionTraits.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
43 changes: 43 additions & 0 deletions Tests/TestingTests/Traits/GroupedConditionTraitTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}