Advertisement

← poker-tutor

Sources/TestKit/TestKit.swift

4.8 KB Download file
import Foundation

/// A very small test harness.
///
/// Apple's test frameworks (XCTest and swift-testing) both require a full Xcode install.
/// This project needs to be testable before Xcode is present, so suites are plain functions
/// collected here and run by `pokerlab test`.

public final class TestRun: @unchecked Sendable {
    public struct Failure {
        public let suite: String
        public let test: String
        public let message: String
        public let file: String
        public let line: Int
    }

    public private(set) var failures: [Failure] = []
    public private(set) var checks = 0
    public private(set) var testCount = 0
    private var currentSuite = ""
    private var currentTest = ""
    private var suiteFailuresAtStart = 0
    private let verbose: Bool
    private let filter: String?

    public init(verbose: Bool = false, filter: String? = nil) {
        self.verbose = verbose
        self.filter = filter
    }

    public var passed: Bool { failures.isEmpty }

    /// Run before every suite's body — a place to put shared state back where the suites expect it.
    public var beforeEachSuite: (() -> Void)?

    public func suite(_ name: String, _ body: (TestRun) -> Void) {
        beforeEachSuite?()
        if let filter, !name.lowercased().contains(filter.lowercased()) { return }
        currentSuite = name
        suiteFailuresAtStart = failures.count
        let start = Date()
        body(self)
        let elapsed = Date().timeIntervalSince(start)
        let failed = failures.count - suiteFailuresAtStart
        let mark = failed == 0 ? "  ok  " : " FAIL "
        let timing = elapsed > 0.5 ? String(format: "  (%.1fs)", elapsed) : ""
        print("\(mark) \(name)\(timing)")
    }

    public func test(_ name: String, _ body: () -> Void) {
        currentTest = name
        testCount += 1
        body()
    }

    public func expect(_ condition: Bool, _ message: @autoclosure () -> String = "", file: String = #fileID, line: Int = #line) {
        checks += 1
        guard !condition else { return }
        let text = message()
        record(text.isEmpty ? "expectation failed" : text, file: file, line: line)
    }

    public func equal<T: Equatable>(_ actual: T, _ expected: T, _ note: @autoclosure () -> String = "", file: String = #fileID, line: Int = #line) {
        checks += 1
        guard actual != expected else { return }
        let suffix = note().isEmpty ? "" : " — \(note())"
        record("expected \(expected), got \(actual)\(suffix)", file: file, line: line)
    }

    public func notEqual<T: Equatable>(_ actual: T, _ unexpected: T, _ note: @autoclosure () -> String = "", file: String = #fileID, line: Int = #line) {
        checks += 1
        guard actual == unexpected else { return }
        let suffix = note().isEmpty ? "" : " — \(note())"
        record("expected something other than \(unexpected)\(suffix)", file: file, line: line)
    }

    public func close(_ actual: Double, _ expected: Double, _ tolerance: Double, _ note: @autoclosure () -> String = "", file: String = #fileID, line: Int = #line) {
        checks += 1
        guard abs(actual - expected) > tolerance else { return }
        let suffix = note().isEmpty ? "" : " — \(note())"
        record(String(format: "expected %.4f ± %.4f, got %.4f", expected, tolerance, actual) + suffix, file: file, line: line)
    }

    public func lessThan<T: Comparable>(_ a: T, _ b: T, _ note: @autoclosure () -> String = "", file: String = #fileID, line: Int = #line) {
        checks += 1
        guard !(a < b) else { return }
        let suffix = note().isEmpty ? "" : " — \(note())"
        record("expected \(a) < \(b)\(suffix)", file: file, line: line)
    }

    public func greaterThan<T: Comparable>(_ a: T, _ b: T, _ note: @autoclosure () -> String = "", file: String = #fileID, line: Int = #line) {
        checks += 1
        guard !(a > b) else { return }
        let suffix = note().isEmpty ? "" : " — \(note())"
        record("expected \(a) > \(b)\(suffix)", file: file, line: line)
    }

    private func record(_ message: String, file: String, line: Int) {
        failures.append(Failure(suite: currentSuite, test: currentTest, message: message, file: file, line: line))
        if verbose { print("       ✗ \(currentTest): \(message)") }
    }

    public func report() -> Bool {
        print("")
        if failures.isEmpty {
            print("PASSED — \(testCount) tests, \(checks) checks")
            return true
        }
        print("FAILED — \(failures.count) of \(checks) checks in \(testCount) tests")
        print("")
        for f in failures {
            print("  \(f.suite) › \(f.test)")
            print("    \(f.message)")
            print("    \(f.file):\(f.line)")
        }
        return false
    }
}