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 16 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
105 changes: 100 additions & 5 deletions Sources/Testing/Traits/ConditionTrait.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ public struct ConditionTrait: TestTrait, SuiteTrait {
public var isRecursive: Bool {
true
}
internal var isInverted: Bool = false
}

// MARK: -
Expand Down Expand Up @@ -126,7 +127,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 +149,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 +167,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 +195,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 +217,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(conditionTraits: [lhs, rhs], operations: [.and])
}

/// 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(conditionTraits: [lhs, rhs], operations: [.or])
}
}
141 changes: 141 additions & 0 deletions Sources/Testing/Traits/GroupedConditionTraits.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//
// 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 to aggregate ``ConditionTrait`` in a way that it merge subcondition
/// instead of logical operation on conditions.
///
public struct GroupedConditionTraits: TestTrait, SuiteTrait {
fileprivate let conditionTraits: [ConditionTrait]
fileprivate let operations: [Operation]

internal init(conditionTraits: [ConditionTrait] = [], operations: [Operation] = []) {
self.conditionTraits = conditionTraits
self.operations = operations
}

public func prepare(for test: Test) async throws {
_ = try await evaluate()
}

@_spi(Experimental)
public func evaluate() async throws -> Bool {
switch conditionTraits.count {
case 0:
preconditionFailure("GroupedConditionTrait must have at least one condition trait.")
case 1:
return try await conditionTraits.first!.evaluate()
default:
return try await evaluateGroupedConditions()
}
}

private func evaluateGroupedConditions() async throws -> Bool {
var result: Bool?
var skipInfo: SkipInfo?

for (index, operation) in operations.enumerated() where index < conditionTraits.count - 1 {
do {
let isEnabled = try await operation.operate(
conditionTraits[index],
conditionTraits[index + 1],
includeSkipInfo: true
)
result = updateResult(currentResult: result, isEnabled: isEnabled, operation: operation)
} catch let error as SkipInfo {
result = updateResult(currentResult: result, isEnabled: false, operation: operation)
skipInfo = error
}
}

if let skipInfo = skipInfo, !result! {
throw skipInfo
}

return result!
}

private func updateResult(currentResult: Bool?, isEnabled: Bool, operation: Operation) -> Bool {
if let currentResult = currentResult {
return operation == .and ? currentResult && isEnabled : currentResult || isEnabled
} else {
return isEnabled
}
}
}

internal extension GroupedConditionTraits {
enum Operation : Sendable {
case and
case or

@discardableResult
fileprivate func operate(_ lhs: ConditionTrait, _ rhs: ConditionTrait, includeSkipInfo: Bool = false) async throws -> Bool {
let (leftResult, rightResult) = try await evaluate(lhs, rhs)

let isEnabled: Bool
let skipSide: (comments: [Comment]?, sourceLocation: SourceLocation)

switch self {
case .and:
isEnabled = evaluateAnd(left: lhs, right: rhs, leftResult: leftResult, rightResult: rightResult)
skipSide = !isEnabled && !rightResult ? (lhs.comments, lhs.sourceLocation) : (rhs.comments, rhs.sourceLocation)
case .or:
isEnabled = evaluateOr(left: lhs, right: rhs, leftResult: leftResult, rightResult: rightResult)
skipSide = !isEnabled ? (lhs.comments, lhs.sourceLocation) : (rhs.comments, rhs.sourceLocation)
}

guard isEnabled || !includeSkipInfo else {
throw SkipInfo(comment: skipSide.comments?.first, sourceContext: SourceContext(backtrace: nil, sourceLocation: skipSide.sourceLocation))
}
return isEnabled
}

private func evaluate(_ lhs: ConditionTrait, _ rhs: ConditionTrait) async throws -> (Bool, Bool) {
async let leftEvaluation = lhs.evaluate()
async let rightEvaluation = rhs.evaluate()
return (try await leftEvaluation, try await rightEvaluation)
}

private func evaluateAnd(left: ConditionTrait, right: ConditionTrait, leftResult: Bool, rightResult: Bool) -> Bool {
return left.isInverted && right.isInverted ? leftResult || rightResult : leftResult && rightResult
}

private func evaluateOr(left: ConditionTrait, right: ConditionTrait, leftResult: Bool, rightResult: Bool) -> Bool {
return left.isInverted && right.isInverted ? leftResult && rightResult : leftResult || rightResult
}
}
}

public extension Trait where Self == GroupedConditionTraits {
private static func createGroupedTrait(lhs: Self, rhs: ConditionTrait, operation: GroupedConditionTraits.Operation) -> Self {
Self(conditionTraits: lhs.conditionTraits + [rhs], operations: lhs.operations + [operation])
}

private static func createGroupedTrait(lhs: Self, rhs: Self, operation: GroupedConditionTraits.Operation) -> Self {
Self(conditionTraits: lhs.conditionTraits + rhs.conditionTraits, operations: lhs.operations + [operation] + rhs.operations)
}

static func && (lhs: Self, rhs: ConditionTrait) -> Self {
createGroupedTrait(lhs: lhs, rhs: rhs, operation: .and)
}

static func && (lhs: Self, rhs: Self) -> Self {
createGroupedTrait(lhs: lhs, rhs: rhs, operation: .and)
}

static func || (lhs: Self, rhs: ConditionTrait) -> Self {
createGroupedTrait(lhs: lhs, rhs: rhs, operation: .or)
}

static func || (lhs: Self, rhs: Self) -> Self {
createGroupedTrait(lhs: lhs, rhs: rhs, operation: .or)
}
}

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, false)])
func evaluateCondition(_ condition: GroupedConditionTraits, _ expected: Bool) async throws {
do {
let result = try await condition.evaluate()
#expect( result == expected)
} catch {

}
}



@Test("Applying mixed traits", Conditions.condition2 || 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")
}
}