From 1cd9be2ea6ebf3f6f620b388e113266e5d946ae2 Mon Sep 17 00:00:00 2001 From: Suzy Silver Date: Thu, 16 Jul 2026 17:07:34 -0500 Subject: [PATCH 1/2] Combine a parameterized test case's argument IDs into a single hashed ID --- .../CustomTestArgumentEncodable.swift | 19 +++++++++ .../Parameterization/Test.Case.ID.swift | 12 +++--- Tests/TestingTests/Test.CaseTests.swift | 41 +++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift b/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift index 0a96775b1..9dfad0b1e 100644 --- a/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift +++ b/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift @@ -99,6 +99,25 @@ extension Test.Case.Argument.ID { } } +extension Test.Case.Argument.ID { + /// Initialize an ID instance by combining several argument IDs into one. + /// + /// - Parameters: + /// - argumentIDs: The argument IDs to combine, in order. + /// + /// The bytes of each argument ID are length-prefixed before being hashed + /// together, so that argument boundaries are preserved. + init(combining argumentIDs: some Sequence) { + var bytes = [UInt8]() + for argumentID in argumentIDs { + var count = UInt64(argumentID.bytes.count).littleEndian + withUnsafeBytes(of: &count) { bytes.append(contentsOf: $0) } + bytes.append(contentsOf: argumentID.bytes) + } + self.init(bytes: SHA256.hash(bytes)) + } +} + #if !SWT_NO_CODABLE extension Test.Case.Argument.ID { /// Encode the specified test argument value and store its encoded diff --git a/Sources/Testing/Parameterization/Test.Case.ID.swift b/Sources/Testing/Parameterization/Test.Case.ID.swift index 172699c41..2c63ffe8e 100644 --- a/Sources/Testing/Parameterization/Test.Case.ID.swift +++ b/Sources/Testing/Parameterization/Test.Case.ID.swift @@ -16,11 +16,12 @@ extension Test.Case { /// different ``Test`` instances. @_spi(ForToolsIntegrationOnly) public struct ID: Sendable { - /// The IDs of the arguments of this instance's associated ``Test/Case``, in - /// the order they appear in ``Test/Case/arguments``. + /// The IDs of the arguments of this instance's associated ``Test/Case``. /// - /// The value of this property is `nil` for the ID of the single test case - /// associated with a non-parameterized test function. + /// For a parameterized test case, this array contains a single element: an + /// ``Test/Case/Argument/ID-swift.struct`` that combines the IDs of every + /// argument. The value of this property is `nil` for the ID of the single + /// test case associated with a non-parameterized test function. public var argumentIDs: [Argument.ID]? /// A number used to distinguish this test case from others associated with @@ -49,7 +50,8 @@ extension Test.Case { @_spi(ForToolsIntegrationOnly) public var id: ID { - ID(argumentIDs: arguments.map { $0.map(\.id) }, discriminator: discriminator, isStable: isStable) + let argumentIDs = arguments.map { [Argument.ID(combining: $0.map(\.id))] } + return ID(argumentIDs: argumentIDs, discriminator: discriminator, isStable: isStable) } } diff --git a/Tests/TestingTests/Test.CaseTests.swift b/Tests/TestingTests/Test.CaseTests.swift index 73a1e73ba..773deea42 100644 --- a/Tests/TestingTests/Test.CaseTests.swift +++ b/Tests/TestingTests/Test.CaseTests.swift @@ -92,6 +92,47 @@ struct Test_CaseTests { #expect(testCaseID.discriminator == 0) } } + + @Suite("Combined argument ID Tests") + struct CombinedArgumentIDTests { + private func makeID(_ values: [any Sendable]) -> Test.Case.ID { + let parameters = values.indices.map { + Test.Parameter(index: $0, firstName: "p\($0)", type: Int.self) + } + return Test.Case(values: values, parameters: parameters, body: {}).id + } + + @Test("Multiple arguments are folded into a single combined ID") + func multipleArgumentsFoldIntoOneID() { + let id = makeID([1, 2, 3]) + #expect(id.argumentIDs?.count == 1) + } + + @Test("Equal arguments produce equal combined IDs") + func equalArgumentsAreDeterministic() { + #expect(makeID([1, 2]).argumentIDs == makeID([1, 2]).argumentIDs) + } + + @Test("Reordered arguments produce different combined IDs") + func combinedIDIsOrderSensitive() { + #expect(makeID([1, 2]).argumentIDs != makeID([2, 1]).argumentIDs) + } + + @Test("Length-prefixing prevents argument boundary collisions") + func combinedIDAvoidsBoundaryCollisions() { + #expect(makeID(["ab", "c"]).argumentIDs != makeID(["a", "bc"]).argumentIDs) + } + + @Test("Combining is stable when all arguments are stable") + func combinedIDIsStableForStableArguments() { + #expect(makeID([1, 2]).isStable) + } + + @Test("Combining preserves the nil ID for non-parameterized cases") + func nonParameterizedCaseHasNilArgumentIDs() { + #expect(Test.Case(body: {}).id.argumentIDs == nil) + } + } #endif } From 8baa72d92f01f1717fa6f839a09cafdfec310003 Mon Sep 17 00:00:00 2001 From: Suzy Silver Date: Mon, 20 Jul 2026 10:12:07 -0700 Subject: [PATCH 2/2] Update to not use littleEndian and return single argument id if only one exisits --- .../CustomTestArgumentEncodable.swift | 20 +++++++++---------- Tests/TestingTests/Test.CaseTests.swift | 8 +++++--- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift b/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift index 9dfad0b1e..f1f3f4d2a 100644 --- a/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift +++ b/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift @@ -100,21 +100,21 @@ extension Test.Case.Argument.ID { } extension Test.Case.Argument.ID { - /// Initialize an ID instance by combining several argument IDs into one. + /// Initialize an ID from the IDs of a test case's arguments. /// /// - Parameters: /// - argumentIDs: The argument IDs to combine, in order. /// - /// The bytes of each argument ID are length-prefixed before being hashed - /// together, so that argument boundaries are preserved. - init(combining argumentIDs: some Sequence) { - var bytes = [UInt8]() - for argumentID in argumentIDs { - var count = UInt64(argumentID.bytes.count).littleEndian - withUnsafeBytes(of: &count) { bytes.append(contentsOf: $0) } - bytes.append(contentsOf: argumentID.bytes) + /// The argument IDs are concatenated and hashed to form a single ID. A stable + /// argument's ID is itself a fixed-size hash, so the concatenation is + /// unambiguous. A single argument's ID already identifies the case, so it is + /// used directly rather than combined. + init(combining argumentIDs: some Collection) { + if let argumentID = argumentIDs.first, argumentIDs.count == 1 { + self = argumentID + } else { + self.init(bytes: SHA256.hash(argumentIDs.flatMap(\.bytes))) } - self.init(bytes: SHA256.hash(bytes)) } } diff --git a/Tests/TestingTests/Test.CaseTests.swift b/Tests/TestingTests/Test.CaseTests.swift index 773deea42..13ae5bc95 100644 --- a/Tests/TestingTests/Test.CaseTests.swift +++ b/Tests/TestingTests/Test.CaseTests.swift @@ -118,9 +118,11 @@ struct Test_CaseTests { #expect(makeID([1, 2]).argumentIDs != makeID([2, 1]).argumentIDs) } - @Test("Length-prefixing prevents argument boundary collisions") - func combinedIDAvoidsBoundaryCollisions() { - #expect(makeID(["ab", "c"]).argumentIDs != makeID(["a", "bc"]).argumentIDs) + @Test("A single argument's ID is used directly without combining") + func singleArgumentIDIsUsedDirectly() throws { + let parameter = Test.Parameter(index: 0, firstName: "x", type: Int.self) + let argumentID = try #require(try Test.Case.Argument.ID(identifying: 1, parameter: parameter)) + #expect(makeID([1]).argumentIDs == [argumentID]) } @Test("Combining is stable when all arguments are stable")