Skip to content

Commit

Permalink
Merge pull request #24 from justeattakeaway/Gen_Unique
Browse files Browse the repository at this point in the history
Generate unique values. 100% documented!
  • Loading branch information
nicorichard authored Nov 24, 2021
2 parents 71a6709 + d84b6dc commit ddea2ce
Show file tree
Hide file tree
Showing 105 changed files with 1,625 additions and 943 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ Carthage/Build/

# Swift PM

.swiftpm/
.swiftpm/

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center">
<img src="assets/Genything-icon.png" width="35%" alt="Genything" /><br/>
<img src="https://raw.githubusercontent.com/justeattakeaway/Genything/main/assets/Genything-icon.png" width="35%" alt="Genything" /><br/>
</p>

<h1 align="center">Genything</h1>
Expand Down Expand Up @@ -56,7 +56,7 @@ Both of these libraries may be used for code testing, rapid prototyping, demo ap
- Run your [screenshot](https://github.com/pointfreeco/swift-snapshot-testing) [tests](https://github.com/uber/ios-snapshot-test-case) with easy-to-use and predictable data
- Use your generators to unit test with [Property Based Testing](https://medium.com/criteo-engineering/introduction-to-property-based-testing-f5236229d237)
- High test coverage
- Good [documentation](https://justeattakeaway.github.io/Genything)
- [100% Documented](https://justeattakeaway.github.io/Genything)

### Gen

Expand Down
5 changes: 4 additions & 1 deletion Sources/Genything/gen/Gen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import Foundation
// MARK: Gen Typeclass

/// A type class capable of generating a value of type `T` from a given `Context`
public struct Gen<T> {
public struct Gen<T>: Identifiable {
/// A stable identity for this generator
public let id = UUID()

/// A callback capable of generating a new value using the provided `Context`
private let generator: (Context) throws -> T

Expand Down
8 changes: 8 additions & 0 deletions Sources/Genything/gen/GenError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

/// An `Error` representing the scenarios in which generators can fail
public enum GenError: Error {

/// The `Context`'s `maxDepth` was reached by some generator which rejects values
case maxDepthReached
}
10 changes: 10 additions & 0 deletions Sources/Genything/gen/build/Gen+compose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@ import Foundation
public extension Gen {
/// The composer class which passes in the context and allows us to generate more complex data with ease
struct GenComposer {
/// Forwards the `Context` to be used by the generators
fileprivate var context: Context

/// Generates a value using the provided `Gen<T>`
///
/// - Parameters:
/// - gen: A generator capable of producing vlaues of type `T`
///
/// - Returns: A value of type `T`
public func generate<T>(_ gen: Gen<T>) -> T {
gen.generate(context: context)
}

/// Generates an arbitrary value of type `T` where `T` conforms to `Arbitrary`
///
/// - Returns: An arbitrary value of type `T`
public func generate<T>() -> T where T: Arbitrary {
generate(T.arbitrary)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/Genything/gen/build/Gen+fromRange.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,18 @@ public extension Gen where T: RandomInRangeable {
///
/// - Note: Many existing Swift classes support this format despite not deriving from a protocol
public protocol RandomInRangeable: Comparable {
/// Returns a random value within the specified range.
///
/// - Parameter range: The range in which to create a random value. range must not be empty.
///
/// - Returns: A random value within the bounds of range.
static func random<RNG>(in range: ClosedRange<Self>, using generator: inout RNG) -> Self where RNG: RandomNumberGenerator

/// Returns a random value within the specified range.
///
/// - Parameter range: The range in which to create a random value. range must not be empty.
///
/// - Returns: A random value within the bounds of range.
static func random<RNG>(in range: Range<Self>, using generator: inout RNG) -> Self where RNG: RandomNumberGenerator
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/Genything/gen/context/Context+Determinism.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Foundation
public enum Determinism {
/// A random `Determinism` seeded by `seed`
/// Subsequent runs using the same `Context` are guaranteed to produce values in the same order
case predetermined(seed: Int)
case predetermined(seed: UInt64)

// TODO: Create a mechanism to log `originalSeed` to allow for replay using .predetermined.

Expand All @@ -20,7 +20,7 @@ public extension Context {
case let .predetermined(seed):
self.init(using: LCRNG(seed: seed), originalSeed: seed)
case .random:
let seed = Int(arc4random())
let seed = UInt64(arc4random())
self.init(using: LCRNG(seed: seed), originalSeed: seed)
}
}
Expand Down
16 changes: 12 additions & 4 deletions Sources/Genything/gen/context/Context.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ public class Context {
/// The original seed used to begin random number generation, if available
///
/// To be used for debugging purposes and to "replay" a generation event
public let originalSeed: Int?
public let originalSeed: UInt64?

// MARK: Mutate

/// The maximum depth that a `filter` operation may experience before failing
/// The maximum depth that a generator which rejects values can reach before failing
///
/// - SeeAlso: `Gen.filter(isIncluded:)`
public var maxFilterDepth: Int = ContextDefaults.maxFilterDepth
/// - SeeAlso: `Gen.unique()`
public var maxDepth: Int = ContextDefaults.maxDepth

// MARK: Produce

Expand All @@ -42,10 +43,17 @@ public class Context {
/// - rng: The Random Number Generator to be used to produce values
/// - originalSeed: The original seed (start position) of `rng`, if possible
///
public init(using rng: RandomNumberGenerator, originalSeed: Int? = nil) {
public init(using rng: RandomNumberGenerator, originalSeed: UInt64? = nil) {
self.rng = AnyRandomNumberGenerator(rng: rng)
self.originalSeed = originalSeed
}

/// A cache capable of storing the unique values created by a particular Generator's id for the lifetime of the `Context`
///
/// - Note: At the moment only Generators which have been `unique`'d will add values to the cache
///
/// - SeeAlso: `Gen.unique()`
internal var uniqueCache: [UUID:[Any]] = [:]
}

// MARK: Convenience Context creators
Expand Down
15 changes: 14 additions & 1 deletion Sources/Genything/gen/context/ContextDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@ import Foundation
///
/// - SeeAlso: `Context`
public enum ContextDefaults {
/// The default iterations that will be used for operations which consume a generator's values
///
/// - SeeAlso: `Gen.take(count:context:)`
/// - SeeAlso: `Gen.forEach(iterations:context:)`
/// - SeeAlso: `Gen.allSatisfy(iterations:context:)`

public static var maxIterations: Int = 1000
public static var maxFilterDepth: Int = 1000
/// The default maximum depth that a generator which rejects values can reach before failing
///
/// - SeeAlso: `Gen.filter(isIncluded:)`
/// - SeeAlso: `Gen.unique()`
public static var maxDepth: Int = 100

/// Stores a factory capable of creating the `Context` which will be used by default
private(set) public static var defaultContextFactory: () -> Context = {
Context(determinism: .predetermined(seed: 0))
}

/// Registers a function capable of creating the `Context` which will be used by default
public static func registerDefaultContext(_ factory: @escaping () -> Context) {
defaultContextFactory = factory
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation

/// A Type Erased `RandomNumberGenerator`
public struct AnyRandomNumberGenerator: RandomNumberGenerator {
var rng: RandomNumberGenerator
public mutating func next() -> UInt64 {
Expand Down
20 changes: 14 additions & 6 deletions Sources/Genything/gen/context/rng/LCRNG.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import Foundation

/// https://en.wikipedia.org/wiki/Linear_congruential_generator
/// A Linear Congruential `RandomNumberGenerator` of pseudo-random values
///
/// - Note: The LCRNG is independent. Values generated by one instance will have no effect on others
/// - Note: The LCRNG is deterministic. Any instance initialized by the same seed will generate the same sequence of values
///
/// - SeeAlso: https://nuclear.llnl.gov/CNP/rng/rngman/node4.html
/// - SeeAlso: https://en.wikipedia.org/wiki/Linear_congruential_generator
public struct LCRNG: RandomNumberGenerator {
private var seed: UInt64

public init(seed: Int) {
self.seed = UInt64(truncatingIfNeeded: seed)
/// Initializes a `LCRNG` with the provided seed
///
/// Any two `LCRNG` instances initialized by the same seed will independently generate the same sequence of pseudo-random
///
/// - Parameter seed: The seed value which should be used to start the generator
public init(seed: UInt64) {
self.seed = seed
}

/// https://nuclear.llnl.gov/CNP/rng/rngman/node4.html
/// The values of the multiplier are hardwired into the implementation and are known to produce a good random number list
private let a: UInt64 = 2862933555777941757
private let b: UInt64 = 3037000493

public mutating func next() -> UInt64 {
seed = a &* seed &+ b
return seed
Expand Down
11 changes: 3 additions & 8 deletions Sources/Genything/gen/mutate/Gen+filter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,12 @@ import Foundation
// MARK: Mutate

public extension Gen {
/// An Error representing the scenarios in which filtering a Generator may fail
enum FilterError: Error {
case maxDepthReached
}

/// Returns: A generator that only produces values which pass the test `isIncluded`
///
/// - Warning: If the filtered condition is rare enough this function can become infinitely complex
/// e.g. `Int.arbitrary.filter { $0 == 0 }` has a `$1/Int.max$` probability of occuring and will be nearly infinite
///
/// Therefore if the Context's `maxFilterDepth` is reached before producing a value the generator will throw
/// Therefore if the Context's `maxDepth` is reached before producing a value the generator will throw
///
/// - Parameters:
/// - isIncluded: A function which returns true if the value should be included
Expand All @@ -22,14 +17,14 @@ public extension Gen {
/// - Returns: A `Gen` generator.
func filter(_ isIncluded: @escaping (T) -> Bool) -> Gen<T> {
Gen<T> { ctx in
let value = (0...ctx.maxFilterDepth).lazy.map { _ in
let value = (0...ctx.maxDepth).lazy.map { _ in
generate(context: ctx)
}.first {
isIncluded($0)
}

guard let value = value else {
throw FilterError.maxDepthReached
throw GenError.maxDepthReached
}
return value
}
Expand Down
45 changes: 45 additions & 0 deletions Sources/Genything/gen/mutate/Gen+unique.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

// MARK: Mutate

public extension Gen where T: Equatable {
/// Returns: A generator that only produces unique values
///
/// - Warning: If the unique'd Generator is small enough this function will throw `UniqueError.maxDepthReached`
///
/// Therefore if the Context's `maxDepth` is reached before producing a value the generator will throw
///
/// - Warning: The unique cache will persist between uses with the same `Context`, leading to a higher chance of exhausting the generator
///
/// The `Context`'s unique cache may be cleared by calling `Context.clearCache()`
///
/// - Parameters:
/// - maxDepth: The maximum amount of times we will attempt to create a distinct unique value before throwing
///
/// - Returns: A `Gen` generator.
func unique() -> Gen<T> {
return Gen<T> { ctx in
let value = (0...ctx.maxDepth).lazy.map { _ in
generate(context: ctx)
}.first { candidateValue in
let exists = ctx.uniqueCache[id]?.contains {
($0 as? T) == candidateValue
} ?? false

return !exists
}

guard let value = value else {
throw GenError.maxDepthReached
}

if let cache = ctx.uniqueCache[id] {
ctx.uniqueCache[id] = cache + [value]
} else {
ctx.uniqueCache[id] = [value]
}

return value
}
}
}
47 changes: 47 additions & 0 deletions Tests/GenythingTests/gen/mutate/Gen+unique.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import XCTest
import Genything

class Gen_UniqueTests: XCTestCase {
func test_uniquing_where_it_is_possible() {
let digitGenerator = Gen.from(0...9).unique()

XCTAssertEqual(
[0,1,2,3,4,5,6,7,8,9],
digitGenerator.take(count: 10).sorted()
)

XCTAssertEqual(
[0,1,2,3,4,5,6,7,8,9],
digitGenerator.take(count: 10).sorted()
)
}

func test_uniquing_with_compose() {

// In order to `unique` with `compose` the generator must be created
// outside of the composition.
let digitGenerator = Gen.from(0...9)
let correctComposedUniqueGenerator = Gen.compose { c in
c.generate(digitGenerator.unique())
}

XCTAssertEqual(
[0,1,2,3,4,5,6,7,8,9],
correctComposedUniqueGenerator.take(count: 10).sorted()
)

// Otherwise the generator will be created occur once per generation
// As the unique values are stored per-generator
// this leads to having no other values to unique against

let incorrectComposedDigitGenerator = Gen.compose { c in
c.generate(Gen.from(0...9).unique())
}

// We do not expect to receive unique values
XCTAssertNotEqual(
[0,1,2,3,4,5,6,7,8,9],
incorrectComposedDigitGenerator.take(count: 10).sorted()
)
}
}
2 changes: 2 additions & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.tgz
undocumented.json
10 changes: 5 additions & 5 deletions docs/Classes.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<a title="Classes Reference"></a>
<header>
<div class="content-wrapper">
<p><a href="index.html">Genything 0.0.2 Docs</a> (91% documented)</p>
<p><a href="index.html">Genything 0.0.2 Docs</a> (100% documented)</p>
<p class="header-right"><a href="https://github.com/justeattakeaway/Genything"><img src="img/gh.png" alt="GitHub"/>View on GitHub</a></p>
<div class="header-right">
<form role="search" action="search.json">
Expand Down Expand Up @@ -55,6 +55,9 @@
<li class="nav-group-task">
<a href="Enums/Determinism.html">Determinism</a>
</li>
<li class="nav-group-task">
<a href="Enums/GenError.html">GenError</a>
</li>
</ul>
</li>
<li class="nav-group-name">
Expand Down Expand Up @@ -169,9 +172,6 @@
<li class="nav-group-task">
<a href="Structs/Gen/GenComposer.html">– GenComposer</a>
</li>
<li class="nav-group-task">
<a href="Structs/Gen/FilterError.html">– FilterError</a>
</li>
<li class="nav-group-task">
<a href="Structs/LCRNG.html">LCRNG</a>
</li>
Expand Down Expand Up @@ -229,7 +229,7 @@ <h4>Declaration</h4>
</section>
</section>
<section id="footer">
<p>&copy; 2021 <a class="link" href="https://www.skipthedishes.com" target="_blank" rel="external noopener">SkipTheDishes</a>. All rights reserved. (Last updated: 2021-11-18)</p>
<p>&copy; 2021 <a class="link" href="https://www.skipthedishes.com" target="_blank" rel="external noopener">SkipTheDishes</a>. All rights reserved. (Last updated: 2021-11-23)</p>
<p>Generated by <a class="link" href="https://github.com/realm/jazzy" target="_blank" rel="external noopener">jazzy ♪♫ v0.14.1</a>, a <a class="link" href="https://realm.io" target="_blank" rel="external noopener">Realm</a> project.</p>
</section>
</article>
Expand Down
Loading

0 comments on commit ddea2ce

Please sign in to comment.