Split UI-free logic into a RunnerCore swift_library and cover it with a swift_test suite using the Swift Testing library (import Testing / @Test / #expect), matching Tests/m323Tests. Runs via `bazel test`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
3.9 KiB
Swift
135 lines
3.9 KiB
Swift
import Foundation
|
|
|
|
// MARK: - Models
|
|
|
|
public enum AgeGroup: Int, CustomStringConvertible, CaseIterable {
|
|
case junior = 1
|
|
case adult = 2
|
|
case senior = 3
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .junior: return "Junior"
|
|
case .adult: return "Adult"
|
|
case .senior: return "Senior"
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct Runner: Equatable {
|
|
public let name: String
|
|
public let ageGroup: AgeGroup
|
|
public let time: String
|
|
public let number: Int
|
|
|
|
public init(name: String, ageGroup: AgeGroup, time: String, number: Int) {
|
|
self.name = name
|
|
self.ageGroup = ageGroup
|
|
self.time = time
|
|
self.number = number
|
|
}
|
|
}
|
|
|
|
public enum SortKey: String, CaseIterable {
|
|
case name
|
|
case ageGroup
|
|
case time
|
|
case number
|
|
|
|
public func sort(_ lhs: Runner, _ rhs: Runner) -> Bool {
|
|
switch self {
|
|
case .name: return lhs.name < rhs.name
|
|
case .ageGroup: return lhs.ageGroup.rawValue < rhs.ageGroup.rawValue
|
|
case .time: return lhs.time < rhs.time
|
|
case .number: return lhs.number < rhs.number
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - IO
|
|
|
|
public func readFile(_ path: String) -> String {
|
|
(try? String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)) ?? ""
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
public func parseResults(_ content: String, separator: String) -> [Int: String] {
|
|
let pairs = content
|
|
.components(separatedBy: .newlines)
|
|
.compactMap { line -> (Int, String)? in
|
|
let cols = line.components(separatedBy: separator)
|
|
guard cols.count >= 2, let key = Int(cols[0]) else { return nil }
|
|
return (key, cols[1])
|
|
}
|
|
return Dictionary(pairs, uniquingKeysWith: { _, last in last })
|
|
}
|
|
|
|
public func parseStarters(_ content: String, separator: String) -> [(nr: Int, ageGroup: Int, name: String)] {
|
|
content
|
|
.components(separatedBy: .newlines)
|
|
.compactMap { line in
|
|
let cols = line.components(separatedBy: separator)
|
|
guard cols.count >= 3, let nr = Int(cols[0]), let ageGroup = Int(cols[1])
|
|
else { return nil }
|
|
return (nr: nr, ageGroup: ageGroup, name: cols[2])
|
|
}
|
|
}
|
|
|
|
public func assembleRunners(
|
|
starters: [(nr: Int, ageGroup: Int, name: String)], results: [Int: String]
|
|
) -> [Runner] {
|
|
starters.compactMap { starter in
|
|
guard let time = results[starter.nr],
|
|
let group = AgeGroup(rawValue: starter.ageGroup)
|
|
else {
|
|
return nil
|
|
}
|
|
return Runner(name: starter.name, ageGroup: group, time: time, number: starter.nr)
|
|
}
|
|
}
|
|
|
|
public func nonParticipants(
|
|
starters: [(nr: Int, ageGroup: Int, name: String)], results: [Int: String]
|
|
) -> [Runner] {
|
|
starters.compactMap { starter in
|
|
guard results[starter.nr] == nil,
|
|
let group = AgeGroup(rawValue: starter.ageGroup)
|
|
else {
|
|
return nil
|
|
}
|
|
return Runner(name: starter.name, ageGroup: group, time: "DNS", number: starter.nr)
|
|
}
|
|
}
|
|
|
|
// MARK: - Transforms
|
|
|
|
public func visibleRunners(
|
|
_ runners: [Runner],
|
|
query: String,
|
|
includedGroups: Set<AgeGroup>,
|
|
sortKey: SortKey
|
|
) -> [Runner] {
|
|
let needle = query.lowercased()
|
|
return runners
|
|
.filter { includedGroups.contains($0.ageGroup) }
|
|
.filter { needle.isEmpty || $0.name.lowercased().contains(needle) }
|
|
.sorted(by: sortKey.sort)
|
|
}
|
|
|
|
public func toggling(_ groups: Set<AgeGroup>, _ group: AgeGroup) -> Set<AgeGroup> {
|
|
groups.contains(group) ? groups.subtracting([group]) : groups.union([group])
|
|
}
|
|
|
|
// MARK: - Formatting
|
|
|
|
public func menuLabel(_ text: String, on: Bool) -> String {
|
|
on ? "[\(text)]" : " \(text) "
|
|
}
|
|
|
|
public func formatRow(_ nr: String, _ name: String, _ group: String, _ time: String) -> String {
|
|
nr.padding(toLength: 6, withPad: " ", startingAt: 0)
|
|
+ name.padding(toLength: 22, withPad: " ", startingAt: 0)
|
|
+ group.padding(toLength: 9, withPad: " ", startingAt: 0)
|
|
+ time
|
|
}
|