Skip to content

Commit 4568785

Browse files
committed
Represent non-encodable test argument values in Test.Case.ID
Resolves swiftlang#995 Resolves rdar://119522099
1 parent af6b5b9 commit 4568785

File tree

7 files changed

+336
-82
lines changed

7 files changed

+336
-82
lines changed

Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -43,50 +43,76 @@ public protocol CustomTestArgumentEncodable: Sendable {
4343
func encodeTestArgument(to encoder: some Encoder) throws
4444
}
4545

46+
/// Get the best encodable representation of a test argument value, if any.
47+
///
48+
/// - Parameters:
49+
/// - value: The value for which an encodable representation is requested.
50+
///
51+
/// - Returns: The best encodable representation of `value`, if one is available,
52+
/// otherwise `nil`.
53+
///
54+
/// For a description of the heuristics used to obtain an encodable
55+
/// representation of an argument value, see <doc:ParameterizedTesting>.
56+
func encodableArgumentValue(for value: some Sendable) -> (any Encodable)? {
57+
#if canImport(Foundation)
58+
// Helper for opening an existential.
59+
func customArgumentWrapper(for value: some CustomTestArgumentEncodable) -> some Encodable {
60+
_CustomArgumentWrapper(rawValue: value)
61+
}
62+
63+
return if let customEncodable = value as? any CustomTestArgumentEncodable {
64+
customArgumentWrapper(for: customEncodable)
65+
} else if let rawRepresentable = value as? any RawRepresentable, let encodableRawValue = rawRepresentable.rawValue as? any Encodable {
66+
encodableRawValue
67+
} else if let encodable = value as? any Encodable {
68+
encodable
69+
} else if let identifiable = value as? any Identifiable, let encodableID = identifiable.id as? any Encodable {
70+
encodableID
71+
} else {
72+
nil
73+
}
74+
#else
75+
return nil
76+
#endif
77+
}
78+
4679
extension Test.Case.Argument.ID {
4780
/// Initialize this instance with an ID for the specified test argument.
4881
///
4982
/// - Parameters:
5083
/// - value: The value of a test argument for which to get an ID.
84+
/// - encodableValue: An encodable representation of `value`, if any, with
85+
/// which to attempt to encode a stable representation.
5186
/// - parameter: The parameter of the test function to which this argument
5287
/// value was passed.
5388
///
54-
/// - Returns: `nil` if an ID cannot be formed from the specified test
55-
/// argument value.
56-
///
57-
/// - Throws: Any error encountered while attempting to encode `value`.
89+
/// If a representation of `value` can be successfully encoded, the value of
90+
/// this instance's `bytes` property will be the the bytes of that encoded
91+
/// JSON representation and the value of its `isStable` property will be
92+
/// `true`. Otherwise, the value of its `bytes` property will be the bytes of
93+
/// a textual description of `value` and the value of `isStable` will be
94+
/// `false` to reflect that the representation is not considered stable.
5895
///
5996
/// This function is not part of the public interface of the testing library.
6097
///
6198
/// ## See Also
6299
///
63100
/// - ``CustomTestArgumentEncodable``
64-
init?(identifying value: some Sendable, parameter: Test.Parameter) throws {
101+
init(identifying value: some Sendable, encodableValue: (any Encodable)?, parameter: Test.Parameter) {
65102
#if canImport(Foundation)
66-
func customArgumentWrapper(for value: some CustomTestArgumentEncodable) -> some Encodable {
67-
_CustomArgumentWrapper(rawValue: value)
68-
}
69-
70-
let encodableValue: (any Encodable)? = if let customEncodable = value as? any CustomTestArgumentEncodable {
71-
customArgumentWrapper(for: customEncodable)
72-
} else if let rawRepresentable = value as? any RawRepresentable, let encodableRawValue = rawRepresentable.rawValue as? any Encodable {
73-
encodableRawValue
74-
} else if let encodable = value as? any Encodable {
75-
encodable
76-
} else if let identifiable = value as? any Identifiable, let encodableID = identifiable.id as? any Encodable {
77-
encodableID
78-
} else {
79-
nil
80-
}
81-
82-
guard let encodableValue else {
83-
return nil
103+
if let encodableValue {
104+
do {
105+
self = .init(bytes: try Self._encode(encodableValue, parameter: parameter), isStable: true)
106+
return
107+
} catch {
108+
// FIXME: Capture the error and propagate to the user, not as a test
109+
// failure but as an advisory warning. A missing argument ID will
110+
// prevent re-running the test case, but is not a blocking issue.
111+
}
84112
}
85-
86-
self = .init(bytes: try Self._encode(encodableValue, parameter: parameter))
87-
#else
88-
nil
89113
#endif
114+
115+
self = .init(bytes: String(describingForTest: value).utf8, isStable: false)
90116
}
91117

92118
#if canImport(Foundation)

Sources/Testing/Parameterization/Test.Case.Generator.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ extension Test.Case {
6262
// A beautiful hack to give us the right number of cases: iterate over a
6363
// collection containing a single Void value.
6464
self.init(sequence: CollectionOfOne(())) { _ in
65-
Test.Case(arguments: [], body: testFunction)
65+
Test.Case(body: testFunction)
6666
}
6767
}
6868

@@ -257,7 +257,27 @@ extension Test.Case {
257257

258258
extension Test.Case.Generator: Sequence {
259259
func makeIterator() -> some IteratorProtocol<Test.Case> {
260-
_sequence.lazy.map(_mapElement).makeIterator()
260+
sequence(state: (
261+
iterator: _sequence.makeIterator(),
262+
testCaseIDs: [Test.Case.ID: Int]()
263+
)) { state in
264+
guard let element = state.iterator.next() else {
265+
return nil
266+
}
267+
268+
var testCase = _mapElement(element)
269+
270+
// Store the original, unmodified test case ID. We're about to modify a
271+
// property which affects it, and we want to update state based on the
272+
// original one.
273+
let testCaseID = testCase.id
274+
275+
// Ensure test cases with identical IDs each have a unique discriminator.
276+
testCase.discriminator = state.testCaseIDs[testCaseID, default: 0]
277+
state.testCaseIDs[testCaseID] = testCase.discriminator + 1
278+
279+
return testCase
280+
}
261281
}
262282

263283
var underestimatedCount: Int {

Sources/Testing/Parameterization/Test.Case.ID.swift

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,50 +15,69 @@ extension Test.Case {
1515
/// parameterized test function. They are not necessarily unique across two
1616
/// different ``Test`` instances.
1717
@_spi(ForToolsIntegrationOnly)
18-
public struct ID: Sendable, Equatable, Hashable {
18+
public struct ID: Sendable {
1919
/// The IDs of the arguments of this instance's associated ``Test/Case``, in
2020
/// the order they appear in ``Test/Case/arguments``.
21+
public var argumentIDs: [Argument.ID]
22+
23+
/// A number used to distinguish this test case from others associated with
24+
/// the same test function whose arguments have the same ID.
25+
///
26+
/// ## See Also
2127
///
22-
/// The value of this property is `nil` if _any_ of the associated test
23-
/// case's arguments has a `nil` ID.
24-
public var argumentIDs: [Argument.ID]?
28+
/// - ``Test/Case/discriminator``
29+
public var discriminator: Int
2530

26-
public init(argumentIDs: [Argument.ID]?) {
31+
public init(argumentIDs: [Argument.ID], discriminator: Int) {
2732
self.argumentIDs = argumentIDs
33+
self.discriminator = discriminator
34+
}
35+
36+
/// Whether or not this test case ID is considered stable across successive
37+
/// runs.
38+
///
39+
/// The value of this property is `true` if all of the argument IDs for this
40+
/// instance are stable, otherwise it is `false`.
41+
public var isStable: Bool {
42+
argumentIDs.allSatisfy(\.isStable)
2843
}
2944
}
3045

3146
@_spi(ForToolsIntegrationOnly)
3247
public var id: ID {
33-
let argumentIDs = arguments.compactMap(\.id)
34-
guard argumentIDs.count == arguments.count else {
35-
return ID(argumentIDs: nil)
36-
}
37-
38-
return ID(argumentIDs: argumentIDs)
48+
ID(argumentIDs: arguments.map(\.id), discriminator: discriminator)
3949
}
4050
}
4151

4252
// MARK: - CustomStringConvertible
4353

4454
extension Test.Case.ID: CustomStringConvertible {
4555
public var description: String {
46-
"argumentIDs: \(String(describing: argumentIDs))"
56+
"argumentIDs: \(argumentIDs), discriminator: \(discriminator)"
4757
}
4858
}
4959

5060
// MARK: - Codable
5161

52-
extension Test.Case.ID: Codable {}
62+
extension Test.Case.ID: Codable {
63+
public init(from decoder: some Decoder) throws {
64+
let container = try decoder.container(keyedBy: CodingKeys.self)
5365

54-
// MARK: - Equatable
66+
// The `argumentIDs` property was optional when this type was first
67+
// introduced, and a `nil` value represented a non-stable test case ID.
68+
// To maintain previous behavior, if this value is absent when decoding,
69+
// default to a single argument ID marked as non-stable.
70+
let argumentIDs = try container.decodeIfPresent([Test.Case.Argument.ID].self, forKey: .argumentIDs)
71+
?? [Test.Case.Argument.ID(bytes: [], isStable: false)]
5572

56-
// We cannot safely implement Equatable for Test.Case because its values are
57-
// type-erased. It does conform to `Identifiable`, but its ID type is composed
58-
// of the IDs of its arguments, and those IDs are not always available (for
59-
// example, if the type of an argument is not Codable). Thus, we cannot check
60-
// for equality of test cases based on this, because if two test cases had
61-
// different arguments, but the type of those arguments is not Codable, they
62-
// both will have a `nil` ID and would incorrectly be considered equal.
63-
//
64-
// `Test.Case.ID` is Equatable, however.
73+
// The `discriminator` property was added after this type was first
74+
// introduced. It can safely default to zero when absent.
75+
let discriminator = try container.decodeIfPresent(type(of: discriminator), forKey: .discriminator) ?? 0
76+
77+
self.init(argumentIDs: argumentIDs, discriminator: discriminator)
78+
}
79+
}
80+
81+
// MARK: - Equatable, Hashable
82+
83+
extension Test.Case.ID: Hashable {}

0 commit comments

Comments
 (0)