From 157700b08a207969caf84e543a0097c5453577d8 Mon Sep 17 00:00:00 2001 From: Lennard Sprong Date: Fri, 20 Jun 2025 19:01:35 +0200 Subject: [PATCH] Add Frequency generators --- Sources/PropertyBased/Gen+Frequency.swift | 78 +++++++++++++++ Sources/PropertyBased/Generator.swift | 20 +++- .../GenTests+Frequency.swift | 94 +++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 Sources/PropertyBased/Gen+Frequency.swift create mode 100644 Tests/PropertyBasedTests/GenTests+Frequency.swift diff --git a/Sources/PropertyBased/Gen+Frequency.swift b/Sources/PropertyBased/Gen+Frequency.swift new file mode 100644 index 0000000..a9dcd69 --- /dev/null +++ b/Sources/PropertyBased/Gen+Frequency.swift @@ -0,0 +1,78 @@ +// +// Gen+Frequency.swift +// PropertyBased +// +// Created by Lennard Sprong on 20/06/2025. +// + +#if swift(>=6.2) +extension Gen { + public static func oneOf(_ generators: Generator>...) + -> Generator> + { + return frequency( + generators.map { gen in (rate: 1.0, gen) } + ) + } + + public static func frequency( + _ generators: [(rate: FloatLiteralType, gen: Generator>)] + ) + -> Generator> + { + var total: FloatLiteralType = 0 + let options = generators.map { rate, gen in + precondition(rate >= 0, "Rate must be non-negative, found a rate of \(rate)") + + total += rate + return (limit: total, gen: gen) + } + + return Generator( + run: { [total] rng in + let pick = FloatLiteralType.random(in: 0.. pick }! as Int + + return (index: index, value: options[index].gen._runIntermediate(&rng)) + }, + shrink: { pair in + let opt = options[pair.index] + let shrunk = opt.gen._shrinker(pair.value).lazy.map { (index: pair.index, value: $0) } + return AnySequence(shrunk) + }, + finalResult: { pair in + let opt = options[pair.index] + return opt.gen._mapFilter(pair.value) + } + ) + } + + @_disfavoredOverload + public static func oneOf(_ generators: repeat Generator) + -> Generator> + { + var gens: [(rate: FloatLiteralType, gen: Generator>)] = [] + + for gen in repeat each generators { + gens.append((rate: 1.0, gen.eraseToAny())) + } + + return frequency(gens) + } + + @_disfavoredOverload + public static func frequency( + _ generators: repeat (rate: FloatLiteralType, gen: Generator) + ) + -> Generator> + { + var gens: [(rate: FloatLiteralType, gen: Generator>)] = [] + + for (rate, gen) in repeat each generators { + gens.append((rate: rate, gen.eraseToAny())) + } + + return frequency(gens) + } +} +#endif // swift(>=6.2) diff --git a/Sources/PropertyBased/Generator.swift b/Sources/PropertyBased/Generator.swift index a879bb9..7b34b26 100644 --- a/Sources/PropertyBased/Generator.swift +++ b/Sources/PropertyBased/Generator.swift @@ -261,7 +261,7 @@ extension Generator { } extension Generator { - /// Wrap the shrinking sequence into a type-erased `AnySequence` struct. + /// Wrap the shrinking sequence into an `AnySequence` struct. /// /// This can be used if multiple generators must have the exact same type. /// - Returns: A copy of this generator. @@ -272,4 +272,22 @@ extension Generator { finalResult: _mapFilter ) } + + /// Wrap the shrinking sequence into a type-erased `AnySequence` struct. + /// + /// This can be used if multiple generators must have the exact same type, and the underlying input value must also be hidden. + /// - Returns: A copy of this generator. + @inlinable public func eraseToAny() -> Generator> { + return .init( + run: { rng in + self._runIntermediate(&rng) as Any + }, + shrink: { + AnySequence(_shrinker($0 as! InputValue).lazy.map { $0 as Any }) + }, + finalResult: { + self._mapFilter($0 as! InputValue) + } + ) + } } diff --git a/Tests/PropertyBasedTests/GenTests+Frequency.swift b/Tests/PropertyBasedTests/GenTests+Frequency.swift new file mode 100644 index 0000000..ff89bf9 --- /dev/null +++ b/Tests/PropertyBasedTests/GenTests+Frequency.swift @@ -0,0 +1,94 @@ +// +// GenTests+Frequency.swift +// PropertyBased +// +// Created by Lennard Sprong on 08/07/2025. +// + +import Testing + +@testable import PropertyBased + +#if swift(>=6.2) +@Suite struct GenFrequencyTests { + enum Choice: Hashable { + case plain + case number(Int) + case text(String) + } + + // MARK: oneOf + + static let unpackedGen = Gen.oneOf( + Gen.always(Choice.plain).eraseToAny(), + Gen.int().map(Choice.number).eraseToAny(), + Gen.lowercaseLetter.string(of: 8).map(Choice.text).eraseToAny(), + ) + + static let packedGen = Gen.oneOf( + Gen.always(Choice.plain), + Gen.int().map(Choice.number), + Gen.lowercaseLetter.string(of: 8).map(Choice.text), + ) + + static let generators = [(0, unpackedGen), (1, packedGen)] + + @Test(arguments: generators) + func testGenerateEnum(_ pair: (Int, Generator>)) async { + let gen = pair.1 + await testGen(gen) + + await confirmation(expectedCount: 1...) { confirm1 in + await confirmation(expectedCount: 1...) { confirm2 in + await confirmation(expectedCount: 1...) { confirm3 in + await propertyCheck(count: 200, input: gen) { item in + switch item { + case .plain: + confirm1() + case .number: + confirm2() + case .text: + confirm3() + } + } + } + } + } + } + + @Test(arguments: generators) + func testShrinkChoice(_ pair: (Int, Generator>)) async throws { + let gen = pair.1 + let value = (index: 1, value: 500 as Any) + let results = gen._shrinker(value).compactMap(gen._mapFilter) + try #require(results.count > 1) + #expect(results.first == .number(0)) + #expect(!results.contains(.number(500))) + } + + // MARK: frequency + + static let unpackedFreqGen = Gen.frequency([ + (1, Gen.int().map(Choice.number).eraseToAny()), + (2.0, Gen.lowercaseLetter.string(of: 8).map(Choice.text).eraseToAny()), + (0, Gen.always(Choice.plain).eraseToAny()), + ]) + + static let packedFreqGen = Gen.frequency( + (1, Gen.int().map(Choice.number)), + (2, Gen.lowercaseLetter.string(of: 8).map(Choice.text)), + (0, Gen.always(Choice.plain)), + ) + static let freqGenerators = [(0, unpackedFreqGen), (1, packedFreqGen)] + + @Test(arguments: freqGenerators) + func testGenerateWithFrequency(_ pair: (Int, Generator>)) async { + let gen = pair.1 + await testGen(gen) + + await propertyCheck(count: 200, input: gen) { item in + #expect(item != .plain) + } + } +} +#endif