diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 5aa8350..e32fc90 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -43,24 +43,51 @@ xcodebuild test \
-parallel-testing-enabled NO
```
+Run the dense setup and Settings journeys on the smaller supported simulator:
+
+```bash
+xcodebuild test \
+ -project VirtualGears.xcodeproj \
+ -scheme VirtualGears \
+ -destination 'platform=iOS Simulator,name=iPhone 17e' \
+ -parallel-testing-enabled NO \
+ -only-testing:VirtualGearsUITests/VirtualGearsUITests/testUXCoverageSetupWizardStates \
+ -only-testing:VirtualGearsUITests/VirtualGearsUITests/testUXCoverageSettingsAndEquipmentStates
+```
+
`VirtualGearsUITests` launches deterministic debug fixtures rather than pretending
-the simulator has Bluetooth hardware. Its 30 scenarios cover every primary
-screen, portrait and landscape status visibility, Accessibility Dynamic Type,
-startup failure, trainer reconnect, a riding app waiting, low Click battery,
-pending shifts, accepted Click press feedback, navigation, stop confirmation and
-cancellation, gear-mode switching, Headwind controls and Demo Mode interactions
-in both shift directions. Six of them are regression guards with measured
-assertions rather than existence checks: the ride status must be wide enough to
-be read as words rather than collapsing to an icon, cancelling the stop
-confirmation must return to the ride, every equipment status must sit on one
-row, a low Click battery must be drawn at warning weight, the Easier/Harder
-buttons in Demo Mode must be drawn with the same distinct visual weight as the
-ride screen's (sampled by pixel colour, since button styling isn't exposed via
-the accessibility tree), and the chain-position reminder must never appear or
-disappear across startup states (it previously vanished the instant the
-trainer connected, making the button above it jump). Screenshots are
-attached to every test result. Protocol
-behavior and equipment lifecycle remain covered by the package tests and
+the simulator has Bluetooth hardware. `DesignedUXState` is the maintained
+coverage contract: it lists every intentionally designed, app-owned screen,
+modal, loading state, warning, error and recovery state. The journey tests cover
+all 65 entries and retain a stable `UX-...` screenshot for each one. The
+completeness test fails if a state is not assigned to an executable journey.
+
+The matrix includes the setup guide, startup, ride, Settings, every equipment
+destination, virtual and physical gearing, Headwind and Demo Mode. It also
+includes Accessibility Dynamic Type for the wizard, Settings and ride;
+landscape ride and Headwind layouts; dark-mode ride and Headwind controls; and
+the dense wizard and Settings journeys on the smaller iPhone 17e. Assertions
+check the state-specific message and action, plus important layout and visual
+invariants. Whole-screen pixel comparisons are deliberately avoided; pixel
+sampling is used only when XCTest cannot expose a meaningful property such as
+button emphasis.
+
+To add or change a user-visible state:
+
+1. Add a debug-only `ScreenshotFixture` launch route that stages the real
+ production view and model. Do not build a visual copy for the test.
+2. Add the state to `DesignedUXState` and assign it to a journey in
+ `uxCoverageManifest`.
+3. In that journey, launch or navigate to the state, assert its defining message
+ and available recovery action, then call `capture(_:)`.
+4. Add an accessibility identifier only when the existing label is unstable or
+ SwiftUI combines several children into one element.
+5. Run the manifest test and all `testUXCoverage...` journeys. A state is not
+ covered until its named screenshot is attached to a passing result.
+
+OS-owned permission sheets and real Bluetooth timing are outside this simulator
+matrix. Every response Virtual Gears owns after those events is still represented.
+Protocol behavior and equipment lifecycle remain covered by the package tests and
physical-hardware evidence.
Open the iPhone project:
@@ -108,7 +135,7 @@ connected across Stop.
`AppConfiguration.normalWheelCircumferenceMillimeters` is optional on disk so
configurations saved by older builds still decode. Its effective value defaults
-to 2070 mm and accepts 1800–2400 mm. A standard wheel-size command from the
+to 2105 mm (700×25 road) and accepts 1800–2400 mm. A standard wheel-size command from the
riding app always takes precedence.
## Documentation website
@@ -692,3 +719,59 @@ Some things that look duplicated are not, and should not be merged:
- The fan reconnects through `resumeSavedConnection` rather than
`retrieveAndConnect`. The two are equivalent today, but making them the
same call would be a behaviour change wearing a refactor's clothes.
+
+## Why the gear ladder is walked rather than sorted
+
+The first version of `Drivetrain.build` paired every chainring with every cog,
+sorted the pile by ratio, pruned the cross-chained pairs and dropped exact
+duplicates. That is not how a drivetrain works, and the difference was
+measurable across the groupsets the app ships.
+
+Running both algorithms over all **72 builds** of the shipped groupsets — real
+chainring and cassette pairings only, no invented combinations:
+
+| Over 72 real groupset builds | Sorted pile | Synchro walk |
+|---|---|---|
+| Builds with a shift too small to feel | 12 | **0** |
+| Builds with a hole above 25% | 5 | **0** |
+| Smallest step anywhere | 0.4% | **5.9%** |
+| Largest step anywhere | 37% | 25% |
+| Easiest and hardest gear kept | always | always |
+
+Both defects disappear rather than being patched. A walked drivetrain cannot
+invent a hole, because it only ever moves one cog at a time, and cannot produce
+a step under the perception floor, because the ring transition refuses one.
+
+The walk is the same idea as Shimano Synchronized Shift and SRAM AXS Sequential:
+rings ascending, cogs descending, one cog per press, and at the end of a ring's
+window a jump to the ring above landing on whichever cog gives a step closest to
+the cassette step just taken. Research backing the constants: Di2 shift points
+are a programmable table rather than a formula, a front shift is always paired
+with a one-to-two cog compensating rear shift, and steps below roughly 5% cannot
+be felt.
+
+An earlier measurement across the old 616-combination catalogue produced far
+uglier numbers, but its worst cases came from 8-speed and triple drivetrains that
+have since been removed. Quoting them would have overstated the problem, so the
+table above uses real parts only.
+
+### Gear counts
+
+The walk produces **cassette speeds + 3 to + 6** gears, with 60 of the 72 builds
+landing on exactly +3 or +4. For 11-speed that is 14 to 16, which matches real
+Di2. Wider ring gaps genuinely produce more distinct gears, so `RideabilityTests`
+pins that band rather than forcing every build into 14 to 16.
+
+### The parked gear
+
+The bike never shifts, so what the rider feels is the parked ratio multiplied by
+the circumference the app sets. The app previously assumed the parked ratio
+equalled its own starting gear. `WheelCircumferenceScaler.effectiveCircumference`
+already computed `W / referenceRatio x selectedRatio`, so the fix was to pass the
+parked ratio in place of the reference. When the two are equal the behaviour is
+byte-identical to before, which is why this never showed up as a regression.
+
+The workable parked-ratio window is
+`hardestRatio / scaleRange.upperBound ... easiestRatio / scaleRange.lowerBound`.
+For the virtual ladder that is 2.011 to 2.50; for the default 105 drivetrain it
+is 1.665 to 4.167.
diff --git a/README.md b/README.md
index a78d582..238f0ab 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,10 @@ to know anything about virtual shifting.
Leave the bike in a quiet, straight chain line and shift virtually instead.
Nothing moves on the bike, so shifting is silent and cannot drop the chain.
+Because the bike stays in that one gear all ride, Virtual Gears asks you once
+which gear it is. That ratio is what every virtual gear is scaled from, so the
+app recommends the quietest gear that works and you confirm it in a tap.
+
**[Read the full documentation](https://sbroenne.github.io/VirtualGears/)**
## Why it exists
@@ -54,15 +58,22 @@ work but have not been tested.
1. Wake the KICKR by turning the pedals.
2. Open Virtual Gears on the iPhone. It finds the KICKR, connects and makes it
available to your riding app.
-3. In your riding app, connect to the trainer named **Virtual Gears**. Some apps
+3. Tell the required first-run setup what is physically on the bike: chainrings,
+ and either a cassette or a Zwift Cog/other single sprocket. Your first ride
+ uses Standard 24 virtual gears automatically.
+4. Move the chain to the quiet, reachable gear Virtual Gears recommends and
+ confirm it.
+5. In your riding app, connect to the trainer named **Virtual Gears**. Some apps
may show the iPhone's name instead.
-4. Tap **Start Shifting**, then shift with the large **Easier** and **Harder**
+6. Tap **Start Shifting**, then shift with the large **Easier** and **Harder**
buttons.
-There is no setup wizard. Virtual Gears is a transparent trainer connection as
-soon as the KICKR is ready. **Start Shifting** engages the gears; **Stop Shifting**
-removes them without disconnecting or stopping the ride in your riding app. If it
-finds more than one trainer, it asks you to choose yours by name.
+Virtual Gears is a transparent trainer connection as soon as the KICKR is ready.
+The physical fact it cannot guess is the gear the bike is parked in, because a
+wrong guess would quietly make every gear wrong. **Start Shifting** engages the
+gears; **Stop Shifting** removes them without disconnecting or stopping the ride
+in your riding app. If it finds more than one trainer, it asks you to choose
+yours by name.
The iPhone screen stays awake while the trainer proxy is available. This keeps
Virtual Gears discoverable to riding apps on Windows and other computers before
@@ -80,10 +91,14 @@ with physical hardware.
## What it can do
-- **24 ready-made virtual gears**, with extra range for climbing.
+- **Ready-made virtual gears**, in ladders of 24 with extra range for climbing.
- **App-independent shifting** for FTMS riding apps that have no virtual gears
of their own.
-- **Real-bike gearing**, built from your chainrings and cassette.
+- **Real groupsets**, from Shimano, SRAM and Campagnolo, or your own chainrings
+ and cassette if your bike is not listed.
+- **Gears that shift the way an electronic groupset shifts**, one cog at a time
+ with the front change folded in, so there are no dead shifts and no invented
+ gaps.
- **On-phone shifting** with large controls in portrait and landscape.
- **Accessible ride controls** with VoiceOver gear feedback, adjustable gear
control and support for larger text.
@@ -123,13 +138,24 @@ with physical hardware.
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
@@ -156,9 +182,17 @@ Every gear change waits for confirmation from the trainer.
- **This is not Zwift's native virtual shifting.** It works independently of the
riding app. Virtual Gears supplies and displays the gears itself.
- **Virtual Gears cannot read the trainer's current wheel circumference.** It
- uses the **Normal wheel circumference** saved in Settings, 2070 mm by default,
- unless the riding app supplies another size. If you use a custom value in the
- Wahoo app, enter the same value in Virtual Gears before shifting.
+ uses the optional **Wheel circumference** saved in Settings, or the 2105 mm
+ (700×25 road) default, unless the riding app supplies another size. Common
+ wheel-size shortcuts and direct millimetre entry are available. If you use a
+ custom value in the Wahoo app, enter the same value in Virtual Gears before
+ shifting.
+- **A riding app on Windows may not reconnect on its own after a Bluetooth
+ drop.** If the wireless link between your phone and the computer times out
+ mid-ride, some Windows riding apps do not scan for the phone again by
+ themselves; you may need to restart the riding app to see Virtual Gears once
+ more. This is the riding app's own reconnect behaviour, not something Virtual
+ Gears controls.
## Support
diff --git a/Sources/VirtualGearsCore/AppConfiguration.swift b/Sources/VirtualGearsCore/AppConfiguration.swift
index be681d8..be53534 100644
--- a/Sources/VirtualGearsCore/AppConfiguration.swift
+++ b/Sources/VirtualGearsCore/AppConfiguration.swift
@@ -12,11 +12,84 @@ public struct AppConfiguration: Codable, Equatable {
public var headwindUUID: String?
public var chainringID = DrivetrainCatalog.defaultChainringID
public var cassetteID = DrivetrainCatalog.defaultCassetteID
- /// The gears Zwift and Wahoo hand out when the bike has none of its own.
- /// It is the starting point because it needs no knowledge of the bike.
+ /// A made-up ladder of evenly spaced ratios rather than a copy of a real
+ /// groupset. It is the starting point because it needs no knowledge of the
+ /// bike.
public var usesVirtualGears = true
- /// Nil in configurations saved before this setting existed. The computed
- /// value below turns that into the long-standing 2070 mm default.
+ public var gearLadderID = GearLadderCatalog.defaultLadderID
+ /// The rider's own gear count and range, used only while `gearLadderID`
+ /// equals `GearLadderCatalog.customLadderID`. Kept even while a built-in
+ /// ladder is selected, so switching to "Custom" and back never forgets
+ /// what the rider last set it to.
+ public var customLadder = CustomGearLadder.default
+ /// What is physically on the trainer, including the one gear the bike is
+ /// parked in. Entirely separate from the gearing being simulated: a rider
+ /// on a single-sprocket Zwift Cog can simulate a twelve-speed groupset, and
+ /// most will.
+ public var physical = PhysicalSetup.default
+
+ /// Whether the rider completed the mandatory first-run bike and chain setup.
+ /// This is set only by the final action; merely presenting or leaving the
+ /// guide never counts as completion.
+ public var setupWizardCompleted = false
+ private var setupWizardVersion = 0
+ private static let currentSetupWizardVersion = 2
+
+ /// Reading is deliberately forgiving: a missing key falls back rather than
+ /// throwing away the rider's saved setup. Legacy completion is migrated
+ /// separately because the old guide also marked itself complete when skipped.
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ func string(_ key: CodingKeys, _ fallback: String) throws -> String {
+ try container.decodeIfPresent(String.self, forKey: key) ?? fallback
+ }
+ kickrName = try string(.kickrName, "")
+ kickrUUID = try string(.kickrUUID, "")
+ clickName = try string(.clickName, "")
+ clickUUID = try string(.clickUUID, "")
+ headwindName = try container.decodeIfPresent(
+ String.self, forKey: .headwindName
+ )
+ headwindUUID = try container.decodeIfPresent(
+ String.self, forKey: .headwindUUID
+ )
+ chainringID = try string(
+ .chainringID, DrivetrainCatalog.defaultChainringID
+ )
+ cassetteID = try string(.cassetteID, DrivetrainCatalog.defaultCassetteID)
+ usesVirtualGears = try container.decodeIfPresent(
+ Bool.self, forKey: .usesVirtualGears
+ ) ?? true
+ gearLadderID = try string(
+ .gearLadderID, GearLadderCatalog.defaultLadderID
+ )
+ customLadder = try container.decodeIfPresent(
+ CustomGearLadder.self, forKey: .customLadder
+ ) ?? .default
+ physical = try container.decodeIfPresent(
+ PhysicalSetup.self, forKey: .physical
+ ) ?? .default
+ let storedSetupWizardCompleted = try container.decodeIfPresent(
+ Bool.self, forKey: .setupWizardCompleted
+ ) ?? false
+ let storedSetupWizardVersion = try container.decodeIfPresent(
+ Int.self, forKey: .setupWizardVersion
+ ) ?? 0
+ if storedSetupWizardVersion >= Self.currentSetupWizardVersion {
+ setupWizardCompleted = storedSetupWizardCompleted
+ setupWizardVersion = storedSetupWizardVersion
+ } else {
+ // The old guide marked itself complete when skipped and also
+ // auto-selected a parked gear before final confirmation. No legacy
+ // field proves both new mandatory steps were deliberately finished.
+ setupWizardCompleted = false
+ setupWizardVersion = 0
+ }
+ normalWheelCircumferenceMillimeters = try container.decodeIfPresent(
+ Int.self, forKey: .normalWheelCircumferenceMillimeters
+ )
+ }
+ /// Nil means the rider did not override the 2105 mm (700x25) default.
public private(set) var normalWheelCircumferenceMillimeters: Int?
/// There is nothing to complete. A trainer worth remembering and gears the
@@ -48,6 +121,10 @@ public struct AppConfiguration: Codable, Equatable {
return true
}
+ public mutating func useDefaultWheelCircumference() {
+ normalWheelCircumferenceMillimeters = nil
+ }
+
public var chainring: ChainringOption {
DrivetrainCatalog.chainring(id: chainringID)
?? DrivetrainCatalog.chainring(id: DrivetrainCatalog.defaultChainringID)!
@@ -58,10 +135,83 @@ public struct AppConfiguration: Codable, Equatable {
?? DrivetrainCatalog.cassette(id: DrivetrainCatalog.defaultCassetteID)!
}
+ /// True while the rider has chosen to define their own gear count and
+ /// range rather than the one built-in ladder.
+ public var usesCustomLadder: Bool {
+ gearLadderID == GearLadderCatalog.customLadderID
+ }
+
+ public var gearLadder: GearLadder {
+ if usesCustomLadder {
+ return GearLadderCatalog.custom(customLadder)
+ }
+ return GearLadderCatalog.ladder(id: gearLadderID)
+ ?? GearLadderCatalog.defaultLadder
+ }
+
+ /// The named groupset the chosen parts belong to, when they belong to one.
+ public var groupset: Groupset? {
+ guard !usesVirtualGears else { return nil }
+ return GroupsetCatalog.groupset(
+ chainringID: chainringID,
+ cassetteID: cassetteID
+ )
+ }
+
+ /// The gear the rider confirmed their bike is parked in. Nil until they
+ /// have confirmed one, which is why setup is not finished without it.
+ public var parkedGear: ParkedGear? { physical.parkedGear }
+
+ /// The gear to recommend: the quietest one that still lets every simulated
+ /// gear reach the trainer.
+ public var suggestedParkedGear: ParkedGear? {
+ guard let drivetrain else { return nil }
+ return ParkedGearAdvice.suggestion(
+ for: physical,
+ simulating: drivetrain
+ )
+ }
+
+ /// Every parked ratio that keeps the whole ladder within the trainer's
+ /// reach, at every wheel size a riding app may ask for.
+ public var workableParkedRatios: ClosedRange? {
+ guard let drivetrain else { return nil }
+ return ParkedGearAdvice.workableRatios(for: drivetrain)
+ }
+
+ /// True when the confirmed parked gear leaves part of the ladder out of
+ /// reach, so the app can say so rather than fail mid-ride.
+ public var parkedGearPutsGearsOutOfReach: Bool {
+ guard let drivetrain, let parkedGear else { return false }
+ return !ParkedGearAdvice.isWorkable(parkedGear, simulating: drivetrain)
+ }
+
+ public mutating func park(in gear: ParkedGear) {
+ physical.park(in: gear)
+ }
+
+ /// Pre-selects the recommendation so confirming it is a single tap.
+ public mutating func parkInSuggestion() {
+ if let suggestedParkedGear { physical.park(in: suggestedParkedGear) }
+ }
+
+ /// Marks the mandatory first-run guide complete after the rider confirms
+ /// both the physical bike and parked gear.
+ @discardableResult
+ public mutating func completeSetupWizard() -> Bool {
+ guard hasSafeGearing,
+ parkedGear != nil,
+ !parkedGearPutsGearsOutOfReach
+ else { return false }
+ setupWizardCompleted = true
+ setupWizardVersion = Self.currentSetupWizardVersion
+ return true
+ }
+
/// Nil when the chosen parts cover a wider spread than the trainer can copy.
public var drivetrain: Drivetrain? {
if usesVirtualGears {
- return try? Drivetrain.virtualLadder()
+ return try? gearLadder.drivetrain()
}
return try? Drivetrain.build(
chainrings: chainring.teeth,
@@ -89,15 +239,25 @@ public struct AppConfiguration: Codable, Equatable {
&& UUID(uuidString: headwindUUID ?? "") != nil
}
- public var hasSafeCircumference: Bool {
+ /// Whether the simulated gearing itself fits the trainer, before the
+ /// rider's physical chain position is considered.
+ public var hasSafeGearing: Bool {
guard let drivetrain else { return false }
return Self.isSafe(drivetrain)
}
+ public var hasSafeCircumference: Bool {
+ guard let drivetrain else { return false }
+ return Self.isSafe(drivetrain, parkedGear: parkedGear)
+ }
+
/// Confirms every gear of a drivetrain can be built and encoded at both
/// ends of the wheel sizes a riding app may ask for. Nothing reaches the
/// KICKR without this.
- public static func isSafe(_ drivetrain: Drivetrain) -> Bool {
+ public static func isSafe(
+ _ drivetrain: Drivetrain,
+ parkedGear: ParkedGear? = nil
+ ) -> Bool {
// Building the gears is the check. The engine scales every gear and
// encodes every command, so anything it accepts can be staged.
//
@@ -106,17 +266,30 @@ public struct AppConfiguration: Codable, Equatable {
// put its hardest gear out of reach once a riding app asks for 2400 mm.
// Checking only the middle is how a drivetrain used to pass setup and
// then fail mid-ride.
+ // The parked gear is checked separately because encoding is not the
+ // only limit. Parked in the big ring on the smallest cog every gear
+ // still fits in the command, but the easiest one asks the trainer for a
+ // 238 mm wheel, and a riding app would draw that as a rider who has
+ // stopped. That is what the scale range exists to prevent.
+ if let parkedGear,
+ !ParkedGearAdvice.isWorkable(parkedGear, simulating: drivetrain) {
+ return false
+ }
let window = TrainerSafety.supportedRidingAppCircumferenceMillimeters
return [window.lowerBound, window.upperBound].allSatisfy { wheelSize in
(try? ConfirmedGearEngine(
drivetrain: drivetrain,
- wheelSizeMillimeters: wheelSize
+ wheelSizeMillimeters: wheelSize,
+ parkedGear: parkedGear
)) != nil
}
}
+ /// Setup is not finished until the rider has said which gear the bike is
+ /// parked in. Guessing it quietly moves every gear the rider feels, so it is
+ /// asked rather than assumed.
public var canFinishSetup: Bool {
- hasValidKickr && hasSafeCircumference
+ hasValidKickr && parkedGear != nil && hasSafeCircumference
}
/// Connecting to a trainer is not the same as choosing one. Every check
@@ -157,9 +330,13 @@ public struct AppConfiguration: Codable, Equatable {
public extension AppConfiguration {
var gearCount: Int { drivetrain?.gears.count ?? 0 }
- /// What the rider chose, in the words printed on the parts.
+ /// What the rider chose, named the way the bike is named: the groupset if
+ /// the parts belong to one, otherwise the parts themselves.
var drivetrainName: String {
- guard !usesVirtualGears else { return "Virtual gears" }
+ guard !usesVirtualGears else { return gearLadder.name }
+ if let groupset {
+ return "\(groupset.qualifiedName) · \(chainring.name) \(cassette.name)"
+ }
return "\(chainring.name) · \(cassette.name)"
}
@@ -169,7 +346,7 @@ public extension AppConfiguration {
return "Too wide a range for the trainer"
}
if usesVirtualGears {
- return "\(gearCount) gears · extra-low climbing range"
+ return "\(gearCount) gears · \(gearLadder.note)"
}
return "\(gearCount) gears · \(rangeDescription)"
}
@@ -204,4 +381,36 @@ public extension AppConfiguration {
+ "hardest for speed. Every ride starts in gear "
+ "\(drivetrain.referenceIndex + 1)."
}
+
+ /// What to tell the rider to do with the chain before they start. The
+ /// bike never shifts, so this is a one-off: park it, confirm it, ride.
+ var parkedGearAdviceText: String {
+ guard let suggestedParkedGear else {
+ return "Leave the bike in a quiet, straight chain line, then tell "
+ + "Virtual Gears which gear that is."
+ }
+ let cog = physical.isSingleSprocket
+ ? "the \(suggestedParkedGear.cogTeeth) tooth sprocket"
+ : "the \(suggestedParkedGear.cogTeeth) tooth cog"
+ return "Park the chain on the \(suggestedParkedGear.chainringTeeth) "
+ + "tooth ring and \(cog). Quiet, straight chain line — and the "
+ + "bike stays there for the whole ride."
+ }
+
+ /// Says plainly what a confirmed gear costs, rather than only computing it.
+ var parkedGearWarning: String? {
+ guard parkedGear != nil, parkedGearPutsGearsOutOfReach,
+ let range = workableParkedRatios
+ else {
+ return nil
+ }
+ return String(
+ format: "Parked in that gear, some gears cannot reach the trainer. "
+ + "It needs to be between %.2f and %.2f — around %d/%d.",
+ range.lowerBound,
+ range.upperBound,
+ suggestedParkedGear?.chainringTeeth ?? 34,
+ suggestedParkedGear?.cogTeeth ?? 15
+ )
+ }
}
diff --git a/Sources/VirtualGearsCore/ConfirmedGearEngine.swift b/Sources/VirtualGearsCore/ConfirmedGearEngine.swift
index 27660e9..93535a2 100644
--- a/Sources/VirtualGearsCore/ConfirmedGearEngine.swift
+++ b/Sources/VirtualGearsCore/ConfirmedGearEngine.swift
@@ -10,6 +10,10 @@ public struct PendingGearChange: Equatable, Sendable {
public struct ConfirmedGearEngine: Equatable, Sendable {
public let drivetrain: Drivetrain
public let wheelSizeMillimeters: Double
+ /// The gear the bike is physically parked in. Every gear is scaled away
+ /// from this, not from the starting gear, because this is what the rider's
+ /// legs actually multiply the wheel size by.
+ public let parkedGear: ParkedGear?
public private(set) var requestedIndex: Int
public private(set) var confirmedIndex: Int
@@ -19,7 +23,8 @@ public struct ConfirmedGearEngine: Equatable, Sendable {
public init(
drivetrain: Drivetrain,
- wheelSizeMillimeters: Double
+ wheelSizeMillimeters: Double,
+ parkedGear: ParkedGear? = nil
) throws {
// Where a riding app's wheel size is accepted or turned away. This is
// the only place that decides it, so the answer cannot differ between
@@ -29,7 +34,13 @@ public struct ConfirmedGearEngine: Equatable, Sendable {
else {
throw VirtualGearError.outsideSupportedRange
}
- let referenceRatio = drivetrain.referenceGear.ratio
+ // Without a confirmed parked gear the old assumption is the only one
+ // available: that the bike happens to be sitting in the starting gear.
+ // Setup will not finish without one, so this is a floor, not a default.
+ let referenceRatio = parkedGear?.ratio ?? drivetrain.referenceGear.ratio
+ guard referenceRatio > 0 else {
+ throw VirtualGearError.invalidCircumferenceInputs
+ }
var changes: [PendingGearChange] = []
for (index, gear) in drivetrain.gears.enumerated() {
let circumference =
@@ -55,12 +66,18 @@ public struct ConfirmedGearEngine: Equatable, Sendable {
self.drivetrain = drivetrain
self.wheelSizeMillimeters =
wheelSizeMillimeters
+ self.parkedGear = parkedGear
requestedIndex = drivetrain.referenceIndex
confirmedIndex = drivetrain.referenceIndex
pendingChange = nil
self.changes = changes
}
+ /// The ratio every gear is scaled away from.
+ public var parkedRatio: Double {
+ parkedGear?.ratio ?? drivetrain.referenceGear.ratio
+ }
+
/// True when the trainer has caught up with everything asked of it.
/// Holding a shift button waits for this, so a hold asks for gears at the
/// trainer's pace and stops the moment the rider lets go.
@@ -87,7 +104,8 @@ public struct ConfirmedGearEngine: Equatable, Sendable {
) throws -> Self {
var result = try Self(
drivetrain: drivetrain,
- wheelSizeMillimeters: wheelSizeMillimeters
+ wheelSizeMillimeters: wheelSizeMillimeters,
+ parkedGear: parkedGear
)
result.requestedIndex = confirmedIndex
result.confirmedIndex = confirmedIndex
diff --git a/Sources/VirtualGearsCore/DemoRideState.swift b/Sources/VirtualGearsCore/DemoRideState.swift
index 68b3186..29ffdad 100644
--- a/Sources/VirtualGearsCore/DemoRideState.swift
+++ b/Sources/VirtualGearsCore/DemoRideState.swift
@@ -79,7 +79,8 @@ public struct DemoRideState: Equatable, Sendable {
try? ConfirmedGearEngine(
drivetrain: $0,
wheelSizeMillimeters:
- TrainerSafety.referenceCircumferenceMillimeters
+ TrainerSafety.referenceCircumferenceMillimeters,
+ parkedGear: configuration.parkedGear
)
}
}
@@ -205,6 +206,9 @@ public extension AppConfiguration {
named: "Simulated Headwind",
id: UUID(uuidString: "D3000000-0000-0000-0000-000000000003")!
)
+ // The demo bike is parked in the gear the app would recommend, so the
+ // demo shows the same gears a rider who followed the advice will get.
+ configuration.parkInSuggestion()
return configuration
}
}
diff --git a/Sources/VirtualGearsCore/Drivetrain.swift b/Sources/VirtualGearsCore/Drivetrain.swift
index 8b2be6e..8144007 100644
--- a/Sources/VirtualGearsCore/Drivetrain.swift
+++ b/Sources/VirtualGearsCore/Drivetrain.swift
@@ -20,35 +20,28 @@ public enum DrivetrainError: Error, Equatable {
public struct Drivetrain: Equatable, Sendable {
/// An even ladder of twenty-four virtual ratios that belongs to no real
- /// bike. The lower half extends farther than the common 0.75-based ladder
- /// so first gear is genuinely easy without sacrificing the harder half.
- /// Gear 12 of the ladder, ratio 2.40, is the gear the trainer sits at when
- /// nothing has been shifted, and it is a product decision rather than a
- /// calculated one.
- ///
- /// It used to be calculated: the ladder was centred inside whatever range
- /// the trainer was believed to accept. That made the gears every rider
- /// feels move whenever an unrelated safety number was edited — widening
- /// that range once made the easiest gear 13% harder, silently. The starting
- /// gear is now stated here and the range only has to be wide enough to hold
- /// it.
- public static let virtualReferenceIndex = 11
-
- public static let virtualRatiosHundredths = [
- 60, 68, 77, 88, 100, 113, 129, 146,
- 165, 187, 212, 240, 261, 282, 303, 324,
- 349, 374, 399, 424, 454, 484, 514, 549,
- ]
+ /// bike. See ``GearLadderCatalog`` for the ladders on offer.
+ public static let virtualReferenceIndex = GearLadderCatalog
+ .standardRange.startingIndex
+
+ public static let virtualRatiosHundredths = GearLadderCatalog
+ .standardRange.ratiosHundredths
/// Built as ratios out of one hundred rather than real teeth, because these
/// gears are not parts anyone can buy.
public static func virtualLadder(
+ ratiosHundredths: [Int] = GearLadderCatalog.standardRange
+ .ratiosHundredths,
+ startingIndex: Int = GearLadderCatalog.standardRange.startingIndex,
scaleRange: ClosedRange = TrainerSafety.supportedScaleRange
) throws -> Drivetrain {
- let gears = try virtualRatiosHundredths
+ let gears = try ratiosHundredths
.sorted()
.map { try VirtualGear(chainring: $0, cog: 100) }
- let reference = virtualReferenceIndex
+ guard gears.indices.contains(startingIndex) else {
+ throw DrivetrainError.invalidReferenceIndex(startingIndex)
+ }
+ let reference = startingIndex
let referenceRatio = gears[reference].ratio
let easiest = gears[0].ratio / referenceRatio
let hardest = gears[gears.count - 1].ratio / referenceRatio
@@ -67,15 +60,54 @@ public struct Drivetrain: Equatable, Sendable {
)
}
- /// Builds the drivetrain a rider actually described, using only the gears
- /// they would really ride.
+ /// The gear ratio every ride starts in.
+ ///
+ /// Stated, not calculated. It used to be derived: the gears were positioned
+ /// wherever they best fitted inside the range the trainer was believed to
+ /// accept, which meant editing an unrelated safety number moved the gear
+ /// every rider starts in. Widening the riding-app wheel range from 2400 to
+ /// 2600 mm shifted a compact twelve-speed rider a full ten per cent harder,
+ /// silently. That range has already been changed once, to make FulGaz work.
+ ///
+ /// 2.40 is gear 12 of the virtual ladder and is what a 34 tooth ring on a
+ /// 14 tooth cog gives — the neutral gear other virtual shifting systems
+ /// settle on too. The range now only has to be wide enough to hold the
+ /// gears around it.
+ public static let startingRatio = 2.40
+
+ /// How many cogs at each end of the cassette a chainring cannot reach.
+ ///
+ /// This is a physical answer, not a proportion of the cassette. The chain
+ /// can only run at so much of an angle before it rubs, and that angle is
+ /// roughly the same whether the cassette has eight cogs or thirteen. The
+ /// old rule removed a fixed *fraction* of the cassette instead, so on a
+ /// small cassette it deleted the very cogs that bridge the two chainrings
+ /// and left a hole in the middle of the gears.
+ public static let crossChainCogLimit = 2
+
+ /// The smallest ratio change a rider can feel. Below roughly five per cent
+ /// the gear number on the screen moves, a command goes out to the trainer,
+ /// and the bike does nothing.
+ public static let perceptibleStepFraction = 0.05
+
+ /// Builds the gears the way an electronic groupset shifts them.
///
- /// Pairing every chainring with every cog is wrong twice over. It invents
- /// badly cross-chained gears nobody uses, such as the small ring on the
- /// smallest cog, and it counts the same ratio twice: 34/17 and 50/25 both
- /// give 2.0, so on the handlebar they would be two gear numbers that feel
- /// identical. Cross-chained pairs are dropped and equal ratios are merged,
- /// which is why a 2x12 gives about sixteen gears rather than twenty-four.
+ /// A Zwift Click has exactly two buttons, so a whole two-chainring
+ /// drivetrain has to collapse into one sequence. Shimano and SRAM already
+ /// solved that problem — Synchronized Shift and AXS Sequential — and this
+ /// copies their answer rather than inventing one: start on the small ring
+ /// and the largest cog, move one cog per press, and at the shift point
+ /// change chainring *and* jump the cassette by a compensating amount so the
+ /// change feels like a normal cassette step.
+ ///
+ /// The previous approach paired every chainring with every cog, sorted the
+ /// pile by ratio, pruned the cross-chained pairs and dropped exact
+ /// duplicates. Measured across the seventy-two builds of the groupsets this
+ /// app ships, that produced a shift too small to feel on twelve of them —
+ /// the smallest was 0.4% — and a hole wider than a quarter on five. Walking
+ /// the drivetrain instead removes both causes rather than patching them: a
+ /// walk cannot invent a hole, and it cannot take a step smaller than the
+ /// transition rule allows.
public static func build(
chainrings: [Int],
cassetteCogs: [Int],
@@ -99,30 +131,22 @@ public struct Drivetrain: Equatable, Sendable {
)
var combinations: [VirtualGear] = []
- let rings = chainrings.sorted()
- for (position, chainring) in rings.enumerated() {
- for cog in usableCogs(
- cassetteCogs,
- forRingAt: position,
- ringCount: rings.count
- ) {
- combinations.append(try VirtualGear(chainring: chainring, cog: cog))
- }
- }
- combinations.sort(by: gearOrder)
-
- var unique: [VirtualGear] = []
- for gear in combinations
- where !unique.contains(where: { hasEqualRatio($0, gear) }) {
- unique.append(gear)
+ for pair in synchronisedSequence(
+ rings: chainrings.sorted(),
+ cogs: cassetteCogs.sorted(by: >)
+ ) {
+ combinations.append(
+ try VirtualGear(chainring: pair.chainring, cog: pair.cog)
+ )
}
- guard let reference = centredReferenceIndex(
- of: unique,
+ guard let reference = startingGearIndex(
+ of: combinations,
scaleRange: scaleRange
) else {
throw DrivetrainError.rangeTooWideForTrainer(
- span: (unique.last?.ratio ?? 0) / (unique.first?.ratio ?? 1),
+ span: (combinations.last?.ratio ?? 0)
+ / (combinations.first?.ratio ?? 1),
widest: scaleRange.upperBound / scaleRange.lowerBound
)
}
@@ -130,47 +154,97 @@ public struct Drivetrain: Equatable, Sendable {
return try Drivetrain(
chainrings: chainrings,
cassetteCogs: cassetteCogs,
- allowedCombinations: unique,
+ allowedCombinations: combinations,
referenceIndex: reference
)
}
- /// The cogs a rider would really use with one chainring. The chain has to
- /// run at an angle to reach across the cassette, so a small ring is ridden
- /// on the larger cogs and a big ring on the smaller ones. Ignoring that is
- /// what produced gears like a 34 tooth ring on an 11 tooth cog, which no
- /// rider would ever choose and which made the handlebar readout describe a
- /// bike nobody owns.
- private static func usableCogs(
- _ cogs: [Int],
- forRingAt position: Int,
- ringCount: Int
- ) -> [Int] {
- guard ringCount > 1 else { return cogs }
- // Largest cog first, so index 0 is the easiest gear on the cassette.
- let ordered = cogs.sorted(by: >)
- let last = ordered.count - 1
- // The smallest ring sits at the easy end of the cassette and the
- // largest at the hard end, with any middle ring spread in between.
- let centre = Double(last)
- * Double(position) / Double(ringCount - 1)
- // Rings share the cassette, so each reaches over roughly the same span
- // regardless of how many there are; more rings simply means each covers
- // less of it and the whole drivetrain covers more ground.
- let reach = max(1.0, Double(ordered.count) * 1.2 / Double(ringCount))
- let lower = max(0, Int((centre - reach).rounded(.up)))
- let upper = min(last, Int((centre + reach).rounded(.down)))
- guard lower <= upper else { return [ordered[min(max(0, Int(centre)), last)]] }
- return Array(ordered[lower...upper])
+ /// One press of the shift button, one step along here.
+ ///
+ /// Chainrings arrive smallest first and cogs largest first, so the walk
+ /// starts at the easiest gear anyone would ride and finishes at the hardest.
+ /// Every step is strictly harder than the one before, which is what makes a
+ /// two-button controller make sense.
+ static func synchronisedSequence(
+ rings: [Int],
+ cogs: [Int]
+ ) -> [(chainring: Int, cog: Int)] {
+ guard let firstRing = rings.first, !cogs.isEmpty else { return [] }
+ guard rings.count > 1 else {
+ return cogs.map { (chainring: firstRing, cog: $0) }
+ }
+
+ let last = cogs.count - 1
+ // Never ban so much of a small cassette that a chainring loses the cogs
+ // that bridge it to the next one — that is the mistake the old
+ // proportional rule made, only inverted.
+ let limit = min(min(crossChainCogLimit, max(1, cogs.count / 4)), last)
+
+ // Which part of the cassette each chainring is allowed to reach. The
+ // smallest ring keeps the easy end, the largest keeps the hard end, and
+ // neither is allowed near the other's corner — that is what stops
+ // small-small and big-big appearing.
+ func window(forRingAt position: Int) -> ClosedRange {
+ let lower = position == 0 ? 0 : limit
+ let upper = position == rings.count - 1 ? last : last - limit
+ return lower...max(lower, upper)
+ }
+
+ func ratio(_ position: Int, _ cogIndex: Int) -> Double {
+ Double(rings[position]) / Double(cogs[cogIndex])
+ }
+
+ var sequence: [(ring: Int, cog: Int)] = [(0, 0)]
+ var ring = 0
+ var cog = 0
+
+ while true {
+ // Still cogs left on this chainring: take one.
+ if cog + 1 <= window(forRingAt: ring).upperBound {
+ cog += 1
+ sequence.append((ring, cog))
+ continue
+ }
+
+ // Out of cassette. Change chainring, and land on the cog that makes
+ // the change feel like the cassette step just taken. This is the
+ // compensating rear shift a real electronic groupset pairs with
+ // every front change.
+ let current = ratio(ring, cog)
+ let wanted = cog > 0 ? current / ratio(ring, cog - 1) : 1.10
+ var moved = false
+
+ for next in (ring + 1)..= 1 + perceptibleStepFraction else { continue }
+ let error = abs(Foundation.log(step / wanted))
+ if error < closest {
+ closest = error
+ landing = candidate
+ }
+ }
+ guard let landing else { continue }
+ ring = next
+ cog = landing
+ sequence.append((ring, cog))
+ moved = true
+ break
+ }
+
+ if !moved { break }
+ }
+
+ return sequence.map { (chainring: rings[$0.ring], cog: cogs[$0.cog]) }
}
- /// The starting gear is the one the trainer's real wheel size maps onto, so
- /// every other gear is scaled away from it. The trainer accepts a limited
- /// range, and that range is lopsided: a gear can be made about 2.3 times
- /// harder than the reference but 3.2 times easier. Centring on the middle
- /// gear therefore wastes the margin, so the reference is placed where the
- /// tighter of the two ends has the most room left.
- private static func centredReferenceIndex(
+ /// The gear the ride starts in: the one nearest ``startingRatio`` whose
+ /// whole ladder the trainer can still cover. Declared rather than derived,
+ /// so editing an unrelated safety number cannot move it.
+ static func startingGearIndex(
of gears: [VirtualGear],
scaleRange: ClosedRange
) -> Int? {
@@ -180,25 +254,19 @@ public struct Drivetrain: Equatable, Sendable {
else {
return nil
}
- let headroom = Foundation.log(scaleRange.upperBound)
- let legroom = -Foundation.log(scaleRange.lowerBound)
- guard headroom > 0, legroom > 0 else { return nil }
-
- // How much of the available room the worst end would use, as a fraction.
- // Anything above 1 does not fit.
- func worstUse(_ ratio: Double) -> Double {
- max(
- Foundation.log(hardest / ratio) / headroom,
- Foundation.log(ratio / easiest) / legroom
- )
+
+ func fits(_ ratio: Double) -> Bool {
+ scaleRange.contains(easiest / ratio)
+ && scaleRange.contains(hardest / ratio)
}
- guard let best = gears.indices.min(by: {
- worstUse(gears[$0].ratio) < worstUse(gears[$1].ratio)
- }) else {
- return nil
+ func distance(_ ratio: Double) -> Double {
+ abs(Foundation.log(ratio / startingRatio))
}
- return worstUse(gears[best].ratio) <= 1 ? best : nil
+
+ return gears.indices
+ .filter { fits(gears[$0].ratio) }
+ .min { distance(gears[$0].ratio) < distance(gears[$1].ratio) }
}
public let chainrings: [Int]
diff --git a/Sources/VirtualGearsCore/DrivetrainCatalog.swift b/Sources/VirtualGearsCore/DrivetrainCatalog.swift
index fc9344d..b03782f 100644
--- a/Sources/VirtualGearsCore/DrivetrainCatalog.swift
+++ b/Sources/VirtualGearsCore/DrivetrainCatalog.swift
@@ -50,82 +50,77 @@ public struct CassetteOption: Identifiable, Equatable, Sendable {
}
}
-/// Real parts a rider can buy, so a saved setup shifts like the bike it names.
+/// The parts the shipped groupsets are made of.
+///
+/// Every entry here appears in at least one entry of ``GroupsetCatalog``, so
+/// every entry is a thing you can buy and can be checked against a spec sheet.
+/// Picking a groupset is the fast path; these lists are the escape hatch for
+/// anyone whose bike mixes parts, so nobody with an unlisted combination is
+/// stranded.
public enum DrivetrainCatalog {
public static let chainrings: [ChainringOption] = [
- // Single rings, smallest to largest.
- .init(id: "1x30", teeth: [30], note: "Very easy climbing"),
- .init(id: "1x32", teeth: [32], note: "Mountain bike"),
- .init(id: "1x34", teeth: [34], note: "Mountain bike"),
- .init(id: "1x36", teeth: [36], note: "Gravel"),
+ // Single rings.
.init(id: "1x38", teeth: [38], note: "Gravel"),
.init(id: "1x40", teeth: [40], note: "Gravel, most common"),
.init(id: "1x42", teeth: [42], note: "Fast gravel"),
.init(id: "1x44", teeth: [44], note: "Fast gravel"),
.init(id: "1x46", teeth: [46], note: "Road"),
- .init(id: "1x48", teeth: [48], note: "Road"),
- .init(id: "1x50", teeth: [50], note: "Road, fast"),
- // Two rings.
+ // Two rings, Shimano.
.init(id: "2x50-34", teeth: [50, 34], note: "Compact road, most common"),
.init(id: "2x52-36", teeth: [52, 36], note: "Mid-compact road"),
.init(id: "2x53-39", teeth: [53, 39], note: "Standard road, racing"),
- .init(id: "2x46-33", teeth: [46, 33], note: "SRAM road"),
- .init(id: "2x48-35", teeth: [48, 35], note: "SRAM road, fast"),
- .init(id: "2x43-30", teeth: [43, 30], note: "SRAM gravel"),
+ .init(id: "2x54-40", teeth: [54, 40], note: "Dura-Ace, racing"),
.init(id: "2x48-31", teeth: [48, 31], note: "Shimano GRX gravel"),
.init(id: "2x46-30", teeth: [46, 30], note: "Gravel, easy climbing"),
- .init(id: "2x45-29", teeth: [45, 29], note: "Campagnolo road"),
- // Three rings.
- .init(id: "3x50-39-30", teeth: [50, 39, 30], note: "Classic road triple"),
- .init(id: "3x44-32-22", teeth: [44, 32, 22], note: "Classic mountain triple"),
+ // Two rings, SRAM.
+ .init(id: "2x50-37", teeth: [50, 37], note: "SRAM road, racing"),
+ .init(id: "2x48-35", teeth: [48, 35], note: "SRAM road, fast"),
+ .init(id: "2x46-33", teeth: [46, 33], note: "SRAM road"),
+ .init(id: "2x43-30", teeth: [43, 30], note: "SRAM Wide, gravel"),
+
+ // Two rings, Campagnolo.
+ .init(id: "2x54-39", teeth: [54, 39], note: "Campagnolo racing"),
+ .init(id: "2x48-32", teeth: [48, 32], note: "Campagnolo road"),
+ .init(id: "2x45-29", teeth: [45, 29], note: "Campagnolo, easy climbing"),
]
public static let cassettes: [CassetteOption] = [
- // 8 speed
- .init(id: "8s-11-28", cogs: [11, 13, 15, 17, 19, 21, 24, 28], note: "Road"),
- .init(id: "8s-11-32", cogs: [11, 13, 15, 18, 21, 24, 28, 32], note: "Touring"),
-
- // 9 speed
- .init(id: "9s-11-28", cogs: [11, 12, 13, 14, 16, 18, 21, 24, 28], note: "Road"),
- .init(id: "9s-11-34", cogs: [11, 13, 15, 17, 20, 23, 26, 30, 34], note: "Touring"),
-
- // 10 speed
- .init(id: "10s-11-28", cogs: [11, 12, 13, 14, 15, 17, 19, 21, 24, 28], note: "Road"),
- .init(id: "10s-11-32", cogs: [11, 12, 14, 16, 18, 20, 22, 25, 28, 32], note: "Road, easy climbing"),
- .init(id: "10s-11-36", cogs: [11, 13, 15, 17, 19, 21, 24, 28, 32, 36], note: "Mountain"),
- .init(id: "10s-11-42", cogs: [11, 13, 15, 18, 21, 24, 28, 32, 36, 42], note: "Mountain, wide"),
-
- // 11 speed
+ // 11 speed, Shimano.
.init(id: "11s-11-28", cogs: [11, 12, 13, 14, 15, 17, 19, 21, 23, 25, 28], note: "Road, racing"),
.init(id: "11s-11-30", cogs: [11, 12, 13, 14, 15, 17, 19, 21, 24, 27, 30], note: "Road"),
- .init(id: "11s-11-32", cogs: [11, 12, 13, 14, 15, 17, 19, 21, 24, 28, 32], note: "Road, most common"),
+ .init(id: "11s-11-32", cogs: [11, 12, 13, 14, 16, 18, 20, 22, 25, 28, 32], note: "Road, most common"),
.init(id: "11s-11-34", cogs: [11, 13, 15, 17, 19, 21, 23, 25, 27, 30, 34], note: "Road, easy climbing"),
- .init(id: "11s-10-42", cogs: [10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42], note: "SRAM mountain"),
- .init(id: "11s-11-42", cogs: [11, 13, 15, 17, 19, 22, 25, 28, 32, 37, 42], note: "Mountain"),
- .init(id: "11s-11-46", cogs: [11, 13, 15, 17, 19, 21, 24, 28, 32, 37, 46], note: "Mountain, wide"),
-
- // 12 speed
- .init(id: "12s-11-30", cogs: [11, 12, 13, 14, 15, 16, 17, 19, 21, 24, 27, 30], note: "Shimano road, close gaps"),
- .init(id: "12s-11-34", cogs: [11, 12, 13, 14, 15, 17, 19, 21, 24, 27, 30, 34], note: "Shimano road"),
- .init(id: "12s-11-36", cogs: [11, 12, 13, 14, 15, 17, 19, 21, 24, 28, 32, 36], note: "Shimano GRX gravel"),
+ .init(id: "11s-12-28", cogs: [12, 13, 14, 15, 16, 17, 19, 21, 23, 25, 28], note: "Road, close gaps"),
+
+ // 12 speed, Shimano.
+ .init(id: "12s-11-28", cogs: [11, 12, 13, 14, 15, 16, 17, 19, 21, 23, 25, 28], note: "Road, racing"),
+ .init(id: "12s-11-30", cogs: [11, 12, 13, 14, 15, 16, 17, 19, 21, 24, 27, 30], note: "Road, close gaps"),
+ .init(id: "12s-11-34", cogs: [11, 12, 13, 14, 15, 17, 19, 21, 24, 27, 30, 34], note: "Road"),
+ .init(id: "12s-11-36", cogs: [11, 12, 13, 14, 15, 17, 19, 21, 24, 28, 32, 36], note: "Gravel"),
+
+ // 12 speed, SRAM.
.init(id: "12s-10-28", cogs: [10, 11, 12, 13, 14, 15, 16, 17, 19, 21, 24, 28], note: "SRAM road, racing"),
+ .init(id: "12s-10-30", cogs: [10, 11, 12, 13, 14, 15, 16, 17, 19, 21, 25, 30], note: "SRAM road"),
.init(id: "12s-10-33", cogs: [10, 11, 12, 13, 14, 15, 17, 19, 21, 24, 28, 33], note: "SRAM road"),
.init(id: "12s-10-36", cogs: [10, 11, 12, 13, 15, 17, 19, 21, 24, 28, 32, 36], note: "SRAM road, easy climbing"),
.init(id: "12s-10-44", cogs: [10, 11, 12, 13, 15, 17, 19, 21, 24, 28, 35, 44], note: "SRAM XPLR gravel"),
- .init(id: "12s-11-50", cogs: [11, 13, 15, 17, 19, 21, 24, 28, 33, 39, 45, 50], note: "Shimano mountain"),
- .init(id: "12s-10-51", cogs: [10, 12, 14, 16, 18, 21, 24, 28, 33, 39, 45, 51], note: "Shimano mountain, wide"),
- .init(id: "12s-10-52", cogs: [10, 12, 14, 16, 18, 21, 24, 28, 32, 38, 44, 52], note: "SRAM mountain, widest"),
+ .init(id: "12s-10-52", cogs: [10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42, 52], note: "SRAM Eagle, widest"),
+
+ // 12 speed, Campagnolo.
+ .init(id: "12s-11-29", cogs: [11, 12, 13, 14, 15, 16, 17, 19, 21, 23, 26, 29], note: "Campagnolo road"),
+ .init(id: "12s-11-32", cogs: [11, 12, 13, 14, 15, 16, 17, 19, 21, 23, 27, 32], note: "Campagnolo road"),
- // 13 speed
+ // 13 speed, Campagnolo.
+ .init(id: "13s-10-29", cogs: [10, 11, 12, 13, 14, 15, 16, 17, 19, 21, 23, 26, 29], note: "Campagnolo racing"),
+ .init(id: "13s-11-32", cogs: [11, 12, 13, 14, 15, 16, 17, 19, 21, 23, 25, 28, 32], note: "Campagnolo, easy climbing"),
.init(id: "13s-9-36", cogs: [9, 10, 11, 12, 13, 14, 15, 16, 17, 19, 21, 23, 36], note: "Campagnolo Ekar gravel"),
.init(id: "13s-9-42", cogs: [9, 10, 11, 12, 13, 14, 16, 18, 20, 23, 27, 34, 42], note: "Campagnolo Ekar, wide"),
- .init(id: "13s-10-46", cogs: [10, 11, 12, 13, 15, 17, 19, 21, 24, 28, 32, 38, 46], note: "SRAM XPLR gravel, wide"),
+ .init(id: "13s-10-44", cogs: [10, 11, 12, 13, 15, 17, 19, 21, 24, 28, 32, 38, 44], note: "Campagnolo Ekar, widest"),
]
- /// A compact road bike with an 11-34 cassette: the setup most riders own, and
- /// a safe, wide starting point for anyone who does not care to change it.
+ /// A 105 R7100 with 50/34 and an 11-34: the most common setup on new bikes.
public static let defaultChainringID = "2x50-34"
public static let defaultCassetteID = "12s-11-34"
diff --git a/Sources/VirtualGearsCore/GearLadder.swift b/Sources/VirtualGearsCore/GearLadder.swift
new file mode 100644
index 0000000..94e944e
--- /dev/null
+++ b/Sources/VirtualGearsCore/GearLadder.swift
@@ -0,0 +1,167 @@
+import Foundation
+
+/// A ladder of virtual ratios that belongs to no real bike.
+///
+/// These are for riders who do not want to copy a groupset at all — a Zwift Cog
+/// on the trainer and a made-up set of evenly spaced gears is a perfectly good
+/// way to ride indoors, and it needs no knowledge of what is bolted to the bike.
+public struct GearLadder: Identifiable, Equatable, Sendable {
+ public let id: String
+ public let name: String
+ public let note: String
+ /// Ratios out of one hundred, easiest first, because these are not parts
+ /// anyone can buy and whole-number maths keeps the ordering exact.
+ public let ratiosHundredths: [Int]
+ /// The gear every ride starts in. Stated per ladder, never calculated, so
+ /// editing an unrelated safety number cannot move it.
+ public let startingIndex: Int
+
+ public init(
+ id: String,
+ name: String,
+ note: String,
+ ratiosHundredths: [Int],
+ startingIndex: Int
+ ) {
+ self.id = id
+ self.name = name
+ self.note = note
+ self.ratiosHundredths = ratiosHundredths
+ self.startingIndex = startingIndex
+ }
+
+ public var gearCount: Int { ratiosHundredths.count }
+
+ public var startingRatio: Double {
+ Double(ratiosHundredths[startingIndex]) / 100
+ }
+
+ public func drivetrain(
+ scaleRange: ClosedRange = TrainerSafety.supportedScaleRange
+ ) throws -> Drivetrain {
+ try Drivetrain.virtualLadder(ratiosHundredths: ratiosHundredths,
+ startingIndex: startingIndex,
+ scaleRange: scaleRange)
+ }
+}
+
+/// The rider's own gear count and range, used when they want something other
+/// than the one built-in ladder. Stored separately from `GearLadder` because it
+/// is parameters a rider can edit, not a fixed table.
+public struct CustomGearLadder: Codable, Equatable, Sendable {
+ public var gearCount: Int
+ public var easiestRatioHundredths: Int
+ public var hardestRatioHundredths: Int
+
+ public init(
+ gearCount: Int,
+ easiestRatioHundredths: Int,
+ hardestRatioHundredths: Int
+ ) {
+ self.gearCount = gearCount
+ self.easiestRatioHundredths = easiestRatioHundredths
+ self.hardestRatioHundredths = hardestRatioHundredths
+ }
+
+ /// Starts from the same numbers as the built-in ladder, so switching to
+ /// "Custom" for the first time changes nothing about how the bike rides
+ /// until the rider actually edits something.
+ public static let `default` = CustomGearLadder(
+ gearCount: 24,
+ easiestRatioHundredths: 75,
+ hardestRatioHundredths: 549
+ )
+
+ /// How many gears a custom ladder may have. Below this a "ladder" stops
+ /// meaning anything; above it the on-screen shift buttons would need more
+ /// taps than any real derailleur has sprockets.
+ public static let gearCountRange = 6...30
+
+ /// The same figures `TrainerSafety.supportedScaleRange` allows a built-in
+ /// ladder to reach, rounded to whole hundredths so a rider edits the same
+ /// units the note text shows.
+ public static var ratioHundredthsRange: ClosedRange {
+ let scale = TrainerSafety.supportedScaleRange
+ let lower = Int((scale.lowerBound * 100).rounded(.up))
+ let upper = Int((scale.upperBound * 100).rounded(.down))
+ return lower...upper
+ }
+}
+
+public enum GearLadderCatalog {
+ /// The twenty-four ratios published for the best-known virtual shifting
+ /// system, reproduced exactly.
+ ///
+ /// Named descriptively rather than after the product. Virtual Gears is not
+ /// affiliated with, endorsed by, or connected to Zwift, Wahoo, Shimano,
+ /// SRAM or Campagnolo; those names appear only to say what the gearing
+ /// copies.
+ public static let standardRange = GearLadder(
+ id: "standard-24",
+ name: "Standard 24",
+ note: "0.75 to 5.49, the common virtual ladder",
+ ratiosHundredths: [
+ 75, 87, 99, 111, 123, 138, 153, 168,
+ 186, 204, 222, 240, 261, 282, 303, 324,
+ 349, 374, 399, 424, 454, 484, 514, 549,
+ ],
+ startingIndex: 11
+ )
+
+ /// The one built-in ladder. A rider who wants something else defines their
+ /// own instead of choosing between several fixed tables that all belong to
+ /// no bike they own.
+ public static let ladders: [GearLadder] = [standardRange]
+
+ public static let defaultLadderID = standardRange.id
+
+ /// The id a saved configuration uses to mean "build the ladder from the
+ /// rider's own `CustomGearLadder` parameters instead of a fixed table."
+ public static let customLadderID = "custom"
+
+ public static func ladder(id: String) -> GearLadder? {
+ ladders.first { $0.id == id }
+ }
+
+ public static var defaultLadder: GearLadder {
+ ladder(id: defaultLadderID) ?? standardRange
+ }
+
+ /// Builds evenly spaced ratios from a rider's own gear count and range, the
+ /// same way every built-in ladder is shaped. The starting gear sits at the
+ /// same fractional position `standardRange` starts at, so a custom ladder
+ /// feels centred the same way rather than starting at one end.
+ public static func custom(_ params: CustomGearLadder) -> GearLadder {
+ let count = max(2, params.gearCount)
+ let easiest = min(
+ params.easiestRatioHundredths, params.hardestRatioHundredths
+ )
+ let hardest = max(
+ params.easiestRatioHundredths, params.hardestRatioHundredths
+ )
+ let ratios: [Int] = (0.. 1 else { return hardest }
+ let fraction = Double(index) / Double(count - 1)
+ return Int(
+ (Double(easiest) + fraction * Double(hardest - easiest))
+ .rounded()
+ )
+ }
+ let startingFraction = Double(standardRange.startingIndex)
+ / Double(standardRange.gearCount - 1)
+ let startingIndex = min(
+ count - 1,
+ max(0, Int((startingFraction * Double(count - 1)).rounded()))
+ )
+ return GearLadder(
+ id: customLadderID,
+ name: "Custom \(count)",
+ note: String(
+ format: "%.2f to %.2f, your own range",
+ Double(easiest) / 100, Double(hardest) / 100
+ ),
+ ratiosHundredths: ratios,
+ startingIndex: startingIndex
+ )
+ }
+}
diff --git a/Sources/VirtualGearsCore/GroupsetCatalog.swift b/Sources/VirtualGearsCore/GroupsetCatalog.swift
new file mode 100644
index 0000000..7b48365
--- /dev/null
+++ b/Sources/VirtualGearsCore/GroupsetCatalog.swift
@@ -0,0 +1,281 @@
+import Foundation
+
+public enum GroupsetBrand: String, CaseIterable, Identifiable, Sendable {
+ case shimano
+ case sram
+ case campagnolo
+
+ public var id: String { rawValue }
+
+ public var name: String {
+ switch self {
+ case .shimano: return "Shimano"
+ case .sram: return "SRAM"
+ case .campagnolo: return "Campagnolo"
+ }
+ }
+}
+
+/// A groupset you can actually buy, so the simulated gears match gearing the
+/// rider already knows.
+///
+/// Nothing physical shifts — the bike is parked in one gear all ride. Picking a
+/// groupset does not change what the bike does; it changes what the *ladder*
+/// looks like, so the gear count, the range and the size of each step feel
+/// familiar instead of arbitrary.
+public struct Groupset: Identifiable, Equatable, Sendable {
+ public let id: String
+ public let brand: GroupsetBrand
+ public let name: String
+ public let speeds: Int
+ public let chainringIDs: [String]
+ public let cassetteIDs: [String]
+ public let note: String
+
+ public init(
+ id: String,
+ brand: GroupsetBrand,
+ name: String,
+ speeds: Int,
+ chainringIDs: [String],
+ cassetteIDs: [String],
+ note: String = ""
+ ) {
+ self.id = id
+ self.brand = brand
+ self.name = name
+ self.speeds = speeds
+ self.chainringIDs = chainringIDs
+ self.cassetteIDs = cassetteIDs
+ self.note = note
+ }
+
+ public var chainrings: [ChainringOption] {
+ chainringIDs.compactMap(DrivetrainCatalog.chainring(id:))
+ }
+
+ public var cassettes: [CassetteOption] {
+ cassetteIDs.compactMap(DrivetrainCatalog.cassette(id:))
+ }
+
+ /// "Shimano 105 R7100" — how a rider would name it.
+ public var qualifiedName: String { "\(brand.name) \(name)" }
+}
+
+/// The groupsets Virtual Gears simulates.
+///
+/// Every entry is a real product with real chainring and cassette options, so
+/// every entry can be checked by hand and tested. The catalogue it replaced was
+/// twenty-two chainrings crossed with twenty-eight cassettes — six hundred and
+/// sixteen combinations, most of which exist on no bike anywhere, and which
+/// could not be validated because there was nothing to validate them against.
+///
+/// Weighted by what people own: Shimano is roughly seventy per cent of the
+/// market, SRAM twenty-six, Campagnolo three to four.
+public enum GroupsetCatalog {
+ public static let groupsets: [Groupset] = [
+ // MARK: Shimano
+ .init(
+ id: "shimano-dura-ace-r9200",
+ brand: .shimano,
+ name: "Dura-Ace R9200",
+ speeds: 12,
+ chainringIDs: ["2x54-40", "2x52-36", "2x50-34"],
+ cassetteIDs: ["12s-11-28", "12s-11-30", "12s-11-34"],
+ note: "Racing"
+ ),
+ // Eleven-speed Dura-Ace and Ultegra are here because eleven-speed is
+ // still the most common drivetrain sitting on a trainer: a very large
+ // installed base of bikes, and of spare cassettes bought for the
+ // trainer itself. Tiagra is deliberately absent — ten-speed is fading
+ // and adds nothing these do not already cover.
+ .init(
+ id: "shimano-dura-ace-r9100",
+ brand: .shimano,
+ name: "Dura-Ace R9100",
+ speeds: 11,
+ chainringIDs: ["2x53-39", "2x52-36", "2x50-34"],
+ cassetteIDs: ["11s-11-28", "11s-11-30", "11s-12-28"],
+ note: "Racing"
+ ),
+ .init(
+ id: "shimano-ultegra-r8100",
+ brand: .shimano,
+ name: "Ultegra R8100",
+ speeds: 12,
+ chainringIDs: ["2x52-36", "2x50-34"],
+ cassetteIDs: ["12s-11-30", "12s-11-34"],
+ note: "Road"
+ ),
+ .init(
+ id: "shimano-ultegra-r8000",
+ brand: .shimano,
+ name: "Ultegra R8000",
+ speeds: 11,
+ chainringIDs: ["2x53-39", "2x52-36", "2x50-34"],
+ cassetteIDs: [
+ "11s-11-28", "11s-11-30", "11s-11-32", "11s-11-34",
+ ],
+ note: "Road"
+ ),
+ .init(
+ id: "shimano-105-r7100",
+ brand: .shimano,
+ name: "105 R7100",
+ speeds: 12,
+ chainringIDs: ["2x52-36", "2x50-34"],
+ cassetteIDs: ["12s-11-34", "12s-11-36"],
+ note: "Most common on new bikes"
+ ),
+ .init(
+ id: "shimano-105-r7000",
+ brand: .shimano,
+ name: "105 R7000",
+ speeds: 11,
+ chainringIDs: ["2x52-36", "2x50-34"],
+ cassetteIDs: [
+ "11s-11-28", "11s-11-30", "11s-11-32", "11s-11-34",
+ ],
+ note: "Road"
+ ),
+ .init(
+ id: "shimano-grx-rx820",
+ brand: .shimano,
+ name: "GRX RX820",
+ speeds: 12,
+ chainringIDs: ["2x48-31"],
+ cassetteIDs: ["12s-11-34", "12s-11-36"],
+ note: "Gravel"
+ ),
+ .init(
+ id: "shimano-grx-rx820-1x",
+ brand: .shimano,
+ name: "GRX RX820 1x",
+ speeds: 12,
+ chainringIDs: ["1x40", "1x42"],
+ cassetteIDs: ["12s-11-34", "12s-11-36"],
+ note: "Gravel, single ring"
+ ),
+ .init(
+ id: "shimano-grx-rx810",
+ brand: .shimano,
+ name: "GRX RX810",
+ speeds: 11,
+ chainringIDs: ["2x48-31", "2x46-30"],
+ cassetteIDs: ["11s-11-34"],
+ note: "Gravel"
+ ),
+
+ // MARK: SRAM
+ .init(
+ id: "sram-red-axs",
+ brand: .sram,
+ name: "Red AXS",
+ speeds: 12,
+ chainringIDs: ["2x50-37", "2x48-35", "2x46-33"],
+ cassetteIDs: ["12s-10-28", "12s-10-30", "12s-10-33"],
+ note: "Racing"
+ ),
+ .init(
+ id: "sram-force-axs",
+ brand: .sram,
+ name: "Force AXS",
+ speeds: 12,
+ chainringIDs: ["2x50-37", "2x48-35", "2x46-33", "2x43-30"],
+ cassetteIDs: [
+ "12s-10-28", "12s-10-30", "12s-10-33", "12s-10-36",
+ ],
+ note: "Road"
+ ),
+ .init(
+ id: "sram-rival-axs",
+ brand: .sram,
+ name: "Rival AXS",
+ speeds: 12,
+ chainringIDs: ["2x48-35", "2x46-33", "2x43-30"],
+ cassetteIDs: ["12s-10-30", "12s-10-33", "12s-10-36"],
+ note: "Road"
+ ),
+ .init(
+ id: "sram-xplr-1x",
+ brand: .sram,
+ name: "Force / Rival XPLR",
+ speeds: 12,
+ chainringIDs: ["1x38", "1x40", "1x42", "1x44", "1x46"],
+ cassetteIDs: ["12s-10-44"],
+ note: "Gravel, single ring"
+ ),
+ .init(
+ id: "sram-apex-axs-1x",
+ brand: .sram,
+ name: "Apex AXS 1x",
+ speeds: 12,
+ chainringIDs: ["1x38", "1x40", "1x42", "1x44", "1x46"],
+ cassetteIDs: ["12s-10-44", "12s-10-52"],
+ note: "Gravel, wide range"
+ ),
+
+ // MARK: Campagnolo
+ .init(
+ id: "campagnolo-super-record-13",
+ brand: .campagnolo,
+ name: "Super Record 13",
+ speeds: 13,
+ chainringIDs: ["2x54-39", "2x52-36", "2x50-34", "2x48-32", "2x45-29"],
+ cassetteIDs: ["13s-10-29", "13s-11-32"],
+ note: "Racing"
+ ),
+ .init(
+ id: "campagnolo-chorus",
+ brand: .campagnolo,
+ name: "Chorus",
+ speeds: 12,
+ chainringIDs: ["2x52-36", "2x50-34", "2x48-32"],
+ cassetteIDs: ["12s-11-29", "12s-11-32", "12s-11-34"],
+ note: "Road"
+ ),
+ .init(
+ id: "campagnolo-ekar",
+ brand: .campagnolo,
+ name: "Ekar",
+ speeds: 13,
+ chainringIDs: ["1x38", "1x40", "1x42", "1x44"],
+ cassetteIDs: ["13s-9-36", "13s-9-42", "13s-10-44"],
+ note: "Gravel, single ring"
+ ),
+ ]
+
+ /// A 105 R7100 with 50/34 and an 11-34: the single most common setup on new
+ /// bikes, and the obvious thing to hand someone who has not chosen yet.
+ public static let defaultGroupsetID = "shimano-105-r7100"
+
+ public static func groupset(id: String) -> Groupset? {
+ groupsets.first { $0.id == id }
+ }
+
+ public static var defaultGroupset: Groupset {
+ groupset(id: defaultGroupsetID) ?? groupsets[0]
+ }
+
+ public static func groupsets(brand: GroupsetBrand) -> [Groupset] {
+ groupsets.filter { $0.brand == brand }
+ }
+
+ /// The groupset a pair of parts belongs to, if any. Used to show a saved
+ /// setup by the name printed on the bike rather than as two part numbers.
+ ///
+ /// Several groupsets share the same parts — 50/34 with an 11-34 is sold on
+ /// everything from 105 to Dura-Ace — so the default is preferred when it
+ /// fits. Guessing the most expensive groupset a rider *might* own is worse
+ /// than guessing the most common one they probably do.
+ public static func groupset(
+ chainringID: String,
+ cassetteID: String
+ ) -> Groupset? {
+ let matches = groupsets.filter {
+ $0.chainringIDs.contains(chainringID)
+ && $0.cassetteIDs.contains(cassetteID)
+ }
+ return matches.first { $0.id == defaultGroupsetID } ?? matches.first
+ }
+}
diff --git a/Sources/VirtualGearsCore/ParkedGear.swift b/Sources/VirtualGearsCore/ParkedGear.swift
new file mode 100644
index 0000000..8855482
--- /dev/null
+++ b/Sources/VirtualGearsCore/ParkedGear.swift
@@ -0,0 +1,207 @@
+import Foundation
+
+/// The gear the bike is left sitting in on the trainer.
+///
+/// The bike never shifts. It is parked in one gear for the whole ride, and
+/// Virtual Gears changes gear by changing the wheel size the trainer works
+/// from. What the rider's legs feel is therefore
+///
+/// feel ∝ parked ratio × wheel circumference we set
+///
+/// The app only controls the second half of that. Until this type existed it
+/// silently assumed the first half equalled its own starting gear, so a rider
+/// parked in the big ring on the smallest cog got a ladder that was ninety per
+/// cent harder than the one on the screen — the easy half simply did not exist.
+///
+/// The step *sizes* were always right, because the scaling is relative. Only the
+/// position of the whole ladder was wrong, which is exactly why it never looked
+/// like a bug.
+public struct ParkedGear: Codable, Equatable, Hashable, Sendable {
+ public let chainringTeeth: Int
+ public let cogTeeth: Int
+
+ public init?(chainringTeeth: Int, cogTeeth: Int) {
+ guard chainringTeeth > 0, cogTeeth > 0 else { return nil }
+ self.chainringTeeth = chainringTeeth
+ self.cogTeeth = cogTeeth
+ }
+
+ public var ratio: Double {
+ Double(chainringTeeth) / Double(cogTeeth)
+ }
+
+ /// The way a rider says it out loud: "fifty, fifteen".
+ public var name: String {
+ "\(chainringTeeth)/\(cogTeeth)"
+ }
+}
+
+/// What the rider physically has on the trainer, as opposed to the gearing they
+/// asked the app to simulate. The two are unrelated: you can ride a single-sprocket
+/// Zwift Cog and simulate a twelve-speed groupset, and most riders will.
+public struct PhysicalSetup: Codable, Equatable, Sendable {
+ /// Largest first, matching how a groupset is named.
+ public var chainringTeeth: [Int]
+ /// Smallest first. A single value means a single sprocket, such as a Zwift Cog.
+ public var cogTeeth: [Int]
+ /// Nil until the rider has confirmed it. Setup is not finished until they have,
+ /// because guessing this quietly corrupts every gear.
+ public var parkedChainringTeeth: Int?
+ public var parkedCogTeeth: Int?
+
+ /// A compact road bike with an 11-34 cassette — the setup most riders own.
+ public static let `default` = PhysicalSetup(
+ chainringTeeth: [50, 34],
+ cogTeeth: [11, 12, 13, 14, 15, 17, 19, 21, 24, 27, 30, 34]
+ )
+
+ /// The single sprocket a Zwift Cog replaces a cassette with, used instead
+ /// of a groupset's real cassette on many indoor-only setups.
+ public static let zwiftCogTeeth = [14]
+
+ public init(
+ chainringTeeth: [Int],
+ cogTeeth: [Int],
+ parkedChainringTeeth: Int? = nil,
+ parkedCogTeeth: Int? = nil
+ ) {
+ self.chainringTeeth = chainringTeeth
+ self.cogTeeth = cogTeeth
+ self.parkedChainringTeeth = parkedChainringTeeth
+ self.parkedCogTeeth = parkedCogTeeth
+ }
+
+ /// True when the bike has one sprocket rather than a cassette, so only the
+ /// chainring is worth asking about.
+ public var isSingleSprocket: Bool { cogTeeth.count == 1 }
+
+ /// The gear the rider confirmed, if they have confirmed one.
+ public var parkedGear: ParkedGear? {
+ guard let parkedChainringTeeth, let parkedCogTeeth else { return nil }
+ return ParkedGear(
+ chainringTeeth: parkedChainringTeeth,
+ cogTeeth: parkedCogTeeth
+ )
+ }
+
+ public mutating func park(in gear: ParkedGear) {
+ parkedChainringTeeth = gear.chainringTeeth
+ parkedCogTeeth = gear.cogTeeth
+ }
+}
+
+/// Works out which gear to tell the rider to park in.
+///
+/// README already says to leave the bike in a quiet, straight chain line. That
+/// guidance is right; it just stops one question short, because it never asks
+/// which gear that turned out to be. Rather than ask an open question, the app
+/// names the gear — the quietest one that still works — and lets the rider
+/// confirm or correct it.
+public enum ParkedGearAdvice {
+ /// How much clearance to keep from the hard limits, so a rider sitting right
+ /// on the edge does not lose their top gear to a rounding tenth.
+ public static let margin = 1.05
+
+ /// Every parked ratio that lets *all* of a drivetrain's gears reach the
+ /// trainer, at every wheel size a riding app may ask for.
+ ///
+ /// The hard end is a real limit: the command tops out at 6553.5 mm, so at a
+ /// 2400 mm wheel the hardest gear needs
+ /// `parked ratio ≥ hardest ratio / 2.73`. Park below that and the top of the
+ /// ladder silently stops working the moment a riding app sets a big wheel.
+ /// This is why "small ring, middle cog" cannot be a fixed sentence: on a
+ /// 105 it lands on 34/17 = 2.00, on a GRX 31/17 = 1.82, both under the floor
+ /// for a full virtual ladder.
+ public static func workableRatios(
+ for drivetrain: Drivetrain,
+ scaleRange: ClosedRange = TrainerSafety.supportedScaleRange
+ ) -> ClosedRange? {
+ guard let easiest = drivetrain.gears.first?.ratio,
+ let hardest = drivetrain.gears.last?.ratio,
+ easiest > 0, hardest > 0
+ else {
+ return nil
+ }
+ let lowest = hardest / scaleRange.upperBound
+ let highest = easiest / scaleRange.lowerBound
+ guard lowest <= highest else { return nil }
+ return lowest...highest
+ }
+
+ /// The gear to recommend: the quietest one that still works.
+ ///
+ /// Indoors the trainer is the loudest thing in the room and its flywheel
+ /// speed is set by the parked ratio, so a lower parked ratio is a quieter
+ /// ride — a middle cog on the small ring spins the flywheel around half as
+ /// fast as the big ring on the smallest cog. Badly cross-chained corners are
+ /// excluded, so the chain line stays straight too.
+ ///
+ /// Because the app compensates for whatever is confirmed, the parked gear
+ /// can be chosen purely for quietness.
+ public static func suggestion(
+ for setup: PhysicalSetup,
+ simulating drivetrain: Drivetrain
+ ) -> ParkedGear? {
+ guard let workable = workableRatios(for: drivetrain) else { return nil }
+ let floor = workable.lowerBound * margin
+ let ceiling = workable.upperBound / margin
+
+ let candidates = usableParkedGears(in: setup)
+ .filter { $0.ratio >= floor && $0.ratio <= ceiling }
+
+ // Quietest first. If nothing clears the margin, fall back to anything
+ // that merely works rather than leaving the rider without a suggestion.
+ if let quietest = candidates.min(by: { $0.ratio < $1.ratio }) {
+ return quietest
+ }
+ return usableParkedGears(in: setup)
+ .filter { workable.contains($0.ratio) }
+ .min { $0.ratio < $1.ratio }
+ }
+
+ /// True when the confirmed gear puts part of the ladder out of the trainer's
+ /// reach, so the app can say so plainly instead of failing mid-ride.
+ public static func isWorkable(
+ _ gear: ParkedGear,
+ simulating drivetrain: Drivetrain
+ ) -> Bool {
+ guard let workable = workableRatios(for: drivetrain) else { return false }
+ return workable.contains(gear.ratio)
+ }
+
+ /// The gears worth parking in: real combinations of the rider's own parts,
+ /// with the cross-chained corners left out.
+ public static func usableParkedGears(
+ in setup: PhysicalSetup
+ ) -> [ParkedGear] {
+ let rings = setup.chainringTeeth.sorted()
+ let cogs = setup.cogTeeth.sorted(by: >)
+ guard !rings.isEmpty, !cogs.isEmpty else { return [] }
+ guard rings.count > 1 else {
+ return cogs.compactMap {
+ ParkedGear(chainringTeeth: rings[0], cogTeeth: $0)
+ }
+ }
+
+ let last = cogs.count - 1
+ let limit = min(
+ min(Drivetrain.crossChainCogLimit, max(1, cogs.count / 4)),
+ last
+ )
+ var gears: [ParkedGear] = []
+ for (position, ring) in rings.enumerated() {
+ let lower = position == 0 ? 0 : limit
+ let upper = position == rings.count - 1 ? last : last - limit
+ guard lower <= upper else { continue }
+ for index in lower...upper {
+ if let gear = ParkedGear(
+ chainringTeeth: ring,
+ cogTeeth: cogs[index]
+ ) {
+ gears.append(gear)
+ }
+ }
+ }
+ return gears
+ }
+}
diff --git a/Sources/VirtualGearsCore/ProxyCoordinator.swift b/Sources/VirtualGearsCore/ProxyCoordinator.swift
index 6c6e1a0..725a907 100644
--- a/Sources/VirtualGearsCore/ProxyCoordinator.swift
+++ b/Sources/VirtualGearsCore/ProxyCoordinator.swift
@@ -305,8 +305,9 @@ public final class ProxyCoordinator {
shiftingID id: UUID
) async {
do {
+ let parkedGear = configuration.parkedGear
guard let drivetrain = configuration.drivetrain,
- AppConfiguration.isSafe(drivetrain) else {
+ AppConfiguration.isSafe(drivetrain, parkedGear: parkedGear) else {
throw ProductBluetoothError.commandFailed(
"These gears are outside the trainer's safe range"
)
@@ -323,7 +324,11 @@ public final class ProxyCoordinator {
var wheelSize = wheelSizeCameFromRidingApp
? (trainerWheelSizeMillimeters ?? reference)
: reference
- if !canBuildGears(around: wheelSize, drivetrain: drivetrain) {
+ if !canBuildGears(
+ around: wheelSize,
+ drivetrain: drivetrain,
+ parkedGear: parkedGear
+ ) {
log(
"Your riding app left a \(Int(wheelSize.rounded())) mm wheel "
+ "size. Gears built around it would reach outside the "
@@ -337,7 +342,8 @@ public final class ProxyCoordinator {
trainerWheelSizeMillimeters = wheelSize
gearEngine = try ConfirmedGearEngine(
drivetrain: drivetrain,
- wheelSizeMillimeters: wheelSize
+ wheelSizeMillimeters: wheelSize,
+ parkedGear: parkedGear
)
gearSequence = drivetrain.gears
updateDisplayedGear()
@@ -592,7 +598,10 @@ public final class ProxyCoordinator {
public func changeDrivetrain(_ configuration: AppConfiguration) async -> Bool {
guard lifecycle.isShifting, let id = lifecycle.shiftingID,
let drivetrain = configuration.drivetrain,
- AppConfiguration.isSafe(drivetrain) else { return false }
+ AppConfiguration.isSafe(
+ drivetrain,
+ parkedGear: configuration.parkedGear
+ ) else { return false }
// Nothing may suspend between the wait and the claim below. Two
// rebuilds would otherwise both see the flag clear and interleave.
guard await waitForGearsToSettle(id) else {
@@ -609,7 +618,8 @@ public final class ProxyCoordinator {
do {
let rebuilt = try ConfirmedGearEngine(
drivetrain: drivetrain,
- wheelSizeMillimeters: wheelSize
+ wheelSizeMillimeters: wheelSize,
+ parkedGear: configuration.parkedGear
)
let command = rebuilt.confirmedSetting.command
// Shifting can be stopped while the trainer is answering. Writing
@@ -654,11 +664,13 @@ public final class ProxyCoordinator {
/// push a gear outside that must not carry into shifting.
private func canBuildGears(
around millimeters: Double,
- drivetrain: Drivetrain
+ drivetrain: Drivetrain,
+ parkedGear: ParkedGear?
) -> Bool {
(try? ConfirmedGearEngine(
drivetrain: drivetrain,
- wheelSizeMillimeters: millimeters
+ wheelSizeMillimeters: millimeters,
+ parkedGear: parkedGear
)) != nil
}
@@ -1197,7 +1209,8 @@ extension ProxyCoordinator {
let engine = try? ConfirmedGearEngine(
drivetrain: drivetrain,
wheelSizeMillimeters:
- TrainerSafety.referenceCircumferenceMillimeters
+ TrainerSafety.referenceCircumferenceMillimeters,
+ parkedGear: configuration.parkedGear
)
else { return }
@@ -1223,5 +1236,13 @@ extension ProxyCoordinator {
public func stageScreenshotReconnecting() {
lifecycle.markReconnecting()
}
+
+ public func stageScreenshotStopping() {
+ _ = lifecycle.beginStopping()
+ }
+
+ public func stageScreenshotRidingAppWheelSize() {
+ wheelSizeCameFromRidingApp = true
+ }
}
#endif
diff --git a/Sources/VirtualGearsCore/TrainerSafety.swift b/Sources/VirtualGearsCore/TrainerSafety.swift
index 5f59910..6d3e633 100644
--- a/Sources/VirtualGearsCore/TrainerSafety.swift
+++ b/Sources/VirtualGearsCore/TrainerSafety.swift
@@ -25,7 +25,9 @@ import Foundation
public enum TrainerSafety {
/// The wheel size the trainer is left sitting at, and the size every gear is
/// scaled away from.
- public static let referenceCircumferenceMillimeters: Double = 2_070
+ /// The default when neither the rider nor the riding app supplied a value.
+ /// 2105 mm is the documented circumference for a 700x25 road wheel.
+ public static let referenceCircumferenceMillimeters: Double = 2_105
/// The wheel sizes a riding app may set, and the promise the tests enforce:
/// every gear must build at every size in here.
diff --git a/Tests/VirtualGearsCoreTests/AppConfigurationTests.swift b/Tests/VirtualGearsCoreTests/AppConfigurationTests.swift
index c882fbb..ff5effa 100644
--- a/Tests/VirtualGearsCoreTests/AppConfigurationTests.swift
+++ b/Tests/VirtualGearsCoreTests/AppConfigurationTests.swift
@@ -5,9 +5,12 @@ import XCTest
/// exist because those rules were being satisfied in one place and quietly
/// skipped in another, which left anyone installing the app unable to ride.
final class AppConfigurationTests: XCTestCase {
+ /// A rider who has chosen a trainer and confirmed which gear the bike is
+ /// parked in — the two things a ride genuinely needs.
private func trainerReady() -> AppConfiguration {
var configuration = AppConfiguration()
configuration.rememberKickr(named: "KICKR CORE", id: UUID())
+ configuration.parkInSuggestion()
return configuration
}
@@ -31,6 +34,16 @@ final class AppConfigurationTests: XCTestCase {
XCTAssertTrue(configuration.setupComplete)
}
+ /// A trainer on its own is not enough any more. The app also has to know
+ /// which gear the bike is left sitting in, because that is what every
+ /// virtual gear is scaled from and guessing it moves the whole ladder.
+ func testATrainerAloneIsNotEnoughWithoutTheParkedGear() {
+ var configuration = AppConfiguration()
+ configuration.rememberKickr(named: "KICKR CORE", id: UUID())
+ XCTAssertTrue(configuration.hasValidKickr)
+ XCTAssertFalse(configuration.canFinishSetup)
+ }
+
func testRememberingATrainerStoresSomethingTheAppCanReconnectTo() {
let id = UUID()
var configuration = AppConfiguration()
@@ -95,16 +108,173 @@ final class AppConfigurationTests: XCTestCase {
XCTAssertTrue(configuration.usesVirtualGears)
XCTAssertNotNil(configuration.drivetrain)
XCTAssertTrue(configuration.hasSafeCircumference)
- XCTAssertEqual(configuration.drivetrainName, "Virtual gears")
+ XCTAssertEqual(configuration.drivetrainName, "Standard 24")
XCTAssertEqual(
configuration.gearSummary,
- "24 gears · extra-low climbing range"
+ "24 gears · 0.75 to 5.49, the common virtual ladder"
)
}
- func testNormalWheelSizeDefaultsTo2070Millimeters() {
+ /// A rider who never opened Custom still has parameters to fall back on
+ /// if they later switch, and they start from the same numbers as Standard
+ /// so switching to Custom for the first time changes nothing on its own.
+ func testUnusedCustomLadderDefaultsMatchStandard() {
let configuration = AppConfiguration()
- XCTAssertEqual(configuration.neutralCircumferenceMillimeters, 2_070)
+ XCTAssertFalse(configuration.usesCustomLadder)
+ XCTAssertEqual(configuration.customLadder.gearCount, 24)
+ XCTAssertEqual(configuration.customLadder.easiestRatioHundredths, 75)
+ XCTAssertEqual(configuration.customLadder.hardestRatioHundredths, 549)
+ }
+
+ /// Switching to Custom is what `gearLadder` should read once it happens,
+ /// and the ladder it builds should have the rider's own gear count.
+ func testSwitchingToCustomBuildsTheLadderFromTheRidersOwnParameters() {
+ var configuration = trainerReady()
+ configuration.customLadder = CustomGearLadder(
+ gearCount: 12,
+ easiestRatioHundredths: 100,
+ hardestRatioHundredths: 300
+ )
+ configuration.gearLadderID = GearLadderCatalog.customLadderID
+
+ XCTAssertTrue(configuration.usesCustomLadder)
+ XCTAssertEqual(configuration.gearLadder.gearCount, 12)
+ XCTAssertEqual(configuration.gearLadder.ratiosHundredths.first, 100)
+ XCTAssertEqual(configuration.gearLadder.ratiosHundredths.last, 300)
+ XCTAssertNotNil(configuration.drivetrain)
+ XCTAssertTrue(configuration.hasSafeCircumference)
+ }
+
+ /// The same safety check that catches an impossible real drivetrain also
+ /// has to catch an impossible custom ladder — a rider typing in a wide
+ /// range should be told plainly, not left with a ride that fails later.
+ func testACustomLadderThatIsTooWideForTheTrainerIsRejected() {
+ var configuration = trainerReady()
+ configuration.customLadder = CustomGearLadder(
+ gearCount: 24,
+ easiestRatioHundredths: 20,
+ hardestRatioHundredths: 2_000
+ )
+ configuration.gearLadderID = GearLadderCatalog.customLadderID
+
+ XCTAssertNil(configuration.drivetrain)
+ XCTAssertFalse(configuration.hasSafeCircumference)
+ XCTAssertFalse(configuration.canFinishSetup)
+ }
+
+ func testUnsafeParkedGearDoesNotMakeSafeGearingLookInvalid() throws {
+ var configuration = trainerReady()
+ let drivetrain = try XCTUnwrap(configuration.drivetrain)
+ let unsuitable = try XCTUnwrap(
+ ParkedGearAdvice.usableParkedGears(in: configuration.physical)
+ .first {
+ !ParkedGearAdvice.isWorkable($0, simulating: drivetrain)
+ }
+ )
+
+ configuration.park(in: unsuitable)
+
+ XCTAssertTrue(configuration.hasSafeGearing)
+ XCTAssertTrue(configuration.parkedGearPutsGearsOutOfReach)
+ XCTAssertFalse(configuration.hasSafeCircumference)
+ XCTAssertFalse(configuration.canFinishSetup)
+ }
+
+ /// A saved configuration from before Custom existed has no `customLadder`
+ /// key at all. Decoding must fall back rather than throw away the rest of
+ /// a rider's saved setup.
+ // MARK: - Setup guide
+
+ func testAFreshConfigurationHasNotCompletedTheSetupGuide() {
+ let configuration = AppConfiguration()
+ XCTAssertFalse(configuration.setupWizardCompleted)
+ }
+
+ func testCompletingTheSetupGuideMarksItComplete() {
+ var configuration = AppConfiguration()
+ configuration.parkInSuggestion()
+ XCTAssertTrue(configuration.completeSetupWizard())
+ XCTAssertTrue(configuration.setupWizardCompleted)
+ }
+
+ func testSetupGuideCannotCompleteWithoutAConfirmedParkedGear() {
+ var configuration = AppConfiguration()
+
+ XCTAssertFalse(configuration.completeSetupWizard())
+ XCTAssertFalse(configuration.setupWizardCompleted)
+ }
+
+ func testDecodingAConfigurationWithNoSetupWizardKeyFallsBackToIncomplete() throws {
+ var configuration = AppConfiguration()
+ configuration.completeSetupWizard()
+ var data = try JSONEncoder().encode(configuration)
+ var object = try JSONSerialization.jsonObject(
+ with: data
+ ) as! [String: Any]
+ object.removeValue(forKey: "setupWizardCompleted")
+ data = try JSONSerialization.data(withJSONObject: object)
+
+ let decoded = try JSONDecoder().decode(
+ AppConfiguration.self, from: data
+ )
+ XCTAssertFalse(decoded.setupWizardCompleted)
+ }
+
+ func testLegacyDeferredGuideMustRunTheMandatorySetup() throws {
+ let configuration = AppConfiguration()
+ var object = try JSONSerialization.jsonObject(
+ with: JSONEncoder().encode(configuration)
+ ) as! [String: Any]
+ object["setupWizardCompleted"] = true
+ object.removeValue(forKey: "setupWizardVersion")
+
+ let decoded = try JSONDecoder().decode(
+ AppConfiguration.self,
+ from: JSONSerialization.data(withJSONObject: object)
+ )
+
+ XCTAssertFalse(decoded.setupWizardCompleted)
+ }
+
+ func testLegacyGuideRunsAgainButKeepsItsBikeSetup() throws {
+ var configuration = AppConfiguration()
+ configuration.parkInSuggestion()
+ configuration.completeSetupWizard()
+ var object = try JSONSerialization.jsonObject(
+ with: JSONEncoder().encode(configuration)
+ ) as! [String: Any]
+ object.removeValue(forKey: "setupWizardVersion")
+
+ let decoded = try JSONDecoder().decode(
+ AppConfiguration.self,
+ from: JSONSerialization.data(withJSONObject: object)
+ )
+
+ XCTAssertFalse(decoded.setupWizardCompleted)
+ XCTAssertNotNil(decoded.parkedGear)
+ }
+
+ func testDecodingAConfigurationWithNoCustomLadderKeyFallsBackToDefault() throws {
+ var configuration = AppConfiguration()
+ configuration.rememberKickr(named: "KICKR CORE", id: UUID())
+ var data = try JSONEncoder().encode(configuration)
+ var object = try JSONSerialization.jsonObject(
+ with: data
+ ) as! [String: Any]
+ object.removeValue(forKey: "customLadder")
+ data = try JSONSerialization.data(withJSONObject: object)
+
+ let decoded = try JSONDecoder().decode(
+ AppConfiguration.self, from: data
+ )
+ XCTAssertEqual(decoded.customLadder, .default)
+ XCTAssertFalse(decoded.usesCustomLadder)
+ }
+
+ func testNormalWheelSizeDefaultsTo700x25Circumference() {
+ let configuration = AppConfiguration()
+ XCTAssertNil(configuration.normalWheelCircumferenceMillimeters)
+ XCTAssertEqual(configuration.neutralCircumferenceMillimeters, 2_105)
}
func testNormalWheelSizeCanBeChangedInsideTheSupportedRange() {
@@ -125,7 +295,17 @@ final class AppConfigurationTests: XCTestCase {
XCTAssertFalse(
configuration.setNormalWheelCircumference(millimeters: 2_401)
)
- XCTAssertEqual(configuration.neutralCircumferenceMillimeters, 2_070)
+ XCTAssertEqual(configuration.neutralCircumferenceMillimeters, 2_105)
+ }
+
+ func testNormalWheelSizeCanReturnToTheDefault() {
+ var configuration = AppConfiguration()
+ configuration.setNormalWheelCircumference(millimeters: 2_200)
+
+ configuration.useDefaultWheelCircumference()
+
+ XCTAssertNil(configuration.normalWheelCircumferenceMillimeters)
+ XCTAssertEqual(configuration.neutralCircumferenceMillimeters, 2_105)
}
/// Gears wider than the trainer can copy must block a ride rather than be
@@ -205,10 +385,18 @@ final class AppConfigurationTests: XCTestCase {
configuration.cassetteID = cassette.id
guard configuration.drivetrain != nil else { continue }
- XCTAssertEqual(
- configuration.drivetrainName,
- "\(chainring.name) · \(cassette.name)"
- )
+ if let groupset = configuration.groupset {
+ XCTAssertEqual(
+ configuration.drivetrainName,
+ "\(groupset.qualifiedName) · "
+ + "\(chainring.name) \(cassette.name)"
+ )
+ } else {
+ XCTAssertEqual(
+ configuration.drivetrainName,
+ "\(chainring.name) · \(cassette.name)"
+ )
+ }
for description in expectedDescriptions
where configuration.gearSummary.contains(description) {
observedDescriptions.insert(description)
@@ -262,6 +450,14 @@ final class AppConfigurationTests: XCTestCase {
XCTAssertTrue(configuration.hasValidKickr)
XCTAssertFalse(configuration.usesHeadwind)
- XCTAssertEqual(configuration.neutralCircumferenceMillimeters, 2_070)
+ XCTAssertEqual(configuration.neutralCircumferenceMillimeters, 2_105)
+ // Fields added later fall back to their defaults rather than throwing
+ // the whole saved setup away.
+ XCTAssertEqual(
+ configuration.gearLadderID,
+ GearLadderCatalog.defaultLadderID
+ )
+ XCTAssertEqual(configuration.physical, .default)
+ XCTAssertNil(configuration.parkedGear)
}
}
diff --git a/Tests/VirtualGearsCoreTests/DemoRideStateTests.swift b/Tests/VirtualGearsCoreTests/DemoRideStateTests.swift
index 5ec3f23..d53d362 100644
--- a/Tests/VirtualGearsCoreTests/DemoRideStateTests.swift
+++ b/Tests/VirtualGearsCoreTests/DemoRideStateTests.swift
@@ -69,7 +69,10 @@ final class DemoRideStateTests: XCTestCase {
func testTheDemoReportsTheRealWheelSizeForTheGear() throws {
var state = DemoRideState(configuration: .demo)
let drivetrain = try XCTUnwrap(AppConfiguration.demo.drivetrain)
- let reference = drivetrain.referenceGear.ratio
+ // Gears are scaled from the gear the bike is actually parked in, not
+ // from the ladder's own starting gear. On the demo bike those differ,
+ // which is exactly the case that used to be silently wrong.
+ let reference = try XCTUnwrap(AppConfiguration.demo.parkedGear).ratio
XCTAssertEqual(
state.wheelSizeMillimeters,
diff --git a/Tests/VirtualGearsCoreTests/DrivetrainTests.swift b/Tests/VirtualGearsCoreTests/DrivetrainTests.swift
index eaba5a3..7998809 100644
--- a/Tests/VirtualGearsCoreTests/DrivetrainTests.swift
+++ b/Tests/VirtualGearsCoreTests/DrivetrainTests.swift
@@ -98,22 +98,25 @@ final class DrivetrainTests: XCTestCase {
}
/// Two combinations can land on the identical ratio, and two gear numbers
- /// that feel the same would be two shifts that do nothing.
+ /// that feel the same would be two shifts that do nothing. Walking the
+ /// drivetrain cannot produce one: every step is strictly harder than the
+ /// last, by more than a rider can feel.
func testBuildKeepsOnlyOneGearPerDistinctRatio() throws {
let drivetrain = try Drivetrain.build(
- chainrings: [50, 25],
- cassetteCogs: [10, 20]
+ chainrings: [50, 34],
+ cassetteCogs: [11, 12, 13, 14, 15, 17, 19, 21, 24, 27, 30, 34]
)
- XCTAssertEqual(
- drivetrain.gears.map { "\($0.chainring)x\($0.cog)" },
- ["25x20", "25x10", "50x10"]
- )
+ let ratios = drivetrain.gears.map(\.ratio)
+ XCTAssertEqual(Set(ratios).count, ratios.count)
+ for (previous, next) in zip(ratios, ratios.dropFirst()) {
+ XCTAssertGreaterThan(next, previous)
+ }
}
- /// The trainer can be pushed about 2.3x harder than the starting gear but
- /// about 4.1x easier. There is more room downwards, so on a wide mountain setup
- /// the starting gear sits above the middle of the range, not on it.
+ /// The starting gear is the one nearest the declared 2.40, not wherever the
+ /// range happened to leave room. On a wide mountain setup that puts it above
+ /// the middle of the ladder rather than on it.
func testBuildPlacesStartingGearWhereBothEndsFit() throws {
let drivetrain = try Drivetrain.build(
chainrings: [32],
@@ -337,7 +340,7 @@ final class DrivetrainTests: XCTestCase {
referenceRatio: reference,
selectedRatio: drivetrain.gears.first!.ratio
),
- 517.5,
+ 657.8125,
accuracy: 0.001
)
XCTAssertEqual(
@@ -347,7 +350,7 @@ final class DrivetrainTests: XCTestCase {
referenceRatio: reference,
selectedRatio: drivetrain.gears.last!.ratio
),
- 4_735.125,
+ 4_815.1875,
accuracy: 0.001
)
for gear in drivetrain.gears {
diff --git a/Tests/VirtualGearsCoreTests/GearLadderCatalogTests.swift b/Tests/VirtualGearsCoreTests/GearLadderCatalogTests.swift
new file mode 100644
index 0000000..0f85763
--- /dev/null
+++ b/Tests/VirtualGearsCoreTests/GearLadderCatalogTests.swift
@@ -0,0 +1,88 @@
+import XCTest
+@testable import VirtualGearsCore
+
+/// `GearLadderCatalog.custom(_:)` is what turns a rider's own gear count and
+/// range into the same evenly-spaced-ratio shape as the one built-in ladder,
+/// so these tests exist to pin down the maths a rider cannot see: the count,
+/// the ordering, the endpoints and where the ride starts.
+final class GearLadderCatalogTests: XCTestCase {
+ func testCustomBuildsTheRequestedGearCount() {
+ let ladder = GearLadderCatalog.custom(
+ CustomGearLadder(
+ gearCount: 10,
+ easiestRatioHundredths: 100,
+ hardestRatioHundredths: 200
+ )
+ )
+ XCTAssertEqual(ladder.gearCount, 10)
+ XCTAssertEqual(ladder.ratiosHundredths.count, 10)
+ }
+
+ func testCustomRatiosAreEvenlySpacedFromEasiestToHardest() {
+ let ladder = GearLadderCatalog.custom(
+ CustomGearLadder(
+ gearCount: 5,
+ easiestRatioHundredths: 100,
+ hardestRatioHundredths: 500
+ )
+ )
+ XCTAssertEqual(ladder.ratiosHundredths, [100, 200, 300, 400, 500])
+ }
+
+ /// A rider could type the easiest and hardest numbers the wrong way
+ /// round. The ladder should still come out easiest-first rather than
+ /// building a descending, unrideable list.
+ func testCustomSortsEasiestAndHardestRegardlessOfInputOrder() {
+ let ladder = GearLadderCatalog.custom(
+ CustomGearLadder(
+ gearCount: 5,
+ easiestRatioHundredths: 500,
+ hardestRatioHundredths: 100
+ )
+ )
+ XCTAssertEqual(ladder.ratiosHundredths, [100, 200, 300, 400, 500])
+ }
+
+ /// The starting gear should sit at the same fractional position the
+ /// built-in ladder starts at (roughly the middle, slightly toward the
+ /// easy side), not always at one fixed index regardless of gear count.
+ func testCustomStartingIndexScalesWithGearCount() {
+ let twelve = GearLadderCatalog.custom(
+ CustomGearLadder(
+ gearCount: 12,
+ easiestRatioHundredths: 100,
+ hardestRatioHundredths: 300
+ )
+ )
+ let twentyFour = GearLadderCatalog.custom(
+ CustomGearLadder(
+ gearCount: 24,
+ easiestRatioHundredths: 100,
+ hardestRatioHundredths: 300
+ )
+ )
+ XCTAssertEqual(twentyFour.startingIndex, GearLadderCatalog.standardRange.startingIndex)
+ XCTAssertLessThan(twelve.startingIndex, twelve.gearCount)
+ XCTAssertGreaterThanOrEqual(twelve.startingIndex, 0)
+ // Roughly proportional: the fraction should be close between sizes.
+ let fractionTwelve = Double(twelve.startingIndex) / Double(twelve.gearCount - 1)
+ let fractionTwentyFour = Double(twentyFour.startingIndex) / Double(twentyFour.gearCount - 1)
+ XCTAssertEqual(fractionTwelve, fractionTwentyFour, accuracy: 0.1)
+ }
+
+ /// A drivetrain still has to be buildable from the generated ratios — the
+ /// same guarantee every built-in ladder gives.
+ func testCustomLadderBuildsARideableDrivetrain() throws {
+ let ladder = GearLadderCatalog.custom(.default)
+ let drivetrain = try ladder.drivetrain()
+ XCTAssertEqual(drivetrain.gears.count, 24)
+ }
+
+ func testCustomLadderIDIsReservedAndNeverMatchesABuiltInLadder() {
+ XCTAssertFalse(
+ GearLadderCatalog.ladders.contains {
+ $0.id == GearLadderCatalog.customLadderID
+ }
+ )
+ }
+}
diff --git a/Tests/VirtualGearsCoreTests/ParkedGearTests.swift b/Tests/VirtualGearsCoreTests/ParkedGearTests.swift
new file mode 100644
index 0000000..bcfff1a
--- /dev/null
+++ b/Tests/VirtualGearsCoreTests/ParkedGearTests.swift
@@ -0,0 +1,363 @@
+import XCTest
+@testable import VirtualGearsCore
+
+/// The bug these were written for was invisible, which is why it needed
+/// finding by measurement rather than by riding.
+///
+/// The bike never shifts. It is parked in one gear, and what the rider's legs
+/// feel is that parked ratio times the wheel size the app sets. The app only
+/// controlled the second half and assumed the first, so the step sizes were
+/// always right and the whole ladder was in the wrong place. A rider parked in
+/// the big ring on the smallest cog was riding a ladder ninety per cent harder
+/// than the one on the screen and would only ever have reported that the app
+/// "has no easy gears".
+final class ParkedGearTests: XCTestCase {
+ private let compact = PhysicalSetup(
+ chainringTeeth: [50, 34],
+ cogTeeth: [11, 12, 13, 14, 15, 17, 19, 21, 24, 27, 30, 34]
+ )
+
+ private func ladder() throws -> Drivetrain {
+ try GearLadderCatalog.standardRange.drivetrain()
+ }
+
+ // MARK: - The maths
+
+ func testRejectsImpossibleToothCounts() {
+ XCTAssertNil(ParkedGear(chainringTeeth: 0, cogTeeth: 15))
+ XCTAssertNil(ParkedGear(chainringTeeth: 34, cogTeeth: 0))
+ XCTAssertNil(ParkedGear(chainringTeeth: -34, cogTeeth: 15))
+ }
+
+ func testAParkedGearIsNamedTheWayARiderSaysIt() throws {
+ let gear = try XCTUnwrap(ParkedGear(chainringTeeth: 34, cogTeeth: 15))
+ XCTAssertEqual(gear.name, "34/15")
+ XCTAssertEqual(gear.ratio, 34.0 / 15.0, accuracy: 0.0001)
+ }
+
+ /// The whole point: the wheel size sent for a gear is scaled by the gear
+ /// the bike is actually in, not by the gear the app started in.
+ func testTheWheelSizeSentIsScaledByTheParkedGear() throws {
+ let drivetrain = try ladder()
+ let parked = try XCTUnwrap(
+ ParkedGear(chainringTeeth: 34, cogTeeth: 15)
+ )
+ let engine = try ConfirmedGearEngine(
+ drivetrain: drivetrain,
+ wheelSizeMillimeters: 2_105,
+ parkedGear: parked
+ )
+
+ for change in drivetrain.gears.indices.map({
+ try? engineChange(engine, at: $0)
+ }) {
+ let change = try XCTUnwrap(change)
+ XCTAssertEqual(
+ change.circumferenceMillimeters,
+ 2_105 * change.gear.ratio / parked.ratio,
+ accuracy: 0.001
+ )
+ }
+ }
+
+ /// A rider whose bike happens to be parked in the starting gear sees
+ /// exactly what the app did before any of this existed.
+ func testParkingInTheStartingGearChangesNothing() throws {
+ let drivetrain = try ladder()
+ let assumed = try ConfirmedGearEngine(
+ drivetrain: drivetrain,
+ wheelSizeMillimeters: 2_105
+ )
+ XCTAssertEqual(
+ assumed.parkedRatio,
+ drivetrain.referenceGear.ratio,
+ accuracy: 0.0001
+ )
+ XCTAssertEqual(
+ assumed.confirmedSetting.circumferenceMillimeters,
+ 2_105,
+ accuracy: 0.001
+ )
+ }
+
+ /// Parked in a harder gear than assumed, every gear on the screen is really
+ /// harder than it says. A rider left in 50/11 is riding gear 1 as a 1.14,
+ /// which is why the easy half of the ladder appears not to exist.
+ func testParkingInTheBigRingMakesEveryGearHarderThanItLooks() throws {
+ let drivetrain = try ladder()
+ let bigRing = try XCTUnwrap(
+ ParkedGear(chainringTeeth: 50, cogTeeth: 15)
+ )
+ let assumed = try ConfirmedGearEngine(
+ drivetrain: drivetrain,
+ wheelSizeMillimeters: 2_105
+ )
+ let corrected = try ConfirmedGearEngine(
+ drivetrain: drivetrain,
+ wheelSizeMillimeters: 2_105,
+ parkedGear: bigRing
+ )
+
+ // Correcting for it asks the trainer for a smaller wheel, which is what
+ // cancels the harder gear out.
+ XCTAssertLessThan(
+ corrected.confirmedSetting.circumferenceMillimeters,
+ assumed.confirmedSetting.circumferenceMillimeters
+ )
+ }
+
+ /// Only the position of the ladder was ever wrong. Every step between gears
+ /// stays exactly the same, which is precisely why nobody would have
+ /// reported this as a bug.
+ func testTheStepBetweenGearsDoesNotDependOnTheParkedGear() throws {
+ let drivetrain = try ladder()
+ let quiet = try XCTUnwrap(ParkedGear(chainringTeeth: 34, cogTeeth: 15))
+ let engines = [
+ try ConfirmedGearEngine(
+ drivetrain: drivetrain,
+ wheelSizeMillimeters: 2_105
+ ),
+ try ConfirmedGearEngine(
+ drivetrain: drivetrain,
+ wheelSizeMillimeters: 2_105,
+ parkedGear: quiet
+ ),
+ ]
+ let ratios = try engines.map { engine -> [Double] in
+ let sizes = try drivetrain.gears.indices.map {
+ try engineChange(engine, at: $0).circumferenceMillimeters
+ }
+ return zip(sizes, sizes.dropFirst()).map { $1 / $0 }
+ }
+ for (assumed, corrected) in zip(ratios[0], ratios[1]) {
+ XCTAssertEqual(assumed, corrected, accuracy: 0.0001)
+ }
+ }
+
+ /// Changing wheel size mid-ride must not quietly forget which gear the bike
+ /// is sitting in.
+ func testRebasingKeepsTheParkedGear() throws {
+ let parked = try XCTUnwrap(ParkedGear(chainringTeeth: 34, cogTeeth: 15))
+ let engine = try ConfirmedGearEngine(
+ drivetrain: try ladder(),
+ wheelSizeMillimeters: 2_105,
+ parkedGear: parked
+ )
+ XCTAssertEqual(try engine.rebased(wheelSizeMillimeters: 2_326).parkedGear, parked)
+ }
+
+ // MARK: - The floor
+
+ /// The hard limit, and the reason "small ring, middle cog" cannot be a
+ /// fixed sentence. The command tops out at 6553.5 mm, so at a 2400 mm wheel
+ /// a full 5.49 ladder needs a parked ratio of at least 2.011.
+ func testTheFullLadderNeedsAParkedRatioOfAtLeastTwoPointZeroOne() throws {
+ let range = try XCTUnwrap(
+ ParkedGearAdvice.workableRatios(for: try ladder())
+ )
+ XCTAssertEqual(range.lowerBound, 2.0105, accuracy: 0.001)
+ XCTAssertEqual(range.upperBound, 3.125, accuracy: 0.001)
+ }
+
+ /// Literal middle-cog advice puts a 105 on 34/17 = 2.00, under the floor,
+ /// and the top of the ladder would silently stop working.
+ func testTheLiteralMiddleCogWouldBreakTheTopOfTheLadder() throws {
+ let drivetrain = try ladder()
+ let literal = try XCTUnwrap(ParkedGear(chainringTeeth: 34, cogTeeth: 17))
+ XCTAssertFalse(
+ ParkedGearAdvice.isWorkable(literal, simulating: drivetrain)
+ )
+ let suggested = try XCTUnwrap(
+ ParkedGearAdvice.suggestion(for: compact, simulating: drivetrain)
+ )
+ XCTAssertTrue(
+ ParkedGearAdvice.isWorkable(suggested, simulating: drivetrain)
+ )
+ }
+
+ /// A real groupset asks less of the trainer than a full virtual ladder, so
+ /// its floor is lower and there is more choice of where to park.
+ func testARealDrivetrainHasAWiderChoiceOfParkedGears() throws {
+ let drivetrain = try Drivetrain.build(
+ chainrings: compact.chainringTeeth,
+ cassetteCogs: compact.cogTeeth
+ )
+ let range = try XCTUnwrap(
+ ParkedGearAdvice.workableRatios(for: drivetrain)
+ )
+ XCTAssertLessThan(range.lowerBound, 2.0)
+ XCTAssertGreaterThan(range.upperBound, 4.0)
+ }
+
+ // MARK: - What to recommend
+
+ /// Quietest that still works. On a 105 the cassette has no 16, so 34/17 is
+ /// under the floor and the answer is 34/15.
+ func testTheSuggestionForACompactIsThirtyFourFifteen() throws {
+ let suggestion = try XCTUnwrap(
+ ParkedGearAdvice.suggestion(for: compact, simulating: try ladder())
+ )
+ XCTAssertEqual(suggestion.name, "34/15")
+ }
+
+ /// A Zwift Cog has one sprocket, so only the chainring is worth asking
+ /// about, and 31/14 lands in the same quiet band as everything else.
+ func testASingleSprocketIsRecommendedAsItself() throws {
+ let cog = PhysicalSetup(chainringTeeth: [31], cogTeeth: [14])
+ XCTAssertTrue(cog.isSingleSprocket)
+ let suggestion = try XCTUnwrap(
+ ParkedGearAdvice.suggestion(for: cog, simulating: try ladder())
+ )
+ XCTAssertEqual(suggestion.name, "31/14")
+ XCTAssertEqual(suggestion.ratio, 31.0 / 14.0, accuracy: 0.0001)
+ }
+
+ /// Indoors the trainer is the loudest thing in the room and its flywheel
+ /// speed follows the parked ratio, so the recommendation should always be
+ /// far quieter than the worst case a rider might otherwise leave it in.
+ func testTheSuggestionIsMuchQuieterThanTheBigRingOnTheSmallestCog() throws {
+ let suggestion = try XCTUnwrap(
+ ParkedGearAdvice.suggestion(for: compact, simulating: try ladder())
+ )
+ XCTAssertLessThan(suggestion.ratio, (50.0 / 11.0) * 0.6)
+ }
+
+ /// The suggestion has to come from the rider's own parts, not from a table.
+ func testTheSuggestionIsAlwaysAGearTheRiderActuallyHas() throws {
+ let drivetrain = try ladder()
+ for groupset in GroupsetCatalog.groupsets {
+ for chainring in groupset.chainrings {
+ for cassette in groupset.cassettes {
+ let setup = PhysicalSetup(
+ chainringTeeth: chainring.teeth,
+ cogTeeth: cassette.cogs
+ )
+ guard let suggestion = ParkedGearAdvice.suggestion(
+ for: setup,
+ simulating: drivetrain
+ ) else { continue }
+ XCTAssertTrue(
+ chainring.teeth.contains(suggestion.chainringTeeth),
+ "\(groupset.name) \(chainring.name)"
+ )
+ XCTAssertTrue(
+ cassette.cogs.contains(suggestion.cogTeeth),
+ "\(groupset.name) \(cassette.name)"
+ )
+ }
+ }
+ }
+ }
+
+ /// The corners a rider is told never to use are not somewhere to leave the
+ /// bike parked for an hour either.
+ func testTheSuggestionIsNeverACrossChainedCorner() throws {
+ let drivetrain = try ladder()
+ let suggestion = try XCTUnwrap(
+ ParkedGearAdvice.suggestion(for: compact, simulating: drivetrain)
+ )
+ XCTAssertNotEqual(suggestion.cogTeeth, compact.cogTeeth.min())
+ XCTAssertNotEqual(suggestion.cogTeeth, compact.cogTeeth.max())
+ }
+
+ // MARK: - Setup will not finish without it
+
+ func testSetupIsNotFinishedUntilTheParkedGearIsConfirmed() {
+ var configuration = AppConfiguration()
+ configuration.rememberKickr(named: "KICKR CORE", id: UUID())
+ XCTAssertNil(configuration.parkedGear)
+ XCTAssertFalse(configuration.canFinishSetup)
+
+ configuration.parkInSuggestion()
+
+ XCTAssertNotNil(configuration.parkedGear)
+ XCTAssertTrue(configuration.canFinishSetup)
+ }
+
+ func testConfirmingTheSuggestionIsASingleTap() throws {
+ var configuration = AppConfiguration()
+ configuration.rememberKickr(named: "KICKR CORE", id: UUID())
+ let suggestion = try XCTUnwrap(configuration.suggestedParkedGear)
+ configuration.parkInSuggestion()
+ XCTAssertEqual(configuration.parkedGear, suggestion)
+ XCTAssertFalse(configuration.parkedGearPutsGearsOutOfReach)
+ XCTAssertNil(configuration.parkedGearWarning)
+ }
+
+ /// Do not just compute the consequence — say it. A rider who confirms
+ /// something far from the recommendation is told what it costs.
+ func testAnUnworkableParkedGearIsExplainedRatherThanRefused() throws {
+ var configuration = AppConfiguration()
+ configuration.rememberKickr(named: "KICKR CORE", id: UUID())
+ configuration.park(
+ in: try XCTUnwrap(ParkedGear(chainringTeeth: 50, cogTeeth: 11))
+ )
+
+ XCTAssertTrue(configuration.parkedGearPutsGearsOutOfReach)
+ let warning = try XCTUnwrap(configuration.parkedGearWarning)
+ XCTAssertTrue(warning.contains("cannot reach"))
+ XCTAssertFalse(configuration.hasSafeCircumference)
+ XCTAssertFalse(configuration.canFinishSetup)
+ }
+
+ /// The advice names a gear rather than asking an open question, and says
+ /// the bike stays in it.
+ func testTheAdviceNamesTheGearAndSaysTheBikeStaysThere() {
+ var configuration = AppConfiguration()
+ configuration.rememberKickr(named: "KICKR CORE", id: UUID())
+ let advice = configuration.parkedGearAdviceText
+ XCTAssertTrue(advice.contains("34"))
+ XCTAssertTrue(advice.contains("15"))
+ XCTAssertTrue(advice.contains("straight chain line"))
+ XCTAssertTrue(advice.contains("whole ride"))
+ }
+
+ func testTheAdviceCallsASingleSprocketASprocket() {
+ var configuration = AppConfiguration()
+ configuration.rememberKickr(named: "KICKR CORE", id: UUID())
+ configuration.physical = PhysicalSetup(
+ chainringTeeth: [31],
+ cogTeeth: [14]
+ )
+ XCTAssertTrue(
+ configuration.parkedGearAdviceText.contains("sprocket")
+ )
+ }
+
+ /// A saved setup has to remember the parked gear, or the rider is asked
+ /// again every launch and the gears move if they answer differently.
+ func testTheParkedGearSurvivesBeingReloaded() throws {
+ var configuration = AppConfiguration()
+ configuration.rememberKickr(named: "KICKR CORE", id: UUID())
+ configuration.parkInSuggestion()
+ let restored = try JSONDecoder().decode(
+ AppConfiguration.self,
+ from: JSONEncoder().encode(configuration)
+ )
+ XCTAssertEqual(restored.parkedGear, configuration.parkedGear)
+ XCTAssertEqual(restored, configuration)
+ }
+
+ // MARK: - Helper
+
+ private func engineChange(
+ _ engine: ConfirmedGearEngine,
+ at index: Int
+ ) throws -> PendingGearChange {
+ var moving = engine
+ moving.requestShift(by: index - engine.confirmedIndex)
+ var change = moving.pendingChange
+ while let pending = moving.pendingChange {
+ change = pending
+ let bytes = Array(pending.command)
+ moving.acknowledge(
+ .wheelCircumference(
+ result: 1,
+ encodedTenthsOfMillimeter:
+ UInt16(bytes[1]) | UInt16(bytes[2]) << 8
+ )
+ )
+ }
+ return try XCTUnwrap(change ?? engine.confirmedSetting)
+ }
+}
diff --git a/Tests/VirtualGearsCoreTests/ProxyCoordinatorTests.swift b/Tests/VirtualGearsCoreTests/ProxyCoordinatorTests.swift
index 42c6f26..6dd4ea8 100644
--- a/Tests/VirtualGearsCoreTests/ProxyCoordinatorTests.swift
+++ b/Tests/VirtualGearsCoreTests/ProxyCoordinatorTests.swift
@@ -50,11 +50,12 @@ final class ProxyCoordinatorTests: XCTestCase {
private func makeConfiguration(
virtualGears: Bool = true,
- normalWheelSize: Int = 2_070
+ normalWheelSize: Int = 2_105
) -> AppConfiguration {
var configuration = AppConfiguration()
configuration.rememberKickr(named: "KICKR", id: trainer.selectedID!)
configuration.usesVirtualGears = virtualGears
+ configuration.parkInSuggestion()
XCTAssertTrue(
configuration.setNormalWheelCircumference(
millimeters: normalWheelSize
@@ -207,7 +208,7 @@ final class ProxyCoordinatorTests: XCTestCase {
XCTAssertEqual(afterNormalStop, 2_105)
// The same ride, but interrupted instead of stopped. A fresh start so
- // the ride begins on 2070 and the riding app moves it mid-ride, which
+ // the ride begins on 2105 and the riding app moves it mid-ride, which
// is the case where the record can go stale.
makeCoordinator()
let survivingDefaults = defaults!
@@ -244,7 +245,7 @@ final class ProxyCoordinatorTests: XCTestCase {
/// 700x25c (2105 mm) is an ordinary one. Gears built around anything above
/// ~2098 mm used to reach outside the range proven safe on the trainer, so
/// this size was carried into the next ride only by falling back to the
- /// neutral 2070 mm. The proven range now reaches 5000 mm, which leaves room
+ /// neutral 2105 mm. The proven range now reaches 5000 mm, which leaves room
/// to build the gears around the wheel the rider actually asked for, so it
/// is kept.
func testAParkedOrdinaryWheelSizeIsKeptForTheNextRide() async throws {
diff --git a/Tests/VirtualGearsCoreTests/RideabilityTests.swift b/Tests/VirtualGearsCoreTests/RideabilityTests.swift
new file mode 100644
index 0000000..9beef34
--- /dev/null
+++ b/Tests/VirtualGearsCoreTests/RideabilityTests.swift
@@ -0,0 +1,308 @@
+import XCTest
+@testable import VirtualGearsCore
+
+/// The tests that ask whether the gears are any good to ride, rather than
+/// whether they are well formed.
+///
+/// The old tests checked structure — ordering, no duplicates, that
+/// cross-chaining happened — and every one of them passed while the app was
+/// handing riders shifts too small to feel and holes wide enough to stall in.
+/// These run over every groupset the app ships, so a regression shows up on a
+/// real bike rather than a made-up one.
+final class RideabilityTests: XCTestCase {
+ /// Every chainring and cassette pairing of every shipped groupset: the
+ /// seventy-odd builds a rider could actually select.
+ private struct Build {
+ let name: String
+ let chainrings: [Int]
+ let cogs: [Int]
+ }
+
+ private var shippedBuilds: [Build] {
+ GroupsetCatalog.groupsets.flatMap { groupset in
+ groupset.chainrings.flatMap { chainring in
+ groupset.cassettes.map { cassette in
+ Build(
+ name: "\(groupset.qualifiedName) "
+ + "\(chainring.name) \(cassette.name)",
+ chainrings: chainring.teeth,
+ cogs: cassette.cogs
+ )
+ }
+ }
+ }
+ }
+
+ private func steps(_ drivetrain: Drivetrain) -> [Double] {
+ let ratios = drivetrain.gears.map(\.ratio)
+ return zip(ratios, ratios.dropFirst()).map { $1 / $0 - 1 }
+ }
+
+ func testEveryShippedGroupsetBuilds() throws {
+ let builds = shippedBuilds
+ XCTAssertGreaterThan(builds.count, 60)
+ for build in builds {
+ XCTAssertNoThrow(
+ try Drivetrain.build(
+ chainrings: build.chainrings,
+ cassetteCogs: build.cogs
+ ),
+ build.name
+ )
+ }
+ }
+
+ /// A shift the rider cannot feel is not a gear. The old algorithm merged
+ /// only *exactly* equal ratios, so near-identical ones survived: the
+ /// smallest step it produced anywhere was 0.4%, on twelve of these builds.
+ func testNoShiftIsTooSmallToFeel() throws {
+ for build in shippedBuilds {
+ let drivetrain = try Drivetrain.build(
+ chainrings: build.chainrings,
+ cassetteCogs: build.cogs
+ )
+ for step in steps(drivetrain) {
+ XCTAssertGreaterThanOrEqual(
+ step,
+ Drivetrain.perceptibleStepFraction,
+ "\(build.name) has a step of "
+ + String(format: "%.1f%%", step * 100)
+ )
+ }
+ }
+ }
+
+ /// Big jumps that come from the cassette itself are real and must survive —
+ /// an 11-34 genuinely steps 30 to 34. What must not survive is a hole *we*
+ /// made by deleting the cogs that bridge the two chainrings, which the old
+ /// proportional pruning did on five of these builds, once by 37%.
+ func testNoGapIsWiderThanTheCassetteAlreadyMakes() throws {
+ for build in shippedBuilds {
+ let drivetrain = try Drivetrain.build(
+ chainrings: build.chainrings,
+ cassetteCogs: build.cogs
+ )
+ let cogs = build.cogs.sorted(by: >)
+ let cassetteWorst = zip(cogs, cogs.dropFirst())
+ .map { Double($0) / Double($1) - 1 }
+ .max() ?? 0
+ let allowed = max(cassetteWorst, 0.25)
+ for step in steps(drivetrain) {
+ XCTAssertLessThanOrEqual(
+ step,
+ allowed + 0.001,
+ "\(build.name) has a gap of "
+ + String(format: "%.0f%%", step * 100)
+ )
+ }
+ }
+ }
+
+ /// The easiest gear a rider owns is the one they need on the steepest
+ /// climb, and the hardest is what they sprint in. Neither may be quietly
+ /// dropped in the name of tidying the ladder up.
+ func testTheEasiestAndHardestGearAreAlwaysKept() throws {
+ for build in shippedBuilds {
+ let drivetrain = try Drivetrain.build(
+ chainrings: build.chainrings,
+ cassetteCogs: build.cogs
+ )
+ XCTAssertEqual(
+ drivetrain.gears.first?.chainring,
+ build.chainrings.min(),
+ build.name
+ )
+ XCTAssertEqual(
+ drivetrain.gears.first?.cog,
+ build.cogs.max(),
+ build.name
+ )
+ XCTAssertEqual(
+ drivetrain.gears.last?.chainring,
+ build.chainrings.max(),
+ build.name
+ )
+ XCTAssertEqual(
+ drivetrain.gears.last?.cog,
+ build.cogs.min(),
+ build.name
+ )
+ }
+ }
+
+ /// A real electronic groupset in sequential mode gives a rider three to six
+ /// more gears than the cassette has cogs — a 2x11 lands on fourteen to
+ /// sixteen, which is exactly what Shimano quotes for Synchronized Shift.
+ /// Pinning the band stops the cross-chain limit drifting and quietly
+ /// handing riders a twenty-two speed bike.
+ func testGearCountsMatchWhatAnElectronicGroupsetGives() throws {
+ for build in shippedBuilds where build.chainrings.count > 1 {
+ let drivetrain = try Drivetrain.build(
+ chainrings: build.chainrings,
+ cassetteCogs: build.cogs
+ )
+ let extra = drivetrain.gears.count - build.cogs.count
+ XCTAssertGreaterThanOrEqual(extra, 3, build.name)
+ XCTAssertLessThanOrEqual(extra, 6, build.name)
+ }
+ }
+
+ /// A single chainring reaches the whole cassette, so the gears are the cogs
+ /// and nothing else.
+ func testASingleChainringGivesExactlyTheCassette() throws {
+ for build in shippedBuilds where build.chainrings.count == 1 {
+ let drivetrain = try Drivetrain.build(
+ chainrings: build.chainrings,
+ cassetteCogs: build.cogs
+ )
+ XCTAssertEqual(drivetrain.gears.count, build.cogs.count, build.name)
+ }
+ }
+
+ /// The chainring never gets smaller as the gear gets harder, and the gear
+ /// always does. This is what makes a two-button controller make sense.
+ func testEveryPressGivesAHarderGearOnTheSameOrABiggerRing() throws {
+ for build in shippedBuilds {
+ let drivetrain = try Drivetrain.build(
+ chainrings: build.chainrings,
+ cassetteCogs: build.cogs
+ )
+ let rings = drivetrain.gears.map(\.chainring)
+ XCTAssertEqual(rings, rings.sorted(), build.name)
+ let ratios = drivetrain.gears.map(\.ratio)
+ XCTAssertEqual(ratios, ratios.sorted(), build.name)
+ }
+ }
+
+ /// Big-big and small-small are the two corners a rider is told never to
+ /// use, and the two the old cross-product invented on every drivetrain.
+ func testTheCrossChainedCornersAreNeverOffered() throws {
+ for build in shippedBuilds where build.chainrings.count > 1 {
+ let drivetrain = try Drivetrain.build(
+ chainrings: build.chainrings,
+ cassetteCogs: build.cogs
+ )
+ let smallest = build.chainrings.min()
+ let biggest = build.chainrings.max()
+ for gear in drivetrain.gears {
+ if gear.chainring == smallest {
+ XCTAssertNotEqual(gear.cog, build.cogs.min(), build.name)
+ }
+ if gear.chainring == biggest {
+ XCTAssertNotEqual(gear.cog, build.cogs.max(), build.name)
+ }
+ }
+ }
+ }
+
+ // MARK: - The starting gear stays where it was put
+
+ /// The regression this pins. The starting gear used to be worked out from
+ /// `TrainerSafety`, so editing the wheel-size window moved the gear every
+ /// rider begins in — a compact twelve-speed shifted ten per cent harder the
+ /// last time that window was widened, and nobody noticed. It is declared
+ /// now, so widening the window has to leave it alone.
+ func testWideningTheWheelSizeWindowDoesNotMoveTheStartingGear() throws {
+ let cogs = [11, 12, 13, 14, 15, 17, 19, 21, 24, 27, 30, 34]
+ let normal = try Drivetrain.build(chainrings: [50, 34], cassetteCogs: cogs)
+ let wider = try Drivetrain.build(
+ chainrings: [50, 34],
+ cassetteCogs: cogs,
+ scaleRange: 0.2...(WahooKickrCommand.maximumCircumferenceMillimeters
+ / 2_600)
+ )
+
+ XCTAssertEqual(normal.referenceGear, wider.referenceGear)
+ XCTAssertEqual(normal.referenceIndex, wider.referenceIndex)
+ }
+
+ /// The default setup, pinned to the gear itself rather than an index, so a
+ /// catalogue edit that moves it shows up as a failure and not a shrug.
+ func testTheDefaultSetupStartsIn34By14() throws {
+ var configuration = AppConfiguration()
+ configuration.usesVirtualGears = false
+ let drivetrain = try XCTUnwrap(configuration.drivetrain)
+
+ XCTAssertEqual(drivetrain.gears.count, 16)
+ XCTAssertEqual(drivetrain.referenceGear.chainring, 34)
+ XCTAssertEqual(drivetrain.referenceGear.cog, 14)
+ // 2.4286, the neutral ratio other virtual shifting systems settle on.
+ XCTAssertEqual(drivetrain.referenceGear.ratio, 34.0 / 14.0, accuracy: 0.0001)
+ }
+
+ /// Every shipped build starts within a quarter of the declared 2.40, or the
+ /// gear a rider begins in would depend on which bike they own.
+ func testEveryShippedBuildStartsNearTheDeclaredRatio() throws {
+ for build in shippedBuilds {
+ let drivetrain = try Drivetrain.build(
+ chainrings: build.chainrings,
+ cassetteCogs: build.cogs
+ )
+ let ratio = drivetrain.referenceGear.ratio
+ XCTAssertGreaterThan(ratio, Drivetrain.startingRatio * 0.8, build.name)
+ XCTAssertLessThan(ratio, Drivetrain.startingRatio * 1.25, build.name)
+ }
+ }
+
+ // MARK: - Every gear still reaches the trainer
+
+ /// The old version of this checked one assumed parked gear. A rider parked
+ /// somewhere else got gears that encoded fine in setup and then ran out of
+ /// command range mid-ride, which is the failure this now covers.
+ func testEveryGearEncodesFromEveryRecommendedParkedGear() throws {
+ let window = TrainerSafety.supportedRidingAppCircumferenceMillimeters
+ for build in shippedBuilds {
+ let drivetrain = try Drivetrain.build(
+ chainrings: build.chainrings,
+ cassetteCogs: build.cogs
+ )
+ let setup = PhysicalSetup(
+ chainringTeeth: build.chainrings,
+ cogTeeth: build.cogs
+ )
+ let parked = try XCTUnwrap(
+ ParkedGearAdvice.suggestion(for: setup, simulating: drivetrain),
+ build.name
+ )
+ for wheelSize in [window.lowerBound, window.upperBound] {
+ XCTAssertNoThrow(
+ try ConfirmedGearEngine(
+ drivetrain: drivetrain,
+ wheelSizeMillimeters: wheelSize,
+ parkedGear: parked
+ ),
+ "\(build.name) parked in \(parked.name) at \(wheelSize) mm"
+ )
+ }
+ }
+ }
+
+ /// Both shipped virtual ladders, from every parked gear the app would
+ /// recommend for a real bike.
+ func testEveryLadderEncodesFromTheRecommendedParkedGear() throws {
+ let window = TrainerSafety.supportedRidingAppCircumferenceMillimeters
+ for ladder in GearLadderCatalog.ladders {
+ let drivetrain = try ladder.drivetrain()
+ for build in shippedBuilds {
+ let setup = PhysicalSetup(
+ chainringTeeth: build.chainrings,
+ cogTeeth: build.cogs
+ )
+ guard let parked = ParkedGearAdvice.suggestion(
+ for: setup,
+ simulating: drivetrain
+ ) else { continue }
+ for wheelSize in [window.lowerBound, window.upperBound] {
+ XCTAssertNoThrow(
+ try ConfirmedGearEngine(
+ drivetrain: drivetrain,
+ wheelSizeMillimeters: wheelSize,
+ parkedGear: parked
+ ),
+ "\(ladder.name) on \(build.name) parked in \(parked.name)"
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/VirtualGears.xcodeproj/project.pbxproj b/VirtualGears.xcodeproj/project.pbxproj
index b91dcce..0bac01f 100644
--- a/VirtualGears.xcodeproj/project.pbxproj
+++ b/VirtualGears.xcodeproj/project.pbxproj
@@ -20,6 +20,7 @@
A00000000000000000000009 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000009 /* Assets.xcassets */; };
A0000000000000000000000B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000B /* PrivacyInfo.xcprivacy */; };
A0000000000000000000000C /* HeadwindCentralService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000C /* HeadwindCentralService.swift */; };
+ A0000000000000000000000D /* SetupWizardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000D /* SetupWizardView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -38,6 +39,7 @@
A10000000000000000000007 /* FTMSPeripheral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FTMSPeripheral.swift; sourceTree = ""; };
A10000000000000000000009 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
A1000000000000000000000C /* HeadwindCentralService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadwindCentralService.swift; sourceTree = ""; };
+ A1000000000000000000000D /* SetupWizardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupWizardView.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -81,6 +83,7 @@
A10000000000000000000006 /* ClickCentralService.swift */,
A10000000000000000000007 /* FTMSPeripheral.swift */,
A1000000000000000000000C /* HeadwindCentralService.swift */,
+ A1000000000000000000000D /* SetupWizardView.swift */,
A10000000000000000000009 /* Assets.xcassets */,
20000000000000000000000B /* Info.plist */,
);
@@ -239,6 +242,7 @@
A00000000000000000000006 /* ClickCentralService.swift in Sources */,
A00000000000000000000007 /* FTMSPeripheral.swift in Sources */,
A0000000000000000000000C /* HeadwindCentralService.swift in Sources */,
+ A0000000000000000000000D /* SetupWizardView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -316,7 +320,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 11;
+ CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = MNW6SJT4V7;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VirtualGearsProduct/Info.plist;
@@ -341,7 +345,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 11;
+ CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = MNW6SJT4V7;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VirtualGearsProduct/Info.plist;
diff --git a/VirtualGearsProduct/AppConfiguration.swift b/VirtualGearsProduct/AppConfiguration.swift
index 0bcac37..6a52e0e 100644
--- a/VirtualGearsProduct/AppConfiguration.swift
+++ b/VirtualGearsProduct/AppConfiguration.swift
@@ -39,10 +39,167 @@ final class ConfigurationStore {
configuration.cassetteID = option.id
}
+ /// Choosing a groupset sets both parts at once, which is the whole point of
+ /// naming groupsets: a rider picks the bike they own rather than assembling
+ /// one part at a time from a list that includes pairings nobody sells.
+ func setGroupset(_ groupset: Groupset) {
+ if let chainring = groupset.chainrings.first {
+ configuration.chainringID = chainring.id
+ }
+ if let cassette = groupset.cassettes.first {
+ configuration.cassetteID = cassette.id
+ }
+ }
+
+ /// Whether the rider's real bike (chainrings, and cassette unless they
+ /// are on a single sprocket) already matches this simulated groupset. Used
+ /// to decide whether it is worth offering to bring the physical setup
+ /// along too, rather than always showing that offer even when there is
+ /// nothing left to do.
+ func physicalSetupMatches(_ groupset: Groupset) -> Bool {
+ guard let chainring = groupset.chainrings.first,
+ configuration.physical.chainringTeeth == chainring.teeth
+ else { return false }
+ guard !configuration.physical.isSingleSprocket else { return true }
+ guard let cassette = groupset.cassettes.first else { return false }
+ return configuration.physical.cogTeeth == cassette.cogs
+ }
+
+ /// Brings the physical bike's chainrings — and cassette, unless the rider
+ /// is on a single sprocket — in line with a groupset chosen from Settings.
+ /// Settings otherwise keeps the two apart on purpose, so this is offered
+ /// rather than automatic: a rider who deliberately simulates a different
+ /// bike than the one on the trainer should never have that quietly
+ /// overwritten just for picking a new groupset to simulate.
+ func matchPhysicalSetup(to groupset: Groupset) {
+ if let chainring = groupset.chainrings.first {
+ setPhysicalChainrings(chainring.teeth)
+ }
+ if !configuration.physical.isSingleSprocket,
+ let cassette = groupset.cassettes.first {
+ setPhysicalCogs(cassette.cogs)
+ }
+ }
+
+ /// The setup guide's single "what's your bike" question: one groupset
+ /// answers both what is physically bolted on (so the chain-position
+ /// advice is right) and what gearing gets simulated (so the ladder
+ /// matches a bike the rider actually recognises), instead of asking the
+ /// same thing twice. Either half can still be changed independently
+ /// afterwards in Settings, for the rider who wants to simulate different
+ /// gearing than what is on the bike.
+ ///
+ /// - Parameter singleSprocketTeeth: Set when the trainer's actual back
+ /// cog is a single sprocket — a Zwift Cog (14 teeth) or any other
+ /// single-speed cog — rather than the groupset's own cassette; a
+ /// common indoor-only setup. The simulated gearing still matches the
+ /// groupset chosen either way; only the physical cog (and so the
+ /// parked-gear advice) changes.
+ func adoptGroupsetForBikeAndGears(
+ _ groupset: Groupset,
+ singleSprocketTeeth: Int? = nil
+ ) {
+ configuration.usesVirtualGears = false
+ setGroupset(groupset)
+ if let chainring = groupset.chainrings.first {
+ setPhysicalChainrings(chainring.teeth)
+ }
+ if let singleSprocketTeeth {
+ setPhysicalCogs([singleSprocketTeeth])
+ } else if let cassette = groupset.cassettes.first {
+ setPhysicalCogs(cassette.cogs)
+ }
+ }
+
+ func setLadder(_ ladder: GearLadder) {
+ configuration.gearLadderID = ladder.id
+ }
+
+ func useStandardVirtualGears() {
+ configuration.usesVirtualGears = true
+ setLadder(GearLadderCatalog.standardRange)
+ clearParkedGear()
+ }
+
+ func configureFirstRunBike(
+ chainringTeeth: [Int],
+ cogTeeth: [Int]
+ ) {
+ configuration.physical = PhysicalSetup(
+ chainringTeeth: chainringTeeth,
+ cogTeeth: cogTeeth
+ )
+ useStandardVirtualGears()
+ }
+
+ /// Uses the parts the rider just chose for both the real bike and the
+ /// simulated gearing. A single sprocket is not a cassette to simulate, so
+ /// that setup keeps the physical answer and uses the standard virtual ladder.
+ func copyPhysicalBikeToSimulatedGears() {
+ guard !configuration.physical.isSingleSprocket,
+ let chainring = DrivetrainCatalog.chainrings.first(where: {
+ $0.teeth == configuration.physical.chainringTeeth
+ }),
+ let cassette = DrivetrainCatalog.cassettes.first(where: {
+ $0.cogs == configuration.physical.cogTeeth
+ })
+ else {
+ useStandardVirtualGears()
+ return
+ }
+ configuration.usesVirtualGears = false
+ setChainring(chainring)
+ setCassette(cassette)
+ clearParkedGear()
+ }
+
+ /// Switches to a ladder the rider defines themselves. The parameters are
+ /// kept even after switching back to the built-in ladder, so returning to
+ /// "Custom" does not forget what was last set.
+ func setCustomLadder(_ params: CustomGearLadder) {
+ configuration.gearLadderID = GearLadderCatalog.customLadderID
+ configuration.customLadder = params
+ }
+
+ /// What is physically bolted to the bike. Changing it invalidates whichever
+ /// gear was confirmed before, because that gear may no longer exist.
+ func setPhysicalChainrings(_ teeth: [Int]) {
+ configuration.physical.chainringTeeth = teeth
+ clearParkedGear()
+ }
+
+ func setPhysicalCogs(_ teeth: [Int]) {
+ configuration.physical.cogTeeth = teeth
+ clearParkedGear()
+ }
+
+ func park(in gear: ParkedGear) {
+ configuration.park(in: gear)
+ }
+
+ func parkInSuggestion() {
+ configuration.parkInSuggestion()
+ }
+
+ private func clearParkedGear() {
+ configuration.physical.parkedChainringTeeth = nil
+ configuration.physical.parkedCogTeeth = nil
+ configuration.parkInSuggestion()
+ }
+
func setNormalWheelCircumference(millimeters: Int) {
configuration.setNormalWheelCircumference(millimeters: millimeters)
}
+ func useDefaultWheelCircumference() {
+ configuration.useDefaultWheelCircumference()
+ }
+
+ @discardableResult
+ func completeSetupWizard() -> Bool {
+ configuration.completeSetupWizard()
+ }
+
private func save() {
guard let defaults else { return }
guard let data = try? JSONEncoder().encode(configuration) else { return }
diff --git a/VirtualGearsProduct/ClickCentralService.swift b/VirtualGearsProduct/ClickCentralService.swift
index 0b5a5af..fe2c6e1 100644
--- a/VirtualGearsProduct/ClickCentralService.swift
+++ b/VirtualGearsProduct/ClickCentralService.swift
@@ -536,11 +536,21 @@ final class ClickCentralService: NSObject {
#if DEBUG
extension ClickCentralService {
- func stageScreenshot(name: String, batteryLevel: Int) {
+ func stageScreenshot(
+ name: String,
+ batteryLevel: Int,
+ candidates: [BluetoothCandidate] = [],
+ state: ProductConnectionState = .ready,
+ stalled: Bool = false,
+ identifying: UUID? = nil
+ ) {
selectedID = ScreenshotFixture.clickID
selectedName = name
+ self.candidates = candidates
self.batteryLevel = batteryLevel
- state = .ready
+ connectionIsStalled = stalled
+ identificationCandidateID = identifying
+ self.state = state
}
func stageScreenshotPressedButton(_ button: ZwiftClickButton) {
diff --git a/VirtualGearsProduct/FTMSPeripheral.swift b/VirtualGearsProduct/FTMSPeripheral.swift
index f5885a3..63704c0 100644
--- a/VirtualGearsProduct/FTMSPeripheral.swift
+++ b/VirtualGearsProduct/FTMSPeripheral.swift
@@ -669,6 +669,14 @@ extension FTMSPeripheral: @preconcurrency CBPeripheralManagerDelegate {
central.identifier,
characteristic: characteristic.uuid.uuidString
))
+ // CoreBluetooth is documented to keep broadcasting on its own once a
+ // central disconnects, but a riding app that never returns has been
+ // reported in the field with no other explanation found. Re-asserting
+ // the advertisement here costs nothing when it was already fine, and
+ // is the one thing that can help if the OS silently let it lapse.
+ if wantsAdvertising, isAdvertising, centrals.isEmpty {
+ advertise()
+ }
}
func peripheralManagerIsReady(
diff --git a/VirtualGearsProduct/HeadwindCentralService.swift b/VirtualGearsProduct/HeadwindCentralService.swift
index 493d48f..c60edad 100644
--- a/VirtualGearsProduct/HeadwindCentralService.swift
+++ b/VirtualGearsProduct/HeadwindCentralService.swift
@@ -621,17 +621,28 @@ final class HeadwindCentralService: NSObject {
#if DEBUG
extension HeadwindCentralService {
- func stageScreenshot(name: String, speed: Int) {
+ func stageScreenshot(
+ name: String,
+ speed: Int,
+ candidates: [BluetoothCandidate] = [],
+ state: ProductConnectionState = .ready,
+ manual: Bool = true,
+ pending: Bool = false,
+ error: String? = nil,
+ stalled: Bool = false
+ ) {
selectedID = ScreenshotFixture.headwindID
selectedName = name
- state = .ready
- mode = .manual
+ self.candidates = candidates
+ self.state = state
+ mode = manual ? .manual : .heartRate
manualSpeed = speed
desiredManualSpeed = speed
- requestedManual = true
+ requestedManual = manual
lastSensorMode = .heartRate
- commandError = nil
- isCommandPending = false
+ commandError = error
+ isCommandPending = pending
+ connectionIsStalled = stalled
}
}
#endif
diff --git a/VirtualGearsProduct/KickrCentralService.swift b/VirtualGearsProduct/KickrCentralService.swift
index b54c815..8b9a07e 100644
--- a/VirtualGearsProduct/KickrCentralService.swift
+++ b/VirtualGearsProduct/KickrCentralService.swift
@@ -735,9 +735,16 @@ final class KickrCentralService: NSObject {
#if DEBUG
extension KickrCentralService {
- func stageScreenshot(name: String, state: ProductConnectionState) {
+ func stageScreenshot(
+ name: String,
+ state: ProductConnectionState,
+ candidates: [BluetoothCandidate] = [],
+ stalled: Bool = false
+ ) {
selectedID = ScreenshotFixture.kickrID
selectedName = name
+ self.candidates = candidates
+ connectionIsStalled = stalled
hasFTMSControl = state == .ready
self.state = state
}
diff --git a/VirtualGearsProduct/SetupView.swift b/VirtualGearsProduct/SetupView.swift
index 15be709..c122c6a 100644
--- a/VirtualGearsProduct/SetupView.swift
+++ b/VirtualGearsProduct/SetupView.swift
@@ -15,10 +15,11 @@ struct SetupView: View {
var body: some View {
Form {
+ setupStatusSection
equipmentSection
wheelSizeSection
gearsSection
- chainLineSection
+ parkedGearSection
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
@@ -45,6 +46,61 @@ struct SetupView: View {
}
}
+ @ViewBuilder
+ private var setupStatusSection: some View {
+ if needsSetup {
+ Section {
+ Text(setupStatusMessage)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ NavigationLink {
+ if store.configuration.hasSafeGearing {
+ ParkedGearView(store: store)
+ } else {
+ GearChoiceView(store: store)
+ }
+ } label: {
+ Label(setupNextAction, systemImage: "arrow.right.circle.fill")
+ .fontWeight(.semibold)
+ }
+ .accessibilityIdentifier("action.finishSetup")
+ } header: {
+ Text("Finish setup")
+ } footer: {
+ Text(
+ "Gearing comes first because it decides which parked gear "
+ + "is safe. Then confirm where the chain is left."
+ )
+ }
+ }
+ }
+
+ private var needsSetup: Bool {
+ !store.configuration.hasSafeGearing
+ || store.configuration.parkedGear == nil
+ || store.configuration.parkedGearPutsGearsOutOfReach
+ }
+
+ private var setupStatusMessage: String {
+ if !store.configuration.hasSafeGearing {
+ return "First choose gears that fit the trainer. After that, Virtual "
+ + "Gears can recommend where to leave the chain."
+ }
+ if store.configuration.parkedGearPutsGearsOutOfReach {
+ return "Your gears fit the trainer, but the current chain position "
+ + "puts some of them out of reach. Choose a workable parked gear."
+ }
+ return "Your gears are ready. Confirm the gear the bike is left in so "
+ + "every virtual gear is scaled correctly."
+ }
+
+ private var setupNextAction: String {
+ store.configuration.hasSafeGearing
+ ? "Confirm the gear the bike is in"
+ : "Choose gears that fit"
+ }
+
private var equipmentSection: some View {
Section {
NavigationLink {
@@ -131,20 +187,27 @@ struct SetupView: View {
NormalWheelSizeView(store: store)
} label: {
LabeledContent(
- "Normal wheel circumference",
- value: "\(store.configuration.neutralCircumferenceMillimeters) mm"
+ "Wheel circumference",
+ value: wheelCircumferenceValue
)
}
} header: {
Text("Trainer wheel size")
} footer: {
Text(
- "Used when your riding app does not send a wheel circumference. "
+ "Optional. Used when your riding app does not send a wheel circumference. "
+ "A value sent by the riding app takes precedence."
)
}
}
+ private var wheelCircumferenceValue: String {
+ if let saved = store.configuration.normalWheelCircumferenceMillimeters {
+ return "\(saved) mm"
+ }
+ return "Default · \(store.configuration.neutralCircumferenceMillimeters) mm"
+ }
+
private var gearsSection: some View {
Section {
NavigationLink {
@@ -161,7 +224,7 @@ struct SetupView: View {
}
}
- if !store.configuration.hasSafeCircumference {
+ if !store.configuration.hasSafeGearing {
Label(
"These gears fall outside the trainer's safe range. Choose "
+ "another set.",
@@ -173,17 +236,53 @@ struct SetupView: View {
Text(store.configuration.setupDescription)
}
}
- private var chainLineSection: some View {
+ /// The bike never shifts, so the gear it is parked in is a fact the app has
+ /// to know rather than guess. Every virtual gear is scaled from that ratio,
+ /// and a wrong guess moves the whole ladder without ever looking broken.
+ private var parkedGearSection: some View {
Section {
- Label(
- "Use the smaller front ring if your bike has one. Pick a rear gear "
- + "that keeps the chain straight, and leave it there. "
- + "Virtual Gears does all the shifting from now on.",
- systemImage: "link"
- )
- .font(.callout)
+ NavigationLink {
+ ParkedGearView(store: store)
+ } label: {
+ // Deliberately not a connection badge. Nothing here connects;
+ // this is a fact about the bike, so it either has an answer or
+ // it is still needed.
+ LabeledContent {
+ if let parked = store.configuration.parkedGear {
+ Text(parked.name)
+ } else {
+ Text("Needed")
+ .foregroundStyle(.orange)
+ }
+ } label: {
+ Text("Gear the bike is in")
+ }
+ }
+ // A stable identifier for UI tests to find this row directly,
+ // since it can sit below the fold once other rows are added
+ // above it and its value text changes with the parked gear.
+ .accessibilityIdentifier("row.parkedGear")
+
+ if store.configuration.parkedGear == nil,
+ store.configuration.hasSafeGearing {
+ Label(
+ "Virtual Gears needs to know which gear the bike is left "
+ + "in. Without it every gear is scaled from a guess.",
+ systemImage: "exclamationmark.triangle.fill"
+ )
+ .font(.callout)
+ .foregroundStyle(.orange)
+ }
+
+ if let warning = store.configuration.parkedGearWarning {
+ Label(warning, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(.orange)
+ }
} header: {
Text("On the bike")
+ } footer: {
+ Text(store.configuration.parkedGearAdviceText)
}
}
@@ -202,6 +301,7 @@ struct SetupView: View {
private struct NormalWheelSizeView: View {
@Bindable var store: ConfigurationStore
@State private var enteredValue: String
+ @State private var isApplyingDefault = false
init(store: ConfigurationStore) {
self.store = store
@@ -214,10 +314,65 @@ private struct NormalWheelSizeView: View {
var body: some View {
Form {
+ Section {
+ ScrollView(.horizontal) {
+ LazyHStack(spacing: 12) {
+ ForEach(WheelCircumferenceShortcut.all) { shortcut in
+ Button {
+ apply(shortcut)
+ } label: {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(shortcut.kind)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(shortcut.size)
+ .font(.headline)
+ Text("\(shortcut.millimeters) mm")
+ .font(.subheadline)
+ }
+ .frame(width: 112, alignment: .leading)
+ .padding(12)
+ .background(
+ selectedShortcut == shortcut
+ ? Color.accentColor.opacity(0.16)
+ : Color(.secondarySystemGroupedBackground),
+ in: .rect(cornerRadius: 12)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(
+ selectedShortcut == shortcut
+ ? Color.accentColor : .clear,
+ lineWidth: 2
+ )
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(
+ "\(shortcut.kind), \(shortcut.size), "
+ + "\(shortcut.millimeters) millimetres"
+ )
+ .accessibilityAddTraits(
+ selectedShortcut == shortcut ? .isSelected : []
+ )
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ .accessibilityIdentifier("wheel.shortcuts")
+ .scrollIndicators(.hidden)
+ } header: {
+ Text("Common size shortcuts")
+ }
+
Section {
TextField("Millimetres", text: $enteredValue)
.keyboardType(.numberPad)
.onChange(of: enteredValue) { _, value in
+ if isApplyingDefault {
+ isApplyingDefault = false
+ return
+ }
guard let millimeters = Int(value), isValid(millimeters)
else { return }
store.setNormalWheelCircumference(
@@ -231,17 +386,30 @@ private struct NormalWheelSizeView: View {
step: 1
) {
LabeledContent(
- "Selected size",
+ "Wheel circumference",
value: "\(store.configuration.neutralCircumferenceMillimeters) mm"
)
}
+
+ Button("Use default \(defaultMillimeters) mm") {
+ store.useDefaultWheelCircumference()
+ let defaultText = String(defaultMillimeters)
+ if enteredValue != defaultText {
+ isApplyingDefault = true
+ enteredValue = defaultText
+ }
+ }
+ .accessibilityIdentifier("wheel.useDefault")
+ .disabled(
+ store.configuration.normalWheelCircumferenceMillimeters == nil
+ )
} header: {
- Text("Normal wheel circumference")
+ Text("Circumference")
} footer: {
Text(
- "Choose 1800–2400 mm. Virtual Gears uses 2070 mm by default. "
- + "This value is the base for the gears and the size restored "
- + "when shifting stops, unless your riding app supplies its own."
+ "Choose 1800–2400 mm. Virtual Gears uses \(defaultMillimeters) mm "
+ + "(700×25 road) when no value is saved. A wheel circumference "
+ + "from the riding app takes precedence."
)
}
@@ -255,10 +423,27 @@ private struct NormalWheelSizeView: View {
}
}
}
- .navigationTitle("Normal wheel size")
+ .navigationTitle("Wheel circumference")
.navigationBarTitleDisplayMode(.inline)
}
+ private var defaultMillimeters: Int {
+ Int(TrainerSafety.referenceCircumferenceMillimeters)
+ }
+
+ private var selectedShortcut: WheelCircumferenceShortcut? {
+ guard let entered = Int(enteredValue)
+ else { return nil }
+ return WheelCircumferenceShortcut.all.first {
+ $0.millimeters == entered
+ }
+ }
+
+ private func apply(_ shortcut: WheelCircumferenceShortcut) {
+ store.setNormalWheelCircumference(millimeters: shortcut.millimeters)
+ enteredValue = String(shortcut.millimeters)
+ }
+
private var wheelSize: Binding {
Binding(
get: { store.configuration.neutralCircumferenceMillimeters },
@@ -282,6 +467,23 @@ private struct NormalWheelSizeView: View {
}
}
+private struct WheelCircumferenceShortcut: Identifiable, Equatable {
+ let kind: String
+ let size: String
+ let millimeters: Int
+
+ var id: String { "\(kind)-\(size)" }
+
+ static let all = [
+ WheelCircumferenceShortcut(kind: "Road", size: "700×25", millimeters: 2_105),
+ WheelCircumferenceShortcut(kind: "Road", size: "700×28", millimeters: 2_136),
+ WheelCircumferenceShortcut(kind: "Gravel", size: "700×40", millimeters: 2_200),
+ WheelCircumferenceShortcut(kind: "MTB", size: "26×2.0", millimeters: 2_055),
+ WheelCircumferenceShortcut(kind: "MTB", size: "27.5×2.25", millimeters: 2_188),
+ WheelCircumferenceShortcut(kind: "MTB", size: "29×2.25", millimeters: 2_326),
+ ]
+}
+
// MARK: - Trainer
private struct TrainerSetupView: View {
@@ -318,6 +520,7 @@ private struct TrainerSetupView: View {
selectedID: kickr.selectedID,
isScanning: kickr.isScanning,
connectionState: kickr.state,
+ initialPhase: stagedDiscoveryPhase(for: .trainer),
startScanning: kickr.startScanning,
stopScanning: {
kickr.stopScanning(reconnectSavedDevice: false)
@@ -404,6 +607,7 @@ private struct ShiftingSetupView: View {
selectedID: click.selectedID,
isScanning: click.isScanning,
connectionState: click.state,
+ initialPhase: stagedDiscoveryPhase(for: .click),
startScanning: click.startScanning,
stopScanning: {
click.stopScanning(reconnectSavedDevice: false)
@@ -537,6 +741,7 @@ private struct HeadwindSetupView: View {
selectedID: headwind.selectedID,
isScanning: headwind.state == .scanning,
connectionState: headwind.state,
+ initialPhase: stagedDiscoveryPhase(for: .headwind),
startScanning: headwind.startScanning,
stopScanning: {
headwind.stopScanning(reconnectSavedDevice: false)
@@ -786,17 +991,69 @@ struct GearChoiceView: View {
} footer: {
Text(
store.configuration.usesVirtualGears
- ? "Twenty-four evenly spaced gears with an extra-low "
- + "climbing range. They are designed for indoor "
- + "riding rather than copied from a particular bike."
- : "Copy the numbers printed on your own bike, or pick "
- + "any combination you would like to ride. It does "
- + "not have to be a set that anyone sells."
+ ? "Evenly spaced gears designed for indoor riding "
+ + "rather than copied from a particular bike."
+ : "Pick the groupset your bike has, or one you would "
+ + "rather be riding. Nothing on the bike moves — "
+ + "this is the gearing that gets simulated."
)
}
+ if store.configuration.usesVirtualGears {
+ Section {
+ ChoiceRow(
+ title: GearLadderCatalog.standardRange.name,
+ note: GearLadderCatalog.standardRange.note,
+ selected: !store.configuration.usesCustomLadder
+ ) {
+ store.setLadder(GearLadderCatalog.standardRange)
+ }
+ NavigationLink {
+ CustomGearLadderView(store: store)
+ } label: {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Custom")
+ if store.configuration.usesCustomLadder {
+ Text(store.configuration.gearLadder.note)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer(minLength: 0)
+ if store.configuration.usesCustomLadder {
+ Image(systemName: "checkmark")
+ .fontWeight(.semibold)
+ .foregroundStyle(.tint)
+ }
+ }
+ .accessibilityAddTraits(
+ store.configuration.usesCustomLadder
+ ? .isSelected : []
+ )
+ }
+ } header: {
+ Text("Which ladder")
+ } footer: {
+ Text(
+ "Standard is the widely used 24-gear table. Custom "
+ + "lets you set your own gear count and range."
+ )
+ }
+ }
+
if !store.configuration.usesVirtualGears {
Section {
+ NavigationLink {
+ GroupsetChoiceView(store: store)
+ } label: {
+ LabeledContent(
+ "Groupset",
+ value: store.configuration.groupset?.qualifiedName
+ ?? "Custom"
+ )
+ }
+
NavigationLink {
ChainringChoiceView(store: store)
} label: {
@@ -816,6 +1073,12 @@ struct GearChoiceView: View {
}
} header: {
Text("The bike you want to feel")
+ } footer: {
+ Text(
+ "Pick a groupset for a set that exists, or set the "
+ + "chainrings and cassette yourself if your bike "
+ + "is not listed."
+ )
}
}
@@ -899,7 +1162,7 @@ private struct GearSpread: View {
/// The result of the two choices above, kept on the same screen so a change is
/// seen immediately rather than discovered mid-ride.
-private struct GearPreview: View {
+struct GearPreview: View {
let configuration: AppConfiguration
var body: some View {
@@ -916,9 +1179,11 @@ private struct GearPreview: View {
drivetrain.gears.count < expectedCombinations {
Text(
"Fewer than the \(expectedCombinations) possible "
- + "pairings, because the ones that would cross the "
- + "chain badly are left out, along with any that "
- + "feel exactly like another."
+ + "pairings. The gears are walked the way an "
+ + "electronic groupset shifts them — one cog at a "
+ + "time, changing ring at the right moment — so "
+ + "badly crossed and repeated combinations never "
+ + "appear."
)
.font(.caption)
.foregroundStyle(.tertiary)
@@ -944,28 +1209,146 @@ private struct GearPreview: View {
}
}
+/// Lets a rider define their own gear count and range instead of the one
+/// built-in ladder, for a bike whose gearing does not match the standard
+/// table. Selecting "Custom" and opening this screen are the same action, so
+/// there is nothing to separately confirm — the live preview at the bottom is
+/// the confirmation.
+private struct CustomGearLadderView: View {
+ @Bindable var store: ConfigurationStore
+
+ var body: some View {
+ Form {
+ Section {
+ Stepper(
+ "\(store.configuration.customLadder.gearCount) gears",
+ value: gearCountBinding,
+ in: CustomGearLadder.gearCountRange
+ )
+ } header: {
+ Text("How many gears")
+ }
+
+ Section {
+ Stepper(
+ "Easiest \(easiestRatioText)×",
+ value: easiestBinding,
+ in: CustomGearLadder.ratioHundredthsRange,
+ step: 5
+ )
+ Stepper(
+ "Hardest \(hardestRatioText)×",
+ value: hardestBinding,
+ in: CustomGearLadder.ratioHundredthsRange,
+ step: 5
+ )
+ } header: {
+ Text("Range")
+ } footer: {
+ Text(
+ "A ratio is how much harder or easier a gear is than "
+ + "riding one-to-one. 1.00× is even, 2.00× is twice "
+ + "as hard, 0.50× is half as hard."
+ )
+ }
+
+ Section {
+ GearPreview(configuration: store.configuration)
+ } header: {
+ Text("What you get")
+ }
+ }
+ .navigationTitle("Custom Ladder")
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear {
+ // Opening this screen is how a rider chooses Custom, so it takes
+ // effect immediately rather than waiting for a value to change —
+ // otherwise navigating here and back without touching anything
+ // would silently leave Standard selected.
+ if !store.configuration.usesCustomLadder {
+ store.setCustomLadder(store.configuration.customLadder)
+ }
+ }
+ .accessibilityIdentifier("screen.customGearLadder")
+ }
+
+ private var easiestRatioText: String {
+ String(
+ format: "%.2f",
+ Double(store.configuration.customLadder.easiestRatioHundredths)
+ / 100
+ )
+ }
+
+ private var hardestRatioText: String {
+ String(
+ format: "%.2f",
+ Double(store.configuration.customLadder.hardestRatioHundredths)
+ / 100
+ )
+ }
+
+ private var gearCountBinding: Binding {
+ Binding(
+ get: { store.configuration.customLadder.gearCount },
+ set: { store.configuration.customLadder.gearCount = $0 }
+ )
+ }
+
+ /// Kept at least one step below the hardest ratio, so the two can never
+ /// cross and silently swap places under the rider's thumb.
+ private var easiestBinding: Binding {
+ Binding(
+ get: { store.configuration.customLadder.easiestRatioHundredths },
+ set: { newValue in
+ store.configuration.customLadder.easiestRatioHundredths = min(
+ newValue,
+ store.configuration.customLadder.hardestRatioHundredths - 5
+ )
+ }
+ )
+ }
+
+ private var hardestBinding: Binding {
+ Binding(
+ get: { store.configuration.customLadder.hardestRatioHundredths },
+ set: { newValue in
+ store.configuration.customLadder.hardestRatioHundredths = max(
+ newValue,
+ store.configuration.customLadder.easiestRatioHundredths + 5
+ )
+ }
+ )
+ }
+}
+
private struct ChainringChoiceView: View {
@Bindable var store: ConfigurationStore
var body: some View {
Form {
ForEach(Self.groups, id: \.title) { group in
- Section {
- ForEach(options(count: group.count)) { option in
- ChoiceRow(
- title: option.name,
- note: option.note,
- detail: nil,
- selected: option.id == store.configuration.chainringID,
- fits: fits(option)
- ) {
- store.setChainring(option)
+ // A heading with nothing under it looks like a loading bug, so
+ // groups the catalogue no longer stocks simply do not appear.
+ if !options(count: group.count).isEmpty {
+ Section {
+ ForEach(options(count: group.count)) { option in
+ ChoiceRow(
+ title: option.name,
+ note: option.note,
+ detail: nil,
+ selected: option.id
+ == store.configuration.chainringID,
+ fits: fits(option)
+ ) {
+ store.setChainring(option)
+ }
}
+ } header: {
+ Text(group.title)
+ } footer: {
+ Text(group.note)
}
- } header: {
- Text(group.title)
- } footer: {
- Text(group.note)
}
}
}
@@ -984,11 +1367,6 @@ private struct ChainringChoiceView: View {
2,
"The usual road setup. More gears, but some of them repeat."
),
- (
- "Three chainrings",
- 3,
- "Older bikes. A very wide spread, so it will not fit every cassette."
- ),
]
private func options(count: Int) -> [ChainringOption] {
@@ -1050,16 +1428,37 @@ private struct CassetteChoiceView: View {
/// One selectable part. A part that cannot work with the other choice is shown
/// dimmed and says why, rather than disappearing and leaving the rider guessing.
-private struct ChoiceRow: View {
+///
+/// This is the one row style used for every tap-to-select list in setup —
+/// ladders, groupsets, physical parts and the parked gear all reuse it rather
+/// than each hand-rolling a `Button` — because a hand-rolled row rendered its
+/// title in the accent colour on iOS 26 (`.buttonStyle(.plain)` alone did not
+/// override it) and nobody noticed until a rider pointed it out. Sharing this
+/// one implementation means that class of bug cannot come back a part at a
+/// time.
+struct ChoiceRow: View {
let title: String
/// What VoiceOver says, when the visible title alone is ambiguous. Three
/// cassettes are called "11-28"; on screen their section heading tells them
/// apart, but a rider hearing the list gets no heading with each row.
var spokenTitle: String?
- let note: String
- let detail: String?
+ /// A single line under the title. Read aloud by VoiceOver as part of the
+ /// row, so it is the right place for anything a rider needs to hear, not
+ /// just see — the gear the row is recommended for, or the cog counts on a
+ /// cassette.
+ var note: String? = nil
+ /// Overrides the note's colour for a warning that should still be tappable
+ /// (a parked gear that puts some gears out of reach, say). Leave nil for
+ /// the default: secondary, or red when `fits` is false.
+ var noteColor: Color? = nil
+ /// A second, quieter line, not read aloud — used for the small print under
+ /// a part that already explains itself in `note`.
+ var detail: String? = nil
let selected: Bool
- let fits: Bool
+ /// False disables the row, dims it and swaps in a fixed "too wide" note —
+ /// used only by the two screens that can conflict with another choice. All
+ /// other callers default to always-tappable.
+ var fits: Bool = true
let select: () -> Void
var body: some View {
@@ -1069,9 +1468,11 @@ private struct ChoiceRow: View {
Text(title)
.font(.body)
.foregroundStyle(.primary)
- Text(fits ? note : "Too wide to combine with your other choice")
- .font(.subheadline)
- .foregroundStyle(fits ? .secondary : Color.red)
+ if let resolvedNote {
+ Text(resolvedNote)
+ .font(.subheadline)
+ .foregroundStyle(resolvedNoteColor)
+ }
if let detail, fits {
Text(detail)
.font(.caption)
@@ -1090,17 +1491,62 @@ private struct ChoiceRow: View {
.buttonStyle(.plain)
.disabled(!fits)
.opacity(fits ? 1 : 0.5)
- .accessibilityLabel(
- fits
- ? "\(spokenTitle ?? title), \(note)"
- : "\(spokenTitle ?? title), too wide to combine with your other choice"
- )
+ .accessibilityLabel(accessibilityText)
.accessibilityAddTraits(selected ? .isSelected : [])
}
+
+ private var resolvedNote: String? {
+ guard fits else { return "Too wide to combine with your other choice" }
+ return note
+ }
+
+ private var resolvedNoteColor: Color {
+ guard fits else { return .red }
+ return noteColor ?? .secondary
+ }
+
+ private var accessibilityText: String {
+ guard let resolvedNote else { return spokenTitle ?? title }
+ return "\(spokenTitle ?? title), \(resolvedNote)"
+ }
}
// MARK: - Shared rows
+private enum StagedDiscoveryDevice {
+ case trainer
+ case click
+ case headwind
+}
+
+/// Production discovery always starts idle. Screenshot fixtures can seed a
+/// later app-owned phase so UI tests cover results, timeout, and identification
+/// without waiting on a real Bluetooth radio.
+private func stagedDiscoveryPhase(
+ for device: StagedDiscoveryDevice
+) -> DeviceDiscoveryState.Phase {
+#if DEBUG
+ guard let fixture = ScreenshotFixture.current else { return .idle }
+ switch (device, fixture) {
+ case (.trainer, .settingsSearching):
+ return .searching
+ case (.trainer, .settingsResults),
+ (.trainer, .settingsUnsupported):
+ return .showingResults
+ case (.trainer, .settingsTimedOut),
+ (.trainer, .settingsBluetoothIssue):
+ return .timedOut
+ case (.click, .settingsClickDuplicates),
+ (.click, .settingsClickIdentifying):
+ return .showingResults
+ default:
+ return .idle
+ }
+#else
+ .idle
+#endif
+}
+
/// A Settings-style row: what it is on the left, what it is set to on the
/// right, and a badge saying whether it is actually connected.
private struct SetupRow: View {
@@ -1234,6 +1680,7 @@ private struct DeviceDiscoverySection: View {
selectedID: UUID?,
isScanning: Bool,
connectionState: ProductConnectionState,
+ initialPhase: DeviceDiscoveryState.Phase = .idle,
startScanning: @escaping () -> Void,
stopScanning: @escaping () -> Void,
cancelScanning: @escaping () -> Void,
@@ -1250,6 +1697,20 @@ private struct DeviceDiscoverySection: View {
self.selectedID = selectedID
self.isScanning = isScanning
self.connectionState = connectionState
+ var initialDiscovery = DeviceDiscoveryState()
+ switch initialPhase {
+ case .idle:
+ break
+ case .searching:
+ initialDiscovery.start()
+ case .showingResults:
+ initialDiscovery.start()
+ initialDiscovery.observe(candidateCount: max(1, candidates.count))
+ case .timedOut:
+ initialDiscovery.start()
+ initialDiscovery.finish(candidateCount: 0)
+ }
+ _discovery = State(initialValue: initialDiscovery)
self.startScanning = startScanning
self.stopScanning = stopScanning
self.cancelScanning = cancelScanning
@@ -1600,3 +2061,293 @@ private struct EquipmentSummary: View {
)
}
}
+
+/// What is physically on the bike, and which gear it is left sitting in.
+///
+/// This is the one question the app used to skip. The bike never shifts, so the
+/// parked ratio is the baseline every virtual gear is scaled from — and a
+/// "quiet, straight chain line" is satisfied by gears more than twice as hard
+/// as each other. Rather than ask an open question, the app names the gear it
+/// wants and lets the rider confirm or correct it in one tap.
+struct ParkedGearView: View {
+ @Bindable var store: ConfigurationStore
+
+ var body: some View {
+ Form {
+ recommendationSection
+ bikeSection
+ gearSection
+ }
+ .navigationTitle("Gear the bike is in")
+ .navigationBarTitleDisplayMode(.inline)
+ .accessibilityIdentifier("screen.parkedGear")
+ }
+
+ private var recommendationSection: some View {
+ Section {
+ Text(store.configuration.parkedGearAdviceText)
+
+ if let suggestion = store.configuration.suggestedParkedGear,
+ store.configuration.parkedGear != suggestion {
+ Button("Use \(suggestion.name)") {
+ store.park(in: suggestion)
+ }
+ .accessibilityIdentifier("button.useSuggestedGear")
+ }
+
+ if let warning = store.configuration.parkedGearWarning {
+ Label(warning, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(.orange)
+ }
+ } header: {
+ Text("What to do")
+ } footer: {
+ Text(
+ "Virtual Gears changes gear by changing the wheel size the "
+ + "trainer works from, so it has to know the gear it is "
+ + "working from. Park the chain once and leave it there."
+ )
+ }
+ }
+
+ private var bikeSection: some View {
+ Section {
+ Picker("Back of the bike", selection: backOfBike) {
+ Text("Cassette").tag(false)
+ Text("Single sprocket").tag(true)
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+
+ NavigationLink {
+ PhysicalChainringView(store: store)
+ } label: {
+ LabeledContent("Chainrings", value: chainringSummary)
+ }
+
+ if store.configuration.physical.isSingleSprocket {
+ Stepper(
+ value: sprocketTeeth,
+ in: 9...30
+ ) {
+ LabeledContent(
+ "Sprocket",
+ value: "\(store.configuration.physical.cogTeeth[0])T"
+ )
+ }
+ .accessibilityIdentifier("parkedGear.sprocketTeeth")
+ } else {
+ NavigationLink {
+ PhysicalCassetteView(store: store)
+ } label: {
+ LabeledContent("Cassette", value: cassetteSummary)
+ }
+ }
+ } header: {
+ Text("What is on the bike")
+ } footer: {
+ Text(
+ "This is your real bike, not the gearing you asked to be "
+ + "simulated. A Zwift Cog is a single sprocket with 14 teeth."
+ )
+ }
+ }
+
+ private var gearSection: some View {
+ Section {
+ ForEach(candidates, id: \.self) { gear in
+ ChoiceRow(
+ title: gear.name,
+ note: caption(for: gear),
+ noteColor: isWorkable(gear) ? nil : .orange,
+ selected: gear == store.configuration.parkedGear
+ ) {
+ store.park(in: gear)
+ }
+ }
+ } header: {
+ Text("Which gear is it in")
+ }
+ }
+
+ private func caption(for gear: ParkedGear) -> String? {
+ if gear == store.configuration.suggestedParkedGear {
+ return "Recommended — quietest that works"
+ } else if !isWorkable(gear) {
+ return "Puts some gears out of reach"
+ }
+ return nil
+ }
+
+ private var candidates: [ParkedGear] {
+ ParkedGearAdvice.usableParkedGears(in: store.configuration.physical)
+ }
+
+ private func isWorkable(_ gear: ParkedGear) -> Bool {
+ guard let drivetrain = store.configuration.drivetrain else { return true }
+ return ParkedGearAdvice.isWorkable(gear, simulating: drivetrain)
+ }
+
+ private var chainringSummary: String {
+ store.configuration.physical.chainringTeeth
+ .map { "\($0)" }
+ .joined(separator: "/")
+ }
+
+ private var cassetteSummary: String {
+ let cogs = store.configuration.physical.cogTeeth
+ guard let smallest = cogs.min(), let largest = cogs.max() else {
+ return "—"
+ }
+ return "\(smallest)-\(largest)"
+ }
+
+ private var backOfBike: Binding {
+ Binding(
+ get: { store.configuration.physical.isSingleSprocket },
+ set: { single in
+ store.setPhysicalCogs(
+ single ? PhysicalSetup.zwiftCogTeeth : PhysicalSetup.default.cogTeeth
+ )
+ }
+ )
+ }
+
+ private var sprocketTeeth: Binding {
+ Binding(
+ get: { store.configuration.physical.cogTeeth.first ?? 14 },
+ set: { store.setPhysicalCogs([$0]) }
+ )
+ }
+}
+
+/// The rings on the rider's own bike. Kept separate from the simulated gearing
+/// on purpose: plenty of riders will run a single 31-tooth ring and ask for a
+/// twelve-speed groupset to be simulated on top of it.
+struct PhysicalChainringView: View {
+ @Binding private var teeth: [Int]
+
+ init(store: ConfigurationStore) {
+ _teeth = Binding(
+ get: { store.configuration.physical.chainringTeeth },
+ set: { store.setPhysicalChainrings($0) }
+ )
+ }
+
+ init(teeth: Binding<[Int]>) {
+ _teeth = teeth
+ }
+
+ var body: some View {
+ Form {
+ Section("One chainring") {
+ ForEach(options(withRingCount: 1)) { option in
+ choice(for: option)
+ }
+ }
+
+ Section("Two chainrings") {
+ ForEach(options(withRingCount: 2)) { option in
+ choice(for: option)
+ }
+ }
+ }
+ .navigationTitle("Chainrings on the bike")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+
+ private func options(withRingCount count: Int) -> [ChainringOption] {
+ DrivetrainCatalog.chainrings
+ .filter { $0.teeth.count == count }
+ .sorted { $0.teeth.lexicographicallyPrecedes($1.teeth) }
+ }
+
+ private func choice(for option: ChainringOption) -> some View {
+ ChoiceRow(
+ title: option.name,
+ selected: option.teeth == teeth
+ ) {
+ teeth = option.teeth
+ }
+ }
+}
+
+struct PhysicalCassetteView: View {
+ @Binding private var cogs: [Int]
+
+ init(store: ConfigurationStore) {
+ _cogs = Binding(
+ get: { store.configuration.physical.cogTeeth },
+ set: { store.setPhysicalCogs($0) }
+ )
+ }
+
+ init(cogs: Binding<[Int]>) {
+ _cogs = cogs
+ }
+
+ var body: some View {
+ Form {
+ ForEach(DrivetrainCatalog.cassettes) { option in
+ ChoiceRow(
+ title: option.qualifiedName,
+ note: option.cogs.map(String.init).joined(separator: ", "),
+ selected: option.cogs == cogs
+ ) {
+ cogs = option.cogs
+ }
+ }
+ }
+ .navigationTitle("Cassette on the bike")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
+
+/// Named groupsets are the fast path: one tap sets both the chainrings and the
+/// cassette to a pairing that exists on a real bike, so the simulated ladder
+/// matches gearing the rider already recognises. The parts lists stay behind it
+/// for anyone whose bike is not here.
+private struct GroupsetChoiceView: View {
+ @Bindable var store: ConfigurationStore
+
+ var body: some View {
+ Form {
+ ForEach(GroupsetBrand.allCases) { brand in
+ Section {
+ ForEach(GroupsetCatalog.groupsets(brand: brand)) { set in
+ ChoiceRow(
+ title: set.name,
+ spokenTitle: set.qualifiedName,
+ note: "\(set.speeds)-speed · \(set.note)",
+ selected: set.id == store.configuration.groupset?.id
+ ) {
+ store.setGroupset(set)
+ }
+ }
+ } header: {
+ Text(brand.name)
+ }
+ }
+
+ if let groupset = store.configuration.groupset,
+ !store.physicalSetupMatches(groupset) {
+ Section {
+ Button("Also set this as what's on the bike") {
+ store.matchPhysicalSetup(to: groupset)
+ }
+ } footer: {
+ Text(
+ "This only changes the gearing being simulated. Your "
+ + "real bike still shows different chainrings or "
+ + "a different cassette — tap to bring those in "
+ + "line too, unless that is deliberate."
+ )
+ }
+ }
+ }
+ .navigationTitle("Groupset")
+ .navigationBarTitleDisplayMode(.inline)
+ .accessibilityIdentifier("screen.groupset")
+ }
+}
diff --git a/VirtualGearsProduct/SetupWizardView.swift b/VirtualGearsProduct/SetupWizardView.swift
new file mode 100644
index 0000000..4c5d63e
--- /dev/null
+++ b/VirtualGearsProduct/SetupWizardView.swift
@@ -0,0 +1,284 @@
+import SwiftUI
+import VirtualGearsCore
+
+/// First run asks only for the two physical facts virtual shifting cannot
+/// infer: what is on the bike and where the chain is left. Simulated gearing
+/// starts on Standard 24 and can be changed independently in Settings later.
+struct SetupWizardView: View {
+ @Bindable var store: ConfigurationStore
+ var onFinish: () -> Void
+
+ @State private var step: WizardStep = .bike
+ @State private var chainringTeeth: [Int]
+ @State private var cogTeeth: [Int]
+ @State private var cassetteCogs: [Int]
+
+ private enum WizardStep {
+ case bike, parkedGear
+ }
+
+ init(store: ConfigurationStore, onFinish: @escaping () -> Void) {
+ self.store = store
+ self.onFinish = onFinish
+ let physical = store.configuration.physical
+ _chainringTeeth = State(initialValue: physical.chainringTeeth)
+ _cogTeeth = State(initialValue: physical.cogTeeth)
+ _cassetteCogs = State(
+ initialValue: physical.isSingleSprocket
+ ? store.configuration.cassette.cogs : physical.cogTeeth
+ )
+ }
+
+ var body: some View {
+ Group {
+ switch step {
+ case .bike:
+ WizardBikeSetupStep(
+ store: store,
+ chainringTeeth: $chainringTeeth,
+ cogTeeth: $cogTeeth,
+ cassetteCogs: $cassetteCogs,
+ onUseBike: useBike
+ )
+ case .parkedGear:
+ WizardParkedGearStep(store: store, onFinish: finish)
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ if step == .parkedGear {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Back") { step = .bike }
+ }
+ }
+ }
+ .interactiveDismissDisabled()
+ }
+
+ private func useBike() {
+ store.configureFirstRunBike(
+ chainringTeeth: chainringTeeth,
+ cogTeeth: cogTeeth
+ )
+ step = .parkedGear
+ }
+
+ private func finish() {
+ guard store.completeSetupWizard() else { return }
+ onFinish()
+ }
+}
+
+private struct WizardBikeSetupStep: View {
+ @Bindable var store: ConfigurationStore
+ @Binding var chainringTeeth: [Int]
+ @Binding var cogTeeth: [Int]
+ @Binding var cassetteCogs: [Int]
+ let onUseBike: () -> Void
+
+ var body: some View {
+ Form {
+ Section {
+ Text(
+ "Tell us what is physically on the bike attached to the "
+ + "trainer. Virtual Gears uses this only to calculate "
+ + "the quiet, safe gear to leave the chain in."
+ )
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ LabeledContent("Virtual gears", value: "Standard 24")
+ Text("Choose other virtual gears later in Settings.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ } header: {
+ Text("Your bike")
+ }
+
+ Section {
+ ChoiceRow(
+ title: "Cassette",
+ selected: !isSingleSprocket
+ ) {
+ cogTeeth = cassetteCogs
+ }
+ .accessibilityIdentifier("wizard.rear.cassette")
+
+ ChoiceRow(
+ title: "Zwift Cog or another single sprocket",
+ note: "A Zwift Cog is normally 14T.",
+ selected: isSingleSprocket
+ ) {
+ if !isSingleSprocket {
+ cogTeeth = PhysicalSetup.zwiftCogTeeth
+ }
+ }
+ .accessibilityIdentifier("wizard.rear.singleSprocket")
+
+ if isSingleSprocket {
+ Stepper(value: sprocketTeeth, in: 9...30) {
+ LabeledContent("Sprocket", value: "\(cogTeeth[0])T")
+ }
+ .accessibilityIdentifier("wizard.sprocketTeeth")
+ }
+ } header: {
+ Text("Back of the bike")
+ }
+
+ Section {
+ NavigationLink {
+ PhysicalChainringView(teeth: $chainringTeeth)
+ } label: {
+ LabeledContent(
+ "Chainrings",
+ value: chainringTeeth.map(String.init).joined(separator: "/")
+ )
+ }
+
+ if !isSingleSprocket {
+ NavigationLink {
+ PhysicalCassetteView(cogs: cassetteBinding)
+ } label: {
+ LabeledContent("Cassette", value: cassetteSummary)
+ }
+ }
+ } header: {
+ Text("Physical parts")
+ }
+ }
+ .accessibilityIdentifier("screen.setupWizard")
+ .navigationTitle("Set up Virtual Gears")
+ .safeAreaInset(edge: .bottom) {
+ Button("Use this bike setup", action: onUseBike)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .fontWeight(.semibold)
+ .padding()
+ .accessibilityIdentifier("wizard.useBikeSetup")
+ }
+ }
+
+ private var isSingleSprocket: Bool {
+ cogTeeth.count == 1
+ }
+
+ private var cassetteSummary: String {
+ guard let smallest = cogTeeth.first, let largest = cogTeeth.last else {
+ return "—"
+ }
+ return "\(cogTeeth.count)-speed · \(smallest)-\(largest)"
+ }
+
+ private var cassetteBinding: Binding<[Int]> {
+ Binding(
+ get: { cogTeeth },
+ set: {
+ cassetteCogs = $0
+ cogTeeth = $0
+ }
+ )
+ }
+
+ private var sprocketTeeth: Binding {
+ Binding(
+ get: { cogTeeth.first ?? PhysicalSetup.zwiftCogTeeth[0] },
+ set: { cogTeeth = [$0] }
+ )
+ }
+}
+
+private struct WizardParkedGearStep: View {
+ @Bindable var store: ConfigurationStore
+ let onFinish: () -> Void
+
+ var body: some View {
+ Form {
+ Section {
+ Text(
+ "Virtual Gears changes resistance from one fixed physical "
+ + "gear. Move the chain once, then leave it there for "
+ + "the whole ride."
+ )
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ } header: {
+ Text("Before you ride")
+ }
+
+ Section {
+ Text(store.configuration.parkedGearAdviceText)
+
+ if let warning = store.configuration.parkedGearWarning {
+ Label(warning, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(.orange)
+ }
+ } header: {
+ Text("Recommended")
+ }
+
+ Section {
+ ForEach(candidates, id: \.self) { gear in
+ ChoiceRow(
+ title: gear.name,
+ note: caption(for: gear),
+ noteColor: isWorkable(gear) ? nil : .orange,
+ selected: gear == store.configuration.parkedGear
+ ) {
+ store.park(in: gear)
+ }
+ }
+ } header: {
+ Text("Position your chain")
+ }
+ }
+ .accessibilityIdentifier("screen.setupWizard")
+ .navigationTitle("Position your chain")
+ .safeAreaInset(edge: .bottom) {
+ Button("Finish setup", action: onFinish)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .fontWeight(.semibold)
+ .disabled(
+ !store.configuration.hasSafeGearing
+ || store.configuration.parkedGear == nil
+ || store.configuration.parkedGearPutsGearsOutOfReach
+ )
+ .padding()
+ }
+ .onAppear {
+ if store.configuration.parkedGear == nil {
+ store.parkInSuggestion()
+ }
+ }
+ }
+
+ private func caption(for gear: ParkedGear) -> String? {
+ if gear == store.configuration.suggestedParkedGear {
+ return "Recommended — quietest that works"
+ } else if !isWorkable(gear) {
+ return "Puts some gears out of reach"
+ }
+ return nil
+ }
+
+ private var candidates: [ParkedGear] {
+ let suggestion = store.configuration.suggestedParkedGear
+ return ParkedGearAdvice.usableParkedGears(
+ in: store.configuration.physical
+ ).sorted { left, right in
+ if left == right { return false }
+ if left == suggestion { return true }
+ if right == suggestion { return false }
+ let leftWorks = isWorkable(left)
+ let rightWorks = isWorkable(right)
+ if leftWorks != rightWorks { return leftWorks }
+ return left.ratio < right.ratio
+ }
+ }
+
+ private func isWorkable(_ gear: ParkedGear) -> Bool {
+ guard let drivetrain = store.configuration.drivetrain else { return true }
+ return ParkedGearAdvice.isWorkable(gear, simulating: drivetrain)
+ }
+}
diff --git a/VirtualGearsProduct/VirtualGearsApp.swift b/VirtualGearsProduct/VirtualGearsApp.swift
index 14b241a..8ac73b0 100644
--- a/VirtualGearsProduct/VirtualGearsApp.swift
+++ b/VirtualGearsProduct/VirtualGearsApp.swift
@@ -95,7 +95,12 @@ struct VirtualGearsApp: App {
#if DEBUG
enum ScreenshotFixture: String {
case starting = "-shotStarting"
+ case startupLooking = "-shotStartupLooking"
+ case startupChoosing = "-shotStartupChoosing"
case ready = "-shotReady"
+ /// A trainer that is connected but a bike whose gear has not been
+ /// confirmed — the one blocker a rider cannot fix by waiting.
+ case unparked = "-shotUnparked"
case failed = "-shotFailed"
case ride = "-shotRide"
case rideAccessibility = "-shotRideAccessibility"
@@ -104,10 +109,28 @@ enum ScreenshotFixture: String {
case ridePending = "-shotRidePending"
case ridePressed = "-shotRidePressed"
case rideReconnecting = "-shotRideReconnecting"
+ case rideStopping = "-shotRideStopping"
+ case rideWheelSize = "-shotRideWheelSize"
case settings = "-shotSettings"
+ case settingsSearching = "-shotSettingsSearching"
+ case settingsResults = "-shotSettingsResults"
+ case settingsUnsupported = "-shotSettingsUnsupported"
+ case settingsTimedOut = "-shotSettingsTimedOut"
+ case settingsBluetoothIssue = "-shotSettingsBluetoothIssue"
+ case settingsStalled = "-shotSettingsStalled"
+ case settingsClickLowBattery = "-shotSettingsClickLowBattery"
+ case settingsClickDuplicates = "-shotSettingsClickDuplicates"
+ case settingsClickIdentifying = "-shotSettingsClickIdentifying"
+ case settingsUnsafeGears = "-shotSettingsUnsafeGears"
+ case settingsAccessibility = "-shotSettingsAccessibility"
+ case setupWizard = "-shotSetupWizard"
+ case setupWizardAccessibility = "-shotSetupWizardAccessibility"
case gears = "-shotGears"
case realGears = "-shotRealGears"
case headwind = "-shotHeadwind"
+ case headwindAutomatic = "-shotHeadwindAutomatic"
+ case headwindPending = "-shotHeadwindPending"
+ case headwindError = "-shotHeadwindError"
case demo = "-shotDemo"
static let kickrID = UUID(uuidString: "10000000-0000-0000-0000-000000000001")!
@@ -138,17 +161,20 @@ private struct ScreenshotFixtureView: View {
var body: some View {
Group {
switch scenario {
- case .starting, .ready, .failed:
+ case .starting, .startupLooking, .startupChoosing, .ready, .failed,
+ .unparked:
StartupView(
store: store,
kickr: kickr,
click: click,
headwind: headwind,
coordinator: coordinator,
- beginsDiscovery: false
+ beginsDiscovery: false,
+ startsWithTrainerChoice: scenario == .startupChoosing
)
case .ride, .rideAccessibility, .rideWaiting, .rideLowBattery,
- .ridePending, .ridePressed, .rideReconnecting:
+ .ridePending, .ridePressed, .rideReconnecting, .rideStopping,
+ .rideWheelSize:
ShiftingView(
store: store,
kickr: kickr,
@@ -157,7 +183,12 @@ private struct ScreenshotFixtureView: View {
coordinator: coordinator,
onRiderStop: {}
)
- case .settings:
+ case .settings, .settingsSearching, .settingsResults,
+ .settingsUnsupported, .settingsTimedOut,
+ .settingsBluetoothIssue, .settingsStalled,
+ .settingsClickLowBattery, .settingsClickDuplicates,
+ .settingsClickIdentifying, .settingsUnsafeGears,
+ .settingsAccessibility:
NavigationStack {
SetupView(
store: store,
@@ -167,11 +198,15 @@ private struct ScreenshotFixtureView: View {
autoConnectsOnAppear: false
)
}
+ case .setupWizard, .setupWizardAccessibility:
+ NavigationStack {
+ SetupWizardView(store: store, onFinish: {})
+ }
case .gears, .realGears:
NavigationStack {
GearChoiceView(store: store)
}
- case .headwind:
+ case .headwind, .headwindAutomatic, .headwindPending, .headwindError:
NavigationStack {
HeadwindControlView(headwind: headwind)
}
@@ -180,7 +215,10 @@ private struct ScreenshotFixtureView: View {
}
}
.dynamicTypeSize(
- scenario == .rideAccessibility ? .accessibility5 : .large
+ scenario == .rideAccessibility
+ || scenario == .setupWizardAccessibility
+ || scenario == .settingsAccessibility
+ ? .accessibility5 : .large
)
.task {
stage()
@@ -192,10 +230,20 @@ private struct ScreenshotFixtureView: View {
private func stage() {
guard scenario != .demo else { return }
var configuration = AppConfiguration()
- configuration.rememberKickr(
- named: "Wahoo KICKR 2A93",
- id: ScreenshotFixture.kickrID
- )
+ configuration.parkInSuggestion()
+ // Every fixture here represents a rider who has already been through
+ // setup once, not a first launch — so the guide should not pop up
+ // and steal the screenshot. The wizard fixture is the one exception:
+ // it exists to test the guide itself, so it must start unseen.
+ if scenario != .setupWizard {
+ configuration.completeSetupWizard()
+ }
+ if scenario != .startupLooking && scenario != .startupChoosing {
+ configuration.rememberKickr(
+ named: "Wahoo KICKR 2A93",
+ id: ScreenshotFixture.kickrID
+ )
+ }
configuration.rememberClick(
named: "Zwift Click",
id: ScreenshotFixture.clickID
@@ -205,17 +253,93 @@ private struct ScreenshotFixtureView: View {
id: ScreenshotFixture.headwindID
)
configuration.usesVirtualGears = scenario != .realGears
+ if scenario == .settingsUnsafeGears {
+ configuration.usesVirtualGears = true
+ configuration.gearLadderID = GearLadderCatalog.customLadderID
+ configuration.customLadder = CustomGearLadder(
+ gearCount: 24,
+ easiestRatioHundredths: 24,
+ hardestRatioHundredths: 1_000
+ )
+ }
+ // The screenshot rider has already parked the bike and confirmed the
+ // gear, which is the state every screen after setup is drawn in.
+ if scenario == .unparked {
+ configuration.physical.parkedChainringTeeth = nil
+ configuration.physical.parkedCogTeeth = nil
+ } else {
+ configuration.parkInSuggestion()
+ }
store.configuration = configuration
+ let trainerCandidates = [
+ BluetoothCandidate(
+ id: ScreenshotFixture.kickrID,
+ name: "Wahoo KICKR 2A93",
+ compatibility: .supported
+ ),
+ BluetoothCandidate(
+ id: UUID(uuidString: "10000000-0000-0000-0000-000000000011")!,
+ name: scenario == .settingsUnsupported
+ ? "Wahoo KICKR SNAP 7B20" : "Wahoo KICKR 7B20",
+ compatibility: scenario == .settingsUnsupported
+ ? .unsupported(
+ model: "KICKR SNAP",
+ reason: "Wheel-on trainers do not support virtual shifting."
+ )
+ : .supported
+ ),
+ ]
+ let stagedTrainerState: ProductConnectionState
+ switch scenario {
+ case .starting:
+ stagedTrainerState = .connecting(name: configuration.kickrName)
+ case .startupLooking, .settingsSearching:
+ stagedTrainerState = .scanning
+ case .settingsBluetoothIssue:
+ stagedTrainerState = .unavailable(
+ "Bluetooth permission is required to find equipment."
+ )
+ case .settingsStalled:
+ stagedTrainerState = .connecting(name: configuration.kickrName)
+ default:
+ stagedTrainerState = .ready
+ }
kickr.stageScreenshot(
- name: configuration.kickrName,
- state: scenario == .starting
- ? .connecting(name: configuration.kickrName) : .ready
+ name: configuration.kickrName.isEmpty
+ ? "Wahoo KICKR 2A93" : configuration.kickrName,
+ state: stagedTrainerState,
+ candidates: scenario == .startupChoosing
+ || scenario == .settingsResults
+ || scenario == .settingsUnsupported
+ ? trainerCandidates : [],
+ stalled: scenario == .settingsStalled
+ )
+ let duplicateClicks = [
+ BluetoothCandidate(
+ id: ScreenshotFixture.clickID,
+ name: "Zwift Click"
+ ),
+ BluetoothCandidate(
+ id: UUID(uuidString: "10000000-0000-0000-0000-000000000012")!,
+ name: "Zwift Click"
+ ),
+ ]
+ click.stageScreenshot(
+ name: configuration.clickName,
+ batteryLevel: scenario == .settingsClickLowBattery ? 15 : 82,
+ candidates: scenario == .settingsClickDuplicates
+ || scenario == .settingsClickIdentifying ? duplicateClicks : [],
+ identifying: scenario == .settingsClickIdentifying
+ ? ScreenshotFixture.clickID : nil
)
- click.stageScreenshot(name: configuration.clickName, batteryLevel: 82)
headwind.stageScreenshot(
name: configuration.headwindName ?? "KICKR HEADWIND",
- speed: 50
+ speed: 50,
+ manual: scenario != .headwindAutomatic,
+ pending: scenario == .headwindPending,
+ error: scenario == .headwindError
+ ? "The Headwind did not confirm the change." : nil
)
if scenario == .rideLowBattery {
@@ -224,7 +348,8 @@ private struct ScreenshotFixtureView: View {
click.stageScreenshotPressedButton(.plus)
}
- if scenario == .ready || scenario == .rideWaiting {
+ if scenario == .ready || scenario == .rideWaiting
+ || scenario == .unparked {
(coordinator.peripheral as? FTMSPeripheral)?
.stageScreenshotAdvertising()
} else if isRideScenario {
@@ -244,13 +369,18 @@ private struct ScreenshotFixtureView: View {
state: .connecting(name: configuration.kickrName)
)
coordinator.stageScreenshotReconnecting()
+ } else if scenario == .rideStopping {
+ coordinator.stageScreenshotStopping()
+ } else if scenario == .rideWheelSize {
+ coordinator.stageScreenshotRidingAppWheelSize()
}
}
private var isRideScenario: Bool {
switch scenario {
case .ride, .rideAccessibility, .rideWaiting, .rideLowBattery,
- .ridePending, .ridePressed, .rideReconnecting:
+ .ridePending, .ridePressed, .rideReconnecting, .rideStopping,
+ .rideWheelSize:
true
default:
false
diff --git a/VirtualGearsProduct/VirtualGearsHomeView.swift b/VirtualGearsProduct/VirtualGearsHomeView.swift
index e64645b..f747059 100644
--- a/VirtualGearsProduct/VirtualGearsHomeView.swift
+++ b/VirtualGearsProduct/VirtualGearsHomeView.swift
@@ -145,12 +145,14 @@ struct StartupView: View {
@Bindable var headwind: HeadwindCentralService
@Bindable var coordinator: ProxyCoordinator
var beginsDiscovery = true
+ var startsWithTrainerChoice = false
var onTryDemo: () -> Void = {}
@State private var showsSettings = false
/// Set when more than one trainer is found, which is the one situation
/// where the rider has to say which is theirs.
@State private var mustChoose = false
@State private var trainerScanSettled = false
+ @State private var showsSetupWizard = false
var body: some View {
NavigationStack {
@@ -168,12 +170,6 @@ struct StartupView: View {
searching
retryButton
}
- // Fixed in the layout regardless of state, so it never
- // appears or disappears under the button above it. It used
- // to live only inside the searching and chooser cards, so
- // the button jumped the instant the trainer connected and
- // this reminder vanished with the rest of that card.
- chainReminder
demoEntry
}
.frame(maxWidth: 560)
@@ -201,7 +197,22 @@ struct StartupView: View {
)
}
}
+ .sheet(isPresented: $showsSetupWizard) {
+ NavigationStack {
+ SetupWizardView(
+ store: store,
+ onFinish: { showsSetupWizard = false }
+ )
+ }
+ }
.task {
+ if startsWithTrainerChoice {
+ trainerScanSettled = true
+ mustChoose = true
+ }
+ if !store.configuration.setupWizardCompleted {
+ showsSetupWizard = true
+ }
if beginsDiscovery {
await begin()
}
@@ -309,18 +320,6 @@ struct StartupView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
- /// The one thing the app cannot do for the rider.
- private var chainReminder: some View {
- Label(
- "Use the smaller front ring if your bike has one. Pick a rear gear "
- + "that keeps the chain straight, and leave it there.",
- systemImage: "link"
- )
- .font(.footnote)
- .foregroundStyle(.secondary)
- .padding(.top, 4)
- }
-
private var demoEntry: some View {
VStack(spacing: 8) {
Divider()
@@ -477,33 +476,70 @@ struct StartupView: View {
/// The same control starts shifting and tries again after a failure. After a
/// failure "Start Shifting" reads as though nothing had been attempted,
/// which is exactly the doubt the card above it has just resolved.
+ ///
+ /// When the only missing piece is the parked gear, this button used to sit
+ /// there greyed out, naming exactly what to do while giving no way to do
+ /// it — the rider had to notice the unrelated Settings gear icon on their
+ /// own. Now it opens Settings itself, so the instruction it gives is also
+ /// the thing tapping it does.
private var retryButton: some View {
Button {
- headwind.applySavedControlPreference()
- coordinator.startShifting(configuration: store.configuration)
+ if needsParkedGear {
+ if store.configuration.setupWizardCompleted {
+ showsSettings = true
+ } else {
+ showsSetupWizard = true
+ }
+ } else {
+ headwind.applySavedControlPreference()
+ coordinator.startShifting(configuration: store.configuration)
+ }
} label: {
Label(
retryTitle,
systemImage: canStart
? (failureMessage == nil ? "bicycle" : "arrow.clockwise")
- : "hourglass"
+ : (needsParkedGear ? "gearshape" : "hourglass")
)
.font(.title2.bold())
.frame(maxWidth: .infinity, minHeight: 64)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
- .disabled(!canStart)
- .accessibilityHint(
- canStart ? "Starts virtual shifting" : "Your trainer is not connected yet"
- )
+ .disabled(!canStart && !needsParkedGear)
+ .accessibilityHint(canStart ? "Starts virtual shifting" : blockedHint)
+ }
+
+ /// True when the parked gear is the only thing standing between here and
+ /// starting, regardless of whether the trainer has connected yet — so the
+ /// button stays actionable even before the trainer is found.
+ private var needsParkedGear: Bool {
+ store.configuration.parkedGear == nil
}
private var retryTitle: String {
- guard canStart else { return "Waiting for trainer" }
+ guard canStart else { return waitingTitle }
return failureMessage == nil ? "Start Shifting" : "Try Again"
}
+ /// Naming the actual blocker matters: a rider told to wait for a trainer
+ /// that is sitting there connected has no way to discover that the missing
+ /// thing is which gear the bike is parked in.
+ private var waitingTitle: String {
+ store.configuration.parkedGear == nil
+ ? "Set the gear you are in"
+ : "Waiting for trainer"
+ }
+
+ private var blockedHint: String {
+ store.configuration.parkedGear == nil
+ ? (store.configuration.setupWizardCompleted
+ ? "Opens Settings so you can set which gear the bike is parked in"
+ : "Opens the setup guide so you can set which gear the bike "
+ + "is parked in")
+ : "Your trainer is not connected yet"
+ }
+
// MARK: - Starting
/// Readiness means actually connected, not merely remembered.
@@ -1521,6 +1557,7 @@ struct ShiftingView: View {
"Your riding app set the wheel size. "
+ "Your gears are built around it."
)
+ .accessibilityIdentifier("note.ridingAppWheelSize")
}
}
.font(.caption)
diff --git a/VirtualGearsUITests/VirtualGearsUITests.swift b/VirtualGearsUITests/VirtualGearsUITests.swift
index acf30cb..db2b251 100644
--- a/VirtualGearsUITests/VirtualGearsUITests.swift
+++ b/VirtualGearsUITests/VirtualGearsUITests.swift
@@ -1,8 +1,143 @@
import XCTest
+/// The maintained contract for "100% UX coverage": every intentionally
+/// designed, app-owned visual state belongs here and is exercised by one of the
+/// journey tests below. Adding a state without assigning it a test fails the
+/// manifest completeness test.
+private enum DesignedUXState: String, CaseIterable {
+ // Setup wizard
+ case wizardBikeCassette = "setup/bike-cassette"
+ case wizardBikeSingleSprocket = "setup/bike-single-sprocket"
+ case wizardParkedGearRecommended = "setup/parked-gear-recommended"
+ case wizardParkedGearWarning = "setup/parked-gear-warning"
+ case wizardAccessibilityText = "setup/accessibility-text"
+
+ // Startup
+ case startupSavedTrainerConnecting = "startup/saved-trainer-connecting"
+ case startupLookingForTrainer = "startup/looking-for-trainer"
+ case startupChooseTrainer = "startup/choose-trainer"
+ case startupReady = "startup/ready"
+ case startupMissingParkedGear = "startup/missing-parked-gear"
+ case startupFailure = "startup/failure"
+
+ // Ride
+ case rideActive = "ride/active"
+ case rideWaitingForApp = "ride/waiting-for-riding-app"
+ case ridePendingShift = "ride/pending-shift"
+ case rideClickPressed = "ride/click-pressed"
+ case rideLowBattery = "ride/low-click-battery"
+ case rideReconnecting = "ride/reconnecting"
+ case rideStopping = "ride/stopping"
+ case rideAppWheelSize = "ride/riding-app-wheel-size"
+ case rideStopConfirmation = "ride/stop-confirmation"
+ case rideLandscape = "ride/landscape"
+ case rideAccessibilityText = "ride/accessibility-text"
+ case rideDarkMode = "ride/dark-mode"
+
+ // Settings and equipment
+ case settingsConnected = "settings/connected"
+ case settingsMissingParkedGear = "settings/missing-parked-gear"
+ case settingsUnsafeGears = "settings/unsafe-gears"
+ case settingsAccessibilityText = "settings/accessibility-text"
+ case wheelSizeDefault = "settings/wheel-size-default"
+ case wheelSizeShortcut = "settings/wheel-size-shortcut"
+ case wheelSizeValid = "settings/wheel-size-valid"
+ case wheelSizeInvalid = "settings/wheel-size-invalid"
+ case trainerConnected = "equipment/trainer-connected"
+ case trainerSearching = "equipment/trainer-searching"
+ case trainerResults = "equipment/trainer-results"
+ case trainerUnsupported = "equipment/trainer-unsupported"
+ case trainerTimedOut = "equipment/trainer-timed-out"
+ case trainerBluetoothIssue = "equipment/trainer-bluetooth-issue"
+ case trainerStalled = "equipment/trainer-stalled"
+ case clickConnected = "equipment/click-connected"
+ case clickLowBattery = "equipment/click-low-battery"
+ case clickDuplicatePrompt = "equipment/click-duplicate-prompt"
+ case clickIdentifying = "equipment/click-identifying"
+ case headwindSetup = "equipment/headwind-connected"
+
+ // Simulated and physical gears
+ case gearsVirtual = "gears/virtual"
+ case gearsCustom = "gears/custom"
+ case gearsRealBike = "gears/real-bike"
+ case groupsetPicker = "gears/groupset-picker"
+ case groupsetMatchBikeOffer = "gears/groupset-match-bike-offer"
+ case chainringPicker = "gears/chainring-picker"
+ case cassettePicker = "gears/cassette-picker"
+ case gearPreviewTooWide = "gears/preview-too-wide"
+ case parkedGearCassette = "physical/parked-gear-cassette"
+ case parkedGearSingleSprocket = "physical/parked-gear-single-sprocket"
+ case parkedGearOutOfReach = "physical/parked-gear-out-of-reach"
+
+ // Headwind
+ case headwindManual = "headwind/manual"
+ case headwindAutomatic = "headwind/automatic"
+ case headwindPending = "headwind/pending-command"
+ case headwindError = "headwind/command-error"
+ case headwindLandscape = "headwind/landscape"
+ case headwindDarkMode = "headwind/dark-mode"
+
+ // Demo
+ case demoRide = "demo/ride"
+ case demoSettings = "demo/settings"
+ case demoGearSettings = "demo/gear-settings"
+ case demoHeadwindAutomatic = "demo/headwind-automatic"
+ case demoHeadwindManual = "demo/headwind-manual"
+}
+
+private let uxCoverageManifest: [DesignedUXState: String] = {
+ var result: [DesignedUXState: String] = [:]
+ func assign(_ states: [DesignedUXState], to test: String) {
+ for state in states { result[state] = test }
+ }
+ assign([
+ .wizardBikeCassette, .wizardBikeSingleSprocket,
+ .wizardParkedGearRecommended, .wizardParkedGearWarning,
+ ], to: "testUXCoverageSetupWizardStates")
+ assign([
+ .startupSavedTrainerConnecting, .startupLookingForTrainer,
+ .startupChooseTrainer, .startupReady, .startupMissingParkedGear,
+ .startupFailure,
+ ], to: "testUXCoverageStartupStates")
+ assign([
+ .rideActive, .rideWaitingForApp, .ridePendingShift, .rideClickPressed,
+ .rideLowBattery, .rideReconnecting, .rideStopping, .rideAppWheelSize,
+ .rideStopConfirmation, .rideLandscape, .rideAccessibilityText,
+ ], to: "testUXCoverageRideStates")
+ assign([
+ .settingsConnected, .settingsMissingParkedGear, .settingsUnsafeGears,
+ .wheelSizeDefault, .wheelSizeShortcut, .wheelSizeValid,
+ .wheelSizeInvalid, .trainerConnected,
+ .trainerSearching, .trainerResults, .trainerUnsupported,
+ .trainerTimedOut, .trainerBluetoothIssue, .trainerStalled,
+ .clickConnected, .clickLowBattery, .clickDuplicatePrompt,
+ .clickIdentifying, .headwindSetup,
+ ], to: "testUXCoverageSettingsAndEquipmentStates")
+ assign([
+ .gearsVirtual, .gearsCustom, .gearsRealBike, .groupsetPicker,
+ .groupsetMatchBikeOffer, .chainringPicker, .cassettePicker,
+ .gearPreviewTooWide, .parkedGearCassette,
+ .parkedGearSingleSprocket, .parkedGearOutOfReach,
+ ], to: "testUXCoverageGearAndPhysicalBikeStates")
+ assign([
+ .headwindManual, .headwindAutomatic, .headwindPending, .headwindError,
+ .headwindLandscape,
+ ], to: "testUXCoverageHeadwindStates")
+ assign([
+ .wizardAccessibilityText, .settingsAccessibilityText, .rideDarkMode,
+ .headwindDarkMode,
+ ], to: "testUXCoverageAccessibilityAndAppearanceVariants")
+ assign([
+ .demoRide, .demoSettings, .demoGearSettings,
+ .demoHeadwindAutomatic, .demoHeadwindManual,
+ ], to: "testUXCoverageDemoStates")
+ return result
+}()
+
@MainActor
final class VirtualGearsUITests: XCTestCase {
private var app: XCUIApplication!
+ private var capturedUXStates: Set = []
func testStartingScreenShowsEveryConfiguredEquipmentStatus() {
launch("-shotStarting")
@@ -238,11 +373,134 @@ final class VirtualGearsUITests: XCTestCase {
assertVisibleElement(app.buttons["Stop virtual shifting"])
}
+ func testSetupGuideWalksBikeThenParkedGear() {
+ launch("-shotSetupWizard")
+
+ assertVisible("screen.setupWizard")
+ XCTAssertTrue(
+ app.navigationBars["Set up Virtual Gears"].waitForExistence(timeout: 2)
+ )
+ assertVisibleElement(app.descendants(matching: .any).matching(
+ NSPredicate(format: "label CONTAINS %@", "Standard 24")
+ ).firstMatch)
+ XCTAssertFalse(app.buttons["Set up later"].exists)
+ app.buttons["wizard.useBikeSetup"].tap()
+
+ XCTAssertTrue(
+ app.navigationBars["Position your chain"].waitForExistence(timeout: 2)
+ )
+ let finish = app.buttons["Finish setup"]
+ XCTAssertTrue(finish.waitForExistence(timeout: 2))
+ XCTAssertTrue(finish.isEnabled)
+
+ app.navigationBars.buttons.firstMatch.tap()
+ XCTAssertTrue(
+ app.navigationBars["Set up Virtual Gears"].waitForExistence(timeout: 2),
+ "Back from chain position should return to the physical bike"
+ )
+ }
+
+ func testFirstRunOffersZwiftCogAndAnySingleSprocket() {
+ launch("-shotSetupWizard")
+
+ let single = app.buttons["wizard.rear.singleSprocket"]
+ assertVisibleElement(single)
+ XCTAssertTrue(single.label.contains("Zwift Cog"))
+ single.tap()
+ XCTAssertTrue(single.isSelected)
+ XCTAssertEqual(app.steppers["wizard.sprocketTeeth"].value as? String, "14")
+ app.buttons["wizard.sprocketTeeth-Increment"].tap()
+ XCTAssertEqual(app.steppers["wizard.sprocketTeeth"].value as? String, "15")
+
+ app.buttons["wizard.useBikeSetup"].tap()
+ XCTAssertTrue(
+ app.navigationBars["Position your chain"].waitForExistence(timeout: 2)
+ )
+ assertVisibleElement(
+ app.buttons.matching(
+ NSPredicate(format: "label CONTAINS %@", "/15")
+ ).firstMatch
+ )
+ }
+
+ func testFirstRunHasNoDismissAction() {
+ launch("-shotSetupWizard")
+
+ assertVisible("screen.setupWizard")
+ XCTAssertFalse(app.buttons["Set up later"].exists)
+ XCTAssertFalse(app.buttons["Cancel"].exists)
+ XCTAssertFalse(app.navigationBars.buttons["Back"].exists)
+ }
+
+ func testSettingsDoesNotOfferTheFirstRunWizardAgain() {
+ launch("-shotSettings")
+
+ assertVisible("screen.settings")
+ XCTAssertFalse(app.staticTexts["Run setup guide again"].exists)
+ XCTAssertFalse(app.staticTexts["Start the full setup guide"].exists)
+ }
+
+ func testWheelCircumferenceShortcutsManualEntryAndDefault() {
+ launch("-shotSettings")
+ app.staticTexts["Wheel circumference"].firstMatch.tap()
+ XCTAssertTrue(
+ app.navigationBars["Wheel circumference"].waitForExistence(timeout: 2)
+ )
+
+ let field = app.textFields["Millimetres"]
+ let shortcuts = app.scrollViews["wheel.shortcuts"]
+ let choices = [
+ ("Road, 700×25, 2105 millimetres", "2105"),
+ ("Road, 700×28, 2136 millimetres", "2136"),
+ ("Gravel, 700×40, 2200 millimetres", "2200"),
+ ("MTB, 26×2.0, 2055 millimetres", "2055"),
+ ("MTB, 27.5×2.25, 2188 millimetres", "2188"),
+ ("MTB, 29×2.25, 2326 millimetres", "2326"),
+ ]
+
+ for (label, expectedValue) in choices {
+ let button = app.buttons[label]
+ for _ in 0..<6 where !button.exists || !button.isHittable {
+ shortcuts.swipeLeft(velocity: .slow)
+ }
+ XCTAssertTrue(button.isHittable, "\(label) is not reachable")
+ button.tap()
+ XCTAssertEqual(field.value as? String, expectedValue)
+ XCTAssertTrue(button.isSelected)
+ }
+
+ field.tap()
+ field.typeText(
+ String(repeating: XCUIKeyboardKey.delete.rawValue, count: 8)
+ )
+ field.typeText("2180")
+ XCTAssertEqual(field.value as? String, "2180")
+ XCTAssertFalse(
+ app.buttons["MTB, 29×2.25, 2326 millimetres"].isSelected
+ )
+
+ let useDefault = app.buttons["wheel.useDefault"]
+ XCTAssertTrue(useDefault.isEnabled)
+ useDefault.tap()
+ XCTAssertEqual(field.value as? String, "2105")
+ expectation(
+ for: NSPredicate(format: "isEnabled == NO"),
+ evaluatedWith: useDefault
+ )
+ waitForExpectations(timeout: 2)
+ app.navigationBars.buttons.firstMatch.tap()
+ assertVisibleElement(app.descendants(matching: .any).matching(
+ NSPredicate(format: "label CONTAINS %@", "Default · 2105 mm")
+ ).firstMatch)
+ }
+
func testSettingsNavigatesToEveryDestination() {
launch("-shotSettings")
assertVisible("screen.settings")
- for destination in ["Trainer", "Zwift Click", "Wahoo Headwind", "Gears"] {
+ for destination in [
+ "Trainer", "Zwift Click", "Wahoo Headwind", "Gears",
+ ] {
let row = app.staticTexts[destination].firstMatch
assertVisibleElement(row)
row.tap()
@@ -252,6 +510,100 @@ final class VirtualGearsUITests: XCTestCase {
)
app.navigationBars.buttons.firstMatch.tap()
}
+
+ // The parked-gear row's title and value collapse into a single
+ // accessibility element, so it is reached by identifier rather than
+ // its title text like the rows above. It also sits below the fold
+ // now that the setup guide row was added at the top, so the list
+ // needs a scroll before it is on screen.
+ app.swipeUp()
+ let parkedGearRow = app.descendants(matching: .any)["row.parkedGear"]
+ assertVisibleElement(parkedGearRow)
+ parkedGearRow.tap()
+ XCTAssertTrue(
+ app.navigationBars["Gear the bike is in"].waitForExistence(timeout: 2),
+ "Gear the bike is in screen did not open"
+ )
+ app.navigationBars.buttons.firstMatch.tap()
+ }
+
+ /// The gear the bike is parked in is the one thing setup cannot guess, so
+ /// the screen has to name a gear, mark it as the recommendation and let it
+ /// be confirmed in a tap.
+ func testParkedGearScreenRecommendsAGearAndLetsItBeConfirmed() {
+ launch("-shotSettings")
+
+ // The row is below the fold once the setup guide row is above it.
+ app.swipeUp()
+ app.descendants(matching: .any)["row.parkedGear"].firstMatch.tap()
+ assertVisible("screen.parkedGear")
+
+ // The advice has to name a gear rather than describe one, because
+ // "a quiet, straight chain line" is true of gears twice as hard as
+ // each other.
+ let advice = app.staticTexts.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Park the chain on the")
+ ).firstMatch
+ XCTAssertTrue(advice.waitForExistence(timeout: 3))
+
+ // Gears that would put part of the ladder beyond the trainer's reach
+ // are still listed, and say so, rather than silently vanishing.
+ let outOfReach = app.buttons.matching(
+ NSPredicate(
+ format: "label CONTAINS[c] %@", "Puts some gears out of reach"
+ )
+ ).firstMatch
+ XCTAssertTrue(outOfReach.waitForExistence(timeout: 3))
+
+ // Confirming one of them has to stick and has to leave a one-tap way
+ // back, rather than the app silently overriding the rider.
+ outOfReach.tap()
+ XCTAssertTrue(
+ app.buttons.matching(
+ NSPredicate(format: "label BEGINSWITH %@", "Use ")
+ ).firstMatch.waitForExistence(timeout: 3),
+ "Confirming another gear should offer a way back to the "
+ + "recommendation"
+ )
+
+ app.navigationBars.buttons.firstMatch.tap()
+ assertVisible("screen.settings")
+ XCTAssertFalse(
+ app.staticTexts.matching(
+ NSPredicate(
+ format: "label CONTAINS[c] %@",
+ "gears fall outside the trainer's safe range"
+ )
+ ).firstMatch.exists,
+ "A bad parked gear must not make valid simulated gearing look unsafe"
+ )
+ let finishSetup = app.descendants(matching: .any)["action.finishSetup"]
+ .firstMatch
+ for _ in 0..<3 where !finishSetup.exists || !finishSetup.isHittable {
+ app.swipeDown()
+ }
+ assertVisibleElement(finishSetup)
+ XCTAssertTrue(finishSetup.label.contains("Confirm the gear"))
+ finishSetup.tap()
+ assertVisible("screen.parkedGear")
+ }
+
+
+ /// The parked gear is the app's biggest silent-failure risk, so a rider who
+ /// has not confirmed one is told exactly that rather than being told to wait
+ /// for a trainer that is already connected. It also used to be disabled,
+ /// naming an action it wouldn't perform — now it opens Settings itself.
+ func testStartNamesTheParkedGearWhenThatIsWhatIsMissing() {
+ launch("-shotUnparked")
+
+ let button = app.buttons["Set the gear you are in"]
+ assertVisibleElement(button)
+ XCTAssertTrue(button.isEnabled)
+ XCTAssertFalse(app.buttons["Waiting for trainer"].exists)
+ XCTAssertFalse(app.buttons["Start Shifting"].exists)
+
+ button.tap()
+ assertVisibleElement(app.navigationBars["Settings"])
}
func testVirtualGearChoiceShowsModeAndPreview() {
@@ -297,6 +649,164 @@ final class VirtualGearsUITests: XCTestCase {
XCTAssertTrue(app.navigationBars["Cassette"].waitForExistence(timeout: 2))
}
+ /// Regression test for a rendering bug: every selectable row added for
+ /// setup (ladders, groupsets, physical parts, the parked gear) was a
+ /// hand-rolled `Button` that iOS 26 rendered entirely in the accent
+ /// colour, rather than the standard black-text-with-a-blue-checkmark
+ /// list style every other row in the app uses. `.buttonStyle(.plain)`
+ /// alone did not fix it. The rows now all share the one `ChoiceRow`
+ /// already proven correct by the pre-existing chainring/cassette
+ /// pickers, so this exercises that every one of them still reports
+ /// exactly one selected row, and that tapping a different one moves the
+ /// selection rather than leaving two rows marked or none at all.
+ func testGroupsetChoiceMovesTheCheckmarkOnSelection() {
+ launch("-shotRealGears")
+
+ assertVisible("screen.gears")
+ app.staticTexts["Groupset"].tap()
+ XCTAssertTrue(app.navigationBars["Groupset"].waitForExistence(timeout: 2))
+
+ let current = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Shimano 105 R7100")
+ ).firstMatch
+ let other = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Shimano Dura-Ace R9200")
+ ).firstMatch
+ XCTAssertTrue(current.waitForExistence(timeout: 2))
+ XCTAssertTrue(other.waitForExistence(timeout: 2))
+ XCTAssertTrue(current.isSelected)
+ XCTAssertFalse(other.isSelected)
+
+ other.tap()
+ XCTAssertTrue(other.isSelected)
+ XCTAssertFalse(current.isSelected)
+
+ app.navigationBars.buttons.firstMatch.tap()
+ XCTAssertTrue(
+ app.staticTexts["Groupset, Shimano Dura-Ace R9200"]
+ .waitForExistence(timeout: 2)
+ )
+ }
+
+ /// Same regression coverage as above, for the virtual gear ladder rows —
+ /// updated for the Standard/Custom redesign: one built-in ladder plus a
+ /// rider-defined one, instead of choosing between two fixed tables.
+ func testGearLadderChoiceCanSwitchToACustomLadderAndBack() {
+ launch("-shotGears")
+
+ assertVisible("screen.gears")
+ let standard = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Standard 24")
+ ).firstMatch
+ XCTAssertTrue(standard.waitForExistence(timeout: 2))
+ XCTAssertTrue(standard.isSelected)
+
+ let custom = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Custom")
+ ).firstMatch
+ XCTAssertTrue(custom.waitForExistence(timeout: 2))
+ XCTAssertFalse(custom.isSelected)
+
+ custom.tap()
+ XCTAssertTrue(
+ app.navigationBars["Custom Ladder"].waitForExistence(timeout: 2)
+ )
+ app.navigationBars.buttons.firstMatch.tap()
+
+ assertVisible("screen.gears")
+ XCTAssertFalse(standard.isSelected)
+ XCTAssertTrue(custom.isSelected)
+
+ standard.tap()
+ XCTAssertTrue(standard.isSelected)
+ XCTAssertFalse(custom.isSelected)
+ }
+
+ /// Editing the gear count on the Custom ladder screen should change the
+ /// preview at the bottom — proof the rider's own numbers actually reach
+ /// the gearing that gets simulated, not just a label.
+ func testCustomLadderGearCountChangesThePreview() {
+ launch("-shotGears")
+ assertVisible("screen.gears")
+
+ app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Custom")
+ ).firstMatch.tap()
+ XCTAssertTrue(
+ app.navigationBars["Custom Ladder"].waitForExistence(timeout: 2)
+ )
+ XCTAssertTrue(app.staticTexts["24 gears"].waitForExistence(timeout: 2))
+
+ app.steppers.firstMatch.buttons["Increment"].tap()
+ XCTAssertTrue(app.staticTexts["25 gears"].waitForExistence(timeout: 2))
+ }
+
+ /// Same regression coverage, for the physical chainring and cassette
+ /// pickers reached from the parked-gear screen.
+ func testPhysicalChainringAndCassetteChoicesUpdateTheSummary() {
+ launch("-shotSettings")
+
+ // The row is below the fold once the setup guide row is above it.
+ app.swipeUp()
+ app.descendants(matching: .any)["row.parkedGear"].firstMatch.tap()
+ assertVisible("screen.parkedGear")
+
+ app.staticTexts["Chainrings"].firstMatch.tap()
+ XCTAssertTrue(
+ app.navigationBars["Chainrings on the bike"].waitForExistence(timeout: 2)
+ )
+ assertVisibleElement(app.staticTexts["One chainring"])
+ let ring38 = app.buttons.matching(
+ NSPredicate(format: "label == %@", "38")
+ ).firstMatch
+ let ring40 = app.buttons.matching(
+ NSPredicate(format: "label == %@", "40")
+ ).firstMatch
+ assertVisibleElement(ring38)
+ assertVisibleElement(ring40)
+ XCTAssertLessThan(ring38.frame.minY, ring40.frame.minY)
+ let defaultChainring = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "50/34")
+ ).firstMatch
+ let otherChainring = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "38")
+ ).firstMatch
+ for _ in 0..<3 where !app.staticTexts["Two chainrings"].exists {
+ app.swipeUp()
+ }
+ assertVisibleElement(app.staticTexts["Two chainrings"])
+ for _ in 0..<3 where !defaultChainring.exists {
+ app.swipeUp()
+ }
+ XCTAssertTrue(defaultChainring.isSelected)
+ for _ in 0..<3 where !otherChainring.isHittable {
+ app.swipeDown()
+ }
+ XCTAssertFalse(otherChainring.isSelected)
+ otherChainring.tap()
+ XCTAssertTrue(otherChainring.isSelected)
+ app.navigationBars.buttons.firstMatch.tap()
+ XCTAssertTrue(
+ app.staticTexts["Chainrings, 38"].waitForExistence(timeout: 2)
+ )
+
+ app.staticTexts["Cassette"].firstMatch.tap()
+ XCTAssertTrue(
+ app.navigationBars["Cassette on the bike"].waitForExistence(timeout: 2)
+ )
+ let otherCassette = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "11-30 · 11 cogs")
+ ).firstMatch
+ XCTAssertTrue(otherCassette.waitForExistence(timeout: 2))
+ XCTAssertFalse(otherCassette.isSelected)
+ otherCassette.tap()
+ XCTAssertTrue(otherCassette.isSelected)
+ app.navigationBars.buttons.firstMatch.tap()
+ XCTAssertTrue(
+ app.staticTexts["Cassette, 11-30"].waitForExistence(timeout: 2)
+ )
+ }
+
func testHeadwindControlsExposeModeSpeedAndPresets() {
launch("-shotHeadwind")
@@ -391,7 +901,491 @@ final class VirtualGearsUITests: XCTestCase {
XCTAssertFalse(app.buttons["50 percent"].isSelected)
}
- private func launch(_ fixture: String) {
+ func testDesignedUXStateManifestIsComplete() {
+ XCTAssertEqual(
+ Set(uxCoverageManifest.keys),
+ Set(DesignedUXState.allCases),
+ "Every designed app-owned state must be assigned to executable UX coverage."
+ )
+ XCTAssertTrue(
+ uxCoverageManifest.values.allSatisfy { $0.hasPrefix("testUXCoverage") },
+ "Coverage entries must name a journey test rather than prose or a ticket."
+ )
+ }
+
+ func testUXCoverageSetupWizardStates() {
+ launch("-shotSetupWizard")
+ XCTAssertTrue(
+ app.navigationBars["Set up Virtual Gears"].waitForExistence(timeout: 2)
+ )
+ assertVisibleElement(app.buttons["wizard.rear.cassette"])
+ capture(.wizardBikeCassette)
+
+ let singleSprocket = app.buttons["wizard.rear.singleSprocket"]
+ singleSprocket.tap()
+ XCTAssertTrue(singleSprocket.isSelected)
+ assertVisibleElement(app.steppers["wizard.sprocketTeeth"])
+ capture(.wizardBikeSingleSprocket)
+
+ app.buttons["wizard.rear.cassette"].tap()
+ app.buttons["wizard.useBikeSetup"].tap()
+ XCTAssertTrue(
+ app.navigationBars["Position your chain"].waitForExistence(timeout: 2)
+ )
+ assertVisibleElement(app.staticTexts.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Park the chain on")
+ ).firstMatch)
+ capture(.wizardParkedGearRecommended)
+
+ let recommended = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Recommended")
+ ).firstMatch
+ assertVisibleElement(recommended)
+ let outOfReach = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Puts some gears out of reach")
+ ).firstMatch
+ for _ in 0..<10 where !isFullyVisible(outOfReach) {
+ app.swipeUp(velocity: .slow)
+ }
+ assertVisibleElement(outOfReach)
+ outOfReach.tap()
+ let warning = app.staticTexts.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "cannot reach")
+ ).firstMatch
+ for _ in 0..<10 where !isFullyVisible(warning) {
+ app.swipeDown(velocity: .slow)
+ }
+ assertVisibleElement(warning)
+ capture(.wizardParkedGearWarning)
+
+ launch("-shotSetupWizard")
+ app.buttons["wizard.useBikeSetup"].tap()
+ XCTAssertTrue(app.buttons["Finish setup"].isEnabled)
+ assertJourneyCoverage()
+ }
+
+ func testUXCoverageStartupStates() {
+ launch("-shotStarting")
+ assertVisibleElement(app.staticTexts["Getting Virtual Gears ready"])
+ capture(.startupSavedTrainerConnecting)
+
+ launch("-shotStartupLooking")
+ assertVisibleElement(app.staticTexts["Looking for your trainer"])
+ capture(.startupLookingForTrainer)
+
+ launch("-shotStartupChoosing")
+ assertVisibleElement(app.staticTexts["Which one is yours?"])
+ assertVisibleElement(app.buttons["Wahoo KICKR 2A93"])
+ capture(.startupChooseTrainer)
+
+ launch("-shotReady")
+ assertVisibleElement(app.staticTexts["Ready to shift"])
+ assertVisibleElement(app.buttons["Start Shifting"])
+ capture(.startupReady)
+
+ launch("-shotUnparked")
+ assertVisibleElement(app.buttons["Set the gear you are in"])
+ capture(.startupMissingParkedGear)
+
+ launch("-shotFailed")
+ assertVisibleElement(app.staticTexts["Shifting could not start"])
+ assertVisibleElement(app.buttons["Try Again"])
+ capture(.startupFailure)
+ assertJourneyCoverage()
+ }
+
+ func testUXCoverageRideStates() {
+ launch("-shotRide")
+ assertVisible("screen.ride")
+ capture(.rideActive)
+
+ launch("-shotRideWaiting")
+ XCTAssertTrue(
+ app.descendants(matching: .any)["status.ridingapp"].label
+ .contains("Waiting for connection")
+ )
+ capture(.rideWaitingForApp)
+
+ launch("-shotRidePending")
+ assertVisibleElement(app.staticTexts["Shifting…"])
+ capture(.ridePendingShift)
+
+ launch("-shotRidePressed")
+ XCTAssertEqual(app.buttons["Shift harder"].value as? String, "Pressed")
+ capture(.rideClickPressed)
+
+ launch("-shotRideLowBattery")
+ assertVisibleElement(
+ app.descendants(matching: .any)["Click battery low, 15 percent"]
+ )
+ capture(.rideLowBattery)
+
+ launch("-shotRideReconnecting")
+ assertVisibleElement(app.descendants(matching: .any)["ride.status"])
+ capture(.rideReconnecting)
+
+ launch("-shotRideStopping")
+ assertVisibleElement(app.descendants(matching: .any)["ride.status"])
+ capture(.rideStopping)
+
+ launch("-shotRideWheelSize")
+ assertVisibleElement(
+ app.descendants(matching: .any)["note.ridingAppWheelSize"]
+ )
+ capture(.rideAppWheelSize)
+
+ launch("-shotRide")
+ app.buttons["Stop virtual shifting"].tap()
+ assertVisibleElement(app.staticTexts["Stop virtual shifting?"])
+ assertVisibleElement(app.buttons["Cancel"])
+ assertVisibleElement(app.buttons["Stop Shifting"])
+ capture(.rideStopConfirmation)
+
+ launch("-shotRide", orientation: .landscapeLeft)
+ waitForLandscapeLayout()
+ assertVisible("screen.ride")
+ assertVisibleElement(app.buttons["Shift easier"])
+ assertVisibleElement(app.buttons["Shift harder"])
+ capture(.rideLandscape)
+
+ launch("-shotRideAccessibility")
+ assertVisible("screen.ride")
+ assertVisibleElement(app.buttons["Shift easier"])
+ capture(.rideAccessibilityText)
+ assertJourneyCoverage()
+
+ }
+
+ func testUXCoverageSettingsAndEquipmentStates() {
+ launch("-shotSettings")
+ assertVisible("screen.settings")
+ capture(.settingsConnected)
+
+ launch("-shotUnparked")
+ app.buttons["Set the gear you are in"].tap()
+ assertVisible("screen.settings")
+ let finishSetup = app.descendants(matching: .any)["action.finishSetup"]
+ .firstMatch
+ assertVisibleElement(finishSetup)
+ XCTAssertTrue(finishSetup.label.contains("Confirm the gear"))
+ capture(.settingsMissingParkedGear)
+ scrollToParkedGearRow()
+ let missingParkedGear = app.descendants(matching: .any)[
+ "row.parkedGear"
+ ].firstMatch
+ XCTAssertTrue(
+ missingParkedGear.label.contains("Needed")
+ )
+ launch("-shotSettingsUnsafeGears")
+ assertVisible("screen.settings")
+ let chooseSafeGears = app.descendants(matching: .any)["action.finishSetup"]
+ .firstMatch
+ assertVisibleElement(chooseSafeGears)
+ XCTAssertTrue(chooseSafeGears.label.contains("Choose gears that fit"))
+ capture(.settingsUnsafeGears)
+
+
+ launch("-shotSettings")
+ app.staticTexts["Wheel circumference"].firstMatch.tap()
+ XCTAssertTrue(
+ app.navigationBars["Wheel circumference"].waitForExistence(timeout: 2)
+ )
+ let useDefault = app.buttons["wheel.useDefault"]
+ XCTAssertFalse(useDefault.isEnabled)
+ capture(.wheelSizeDefault)
+
+ let road28 = app.buttons["Road, 700×28, 2136 millimetres"]
+ assertVisibleElement(road28)
+ road28.tap()
+ XCTAssertTrue(road28.isSelected)
+ capture(.wheelSizeShortcut)
+
+ let field = app.textFields["Millimetres"]
+ XCTAssertEqual(field.value as? String, "2136")
+ field.tap()
+ field.typeKey("a", modifierFlags: .command)
+ field.typeKey(.delete, modifierFlags: [])
+ field.typeText("2180")
+ XCTAssertFalse(road28.isSelected)
+ capture(.wheelSizeValid)
+
+ field.typeKey("a", modifierFlags: .command)
+ field.typeKey(.delete, modifierFlags: [])
+ field.typeText("999")
+ app.swipeUp()
+ assertVisibleElement(app.staticTexts.matching(
+ NSPredicate(format: "label BEGINSWITH %@", "Enter a value from")
+ ).firstMatch)
+ capture(.wheelSizeInvalid)
+
+ openSettingsDestination("Trainer", fixture: "-shotSettings")
+ capture(.trainerConnected)
+ openSettingsDestination("Trainer", fixture: "-shotSettingsSearching")
+ assertVisibleElement(app.staticTexts["Looking for trainers…"])
+ capture(.trainerSearching)
+ openSettingsDestination("Trainer", fixture: "-shotSettingsResults")
+ assertVisibleElement(app.buttons["Wahoo KICKR 7B20"])
+ capture(.trainerResults)
+ openSettingsDestination("Trainer", fixture: "-shotSettingsUnsupported")
+ assertVisibleElement(app.staticTexts.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Wheel-on trainers")
+ ).firstMatch)
+ capture(.trainerUnsupported)
+ openSettingsDestination("Trainer", fixture: "-shotSettingsTimedOut")
+ assertVisibleElement(app.staticTexts["No trainer found"])
+ capture(.trainerTimedOut)
+ openSettingsDestination("Trainer", fixture: "-shotSettingsBluetoothIssue")
+ assertVisibleElement(app.buttons["Open Bluetooth Settings"])
+ capture(.trainerBluetoothIssue)
+ openSettingsDestination("Trainer", fixture: "-shotSettingsStalled")
+ assertVisibleElement(app.staticTexts["Still trying to connect"])
+ capture(.trainerStalled)
+
+ openSettingsDestination("Zwift Click", fixture: "-shotSettings")
+ assertVisibleElement(app.staticTexts["Battery 82%"])
+ capture(.clickConnected)
+ openSettingsDestination("Zwift Click", fixture: "-shotSettingsClickLowBattery")
+ assertVisibleElement(app.staticTexts["Worth replacing the battery soon."])
+ capture(.clickLowBattery)
+ openSettingsDestination("Zwift Click", fixture: "-shotSettingsClickDuplicates")
+ assertVisibleElement(app.buttons["Identify by pressing a button"])
+ capture(.clickDuplicatePrompt)
+ openSettingsDestination("Zwift Click", fixture: "-shotSettingsClickIdentifying")
+ assertVisibleElement(
+ app.staticTexts["Keep pressing either button on the Click you want."]
+ )
+ capture(.clickIdentifying)
+
+ openSettingsDestination("Wahoo Headwind", fixture: "-shotSettings")
+ assertVisibleElement(app.staticTexts["KICKR HEADWIND 4D21"])
+ capture(.headwindSetup)
+ assertJourneyCoverage()
+ }
+
+ func testUXCoverageGearAndPhysicalBikeStates() {
+ launch("-shotGears")
+ assertVisible("screen.gears")
+ capture(.gearsVirtual)
+ app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Custom")
+ ).firstMatch.tap()
+ assertVisible("screen.customGearLadder")
+ capture(.gearsCustom)
+
+ launch("-shotRealGears")
+ assertVisible("screen.gears")
+ capture(.gearsRealBike)
+
+ app.staticTexts["Groupset"].tap()
+ assertVisible("screen.groupset")
+ capture(.groupsetPicker)
+ let dura = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Dura-Ace R9200")
+ ).firstMatch
+ assertVisibleElement(dura)
+ dura.tap()
+ for _ in 0..<4 { app.swipeUp() }
+ assertVisibleElement(app.buttons["Also set this as what's on the bike"])
+ capture(.groupsetMatchBikeOffer)
+
+ launch("-shotRealGears")
+ app.staticTexts["Chainrings"].tap()
+ XCTAssertTrue(app.navigationBars["Chainrings"].waitForExistence(timeout: 2))
+ capture(.chainringPicker)
+
+ launch("-shotRealGears")
+ app.staticTexts["Cassette"].tap()
+ XCTAssertTrue(app.navigationBars["Cassette"].waitForExistence(timeout: 2))
+ capture(.cassettePicker)
+
+ launch("-shotSettingsUnsafeGears")
+ app.staticTexts["Gears"].firstMatch.tap()
+ assertVisibleElement(app.staticTexts["Too wide for the trainer"])
+ capture(.gearPreviewTooWide)
+
+ launch("-shotSettings")
+ openParkedGear()
+ assertVisible("screen.parkedGear")
+ capture(.parkedGearCassette)
+ app.buttons["Single sprocket"].tap()
+ XCTAssertTrue(app.buttons["Single sprocket"].isSelected)
+ capture(.parkedGearSingleSprocket)
+
+ app.buttons["Cassette"].tap()
+ let outOfReach = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "Puts some gears out of reach")
+ ).firstMatch
+ assertVisibleElement(outOfReach)
+ outOfReach.tap()
+ assertVisibleElement(app.buttons.matching(
+ NSPredicate(format: "label BEGINSWITH %@", "Use ")
+ ).firstMatch)
+ capture(.parkedGearOutOfReach)
+ assertJourneyCoverage()
+ }
+
+ func testUXCoverageHeadwindStates() {
+ launch("-shotHeadwind")
+ assertVisible("screen.headwind")
+ assertVisibleElement(app.staticTexts["Fixed fan speed"])
+ capture(.headwindManual)
+
+ launch("-shotHeadwindAutomatic")
+ assertVisibleElement(app.staticTexts["Sensor control"])
+ capture(.headwindAutomatic)
+
+ launch("-shotHeadwindPending")
+ assertVisibleElement(app.staticTexts["Applying change…"])
+ capture(.headwindPending)
+
+ launch("-shotHeadwindError")
+ assertVisibleElement(
+ app.staticTexts["The Headwind did not confirm the change."]
+ )
+ capture(.headwindError)
+
+ launch("-shotHeadwind", orientation: .landscapeLeft)
+ waitForLandscapeLayout()
+ assertVisible("screen.headwind")
+ assertVisibleElement(app.buttons["50 percent"])
+ capture(.headwindLandscape)
+ assertJourneyCoverage()
+
+ }
+
+ func testUXCoverageAccessibilityAndAppearanceVariants() {
+ launch("-shotSetupWizardAccessibility")
+ XCTAssertTrue(
+ app.navigationBars["Set up Virtual Gears"].waitForExistence(timeout: 2)
+ )
+ let confirmBike = app.buttons["wizard.useBikeSetup"]
+ for _ in 0..<5 where !confirmBike.exists || !confirmBike.isHittable {
+ app.swipeUp()
+ }
+ assertVisibleElement(confirmBike)
+ capture(.wizardAccessibilityText)
+
+ launch("-shotSettingsAccessibility")
+ assertVisible("screen.settings")
+ XCTAssertFalse(app.staticTexts["Run setup guide again"].exists)
+ capture(.settingsAccessibilityText)
+
+ launch("-shotRide", extraArguments: ["-AppleInterfaceStyle", "Dark"])
+ assertVisible("screen.ride")
+ assertVisibleElement(app.buttons["Shift harder"])
+ capture(.rideDarkMode)
+
+ launch("-shotHeadwind", extraArguments: ["-AppleInterfaceStyle", "Dark"])
+ assertVisible("screen.headwind")
+ assertVisibleElement(app.buttons["50 percent"])
+ capture(.headwindDarkMode)
+ assertJourneyCoverage()
+ }
+
+ func testUXCoverageDemoStates() {
+ launch("-shotDemo")
+ assertVisible("screen.demo")
+ capture(.demoRide)
+
+ app.buttons["Settings"].tap()
+ assertVisible("screen.demo-settings")
+ capture(.demoSettings)
+ app.buttons["Done"].tap()
+
+ let gearsMenu = app.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] %@", "gears")
+ ).firstMatch
+ gearsMenu.tap()
+ let allGearSettings = app.buttons["All Gear Settings…"]
+ assertVisibleElement(allGearSettings)
+ allGearSettings.tap()
+ assertVisible("screen.gears")
+ capture(.demoGearSettings)
+ app.buttons["Done"].tap()
+
+ app.buttons["Fan"].tap()
+ assertVisible("screen.demo-headwind")
+ capture(.demoHeadwindAutomatic)
+ app.buttons["Manual"].tap()
+ assertVisibleElement(app.buttons["50 percent"])
+ capture(.demoHeadwindManual)
+ assertJourneyCoverage()
+ }
+
+ private func openSettingsDestination(_ title: String, fixture: String) {
+ launch(fixture)
+ assertVisible("screen.settings")
+ let row = app.staticTexts[title].firstMatch
+ for _ in 0..<3 where !row.exists || !row.isHittable {
+ app.swipeUp()
+ }
+ assertVisibleElement(row)
+ XCTAssertTrue(row.isHittable, "\(title) row is not tappable")
+ row.tap()
+ XCTAssertTrue(
+ app.navigationBars[title].waitForExistence(timeout: 2),
+ "\(title) screen did not open"
+ )
+ }
+
+ private func openParkedGear() {
+ scrollToParkedGearRow()
+ let row = app.descendants(matching: .any)["row.parkedGear"].firstMatch
+ assertVisibleElement(row)
+ row.tap()
+ }
+
+ private func scrollToParkedGearRow() {
+ let row = app.descendants(matching: .any)["row.parkedGear"].firstMatch
+ for _ in 0..<3 where !row.exists || !row.isHittable {
+ app.swipeUp()
+ }
+ assertVisibleElement(row)
+ }
+
+ private func waitForLandscapeLayout() {
+ let window = app.windows.firstMatch
+ let deadline = Date().addingTimeInterval(3)
+ while Date() < deadline, window.frame.width <= window.frame.height {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+ }
+ XCTAssertGreaterThan(window.frame.width, window.frame.height)
+ }
+
+ private func capture(_ state: DesignedUXState) {
+ XCTAssertTrue(
+ capturedUXStates.insert(state).inserted,
+ "\(state.rawValue) was captured more than once in one journey."
+ )
+ let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
+ attachment.name = "UX-\(state.rawValue.replacingOccurrences(of: "/", with: "-"))"
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+
+ private func assertJourneyCoverage() {
+ guard let journey = Set(uxCoverageManifest.values).first(
+ where: { name.contains($0) }
+ ) else {
+ return XCTFail("No UX coverage manifest entry matches \(name).")
+ }
+ let expected = Set(
+ uxCoverageManifest.compactMap { state, assignedJourney in
+ assignedJourney == journey ? state : nil
+ }
+ )
+ XCTAssertEqual(
+ capturedUXStates,
+ expected,
+ "\(journey) must execute every state assigned to it exactly once."
+ )
+ }
+
+ private func launch(
+ _ fixture: String,
+ orientation: UIDeviceOrientation = .portrait,
+ extraArguments: [String] = []
+ ) {
continueAfterFailure = false
app = XCUIApplication()
addTeardownBlock { @MainActor [weak self] in
@@ -405,30 +1399,25 @@ final class VirtualGearsUITests: XCTestCase {
app.terminate()
self.app = nil
}
- XCUIDevice.shared.orientation = .portrait
+ XCUIDevice.shared.orientation = orientation
app.launchArguments = [
fixture, "-AppleLanguages", "(en)", "-AppleLocale", "en_US",
- ]
+ ] + extraArguments
app.launch()
}
- func testTheChainReminderNeverAppearsOrDisappearsAcrossStartupStates() {
- // It used to live only inside the searching and chooser cards, so it
- // vanished the instant the trainer connected and the button above it
- // jumped up to fill the gap. It must now be part of the fixed layout,
- // present in every startup state.
- let reminderText = "Use the smaller front ring if your bike has one. "
- + "Pick a rear gear that keeps the chain straight, and leave it "
- + "there."
+ func testStartupDoesNotRepeatTheChainPositionAfterSetup() {
+ let reminderText = "Chain on 34 at the front and 15 at the back, and "
+ + "leave it there."
launch("-shotStarting")
- assertVisibleElement(app.staticTexts[reminderText])
+ XCTAssertFalse(app.staticTexts[reminderText].exists)
launch("-shotReady")
- assertVisibleElement(app.staticTexts[reminderText])
+ XCTAssertFalse(app.staticTexts[reminderText].exists)
launch("-shotFailed")
- assertVisibleElement(app.staticTexts[reminderText])
+ XCTAssertFalse(app.staticTexts[reminderText].exists)
}
/// Samples the average colour of an element as it is actually rendered.
@@ -540,4 +1529,15 @@ final class VirtualGearsUITests: XCTestCase {
line: line
)
}
+
+ private func isFullyVisible(_ element: XCUIElement) -> Bool {
+ guard element.exists else { return false }
+ let frame = element.frame
+ guard frame.width > 1, frame.height > 1 else { return false }
+ let visibleFrame = frame.intersection(app.windows.firstMatch.frame)
+ guard !visibleFrame.isNull else { return false }
+ return visibleFrame.width * visibleFrame.height
+ / (frame.width * frame.height) >= 0.95
+ }
+
}
diff --git a/docs/APP_STORE.md b/docs/APP_STORE.md
index 1bc461d..c1953eb 100644
--- a/docs/APP_STORE.md
+++ b/docs/APP_STORE.md
@@ -53,7 +53,7 @@ the iPhone and when advertising itself as a trainer.
### Promotional text (170 characters max, editable any time without review)
- Give your Wahoo KICKR virtual gears in compatible FTMS riding apps. Shift on your iPhone with 24 virtual gears or your real-bike gearing.
+ Give your Wahoo KICKR virtual gears in compatible FTMS riding apps. Shift on your iPhone with 24 virtual gears or real groupset gearing.
### Description
@@ -77,8 +77,23 @@ the iPhone and when advertising itself as a trainer.
FTMS trainer connection; it does not support Zwift's native gear system.
You get a full set of gears you can shift through mid-ride — either 24 evenly
- spaced gears with an extra-low climbing range or an exact copy of the gears on
- your real bike.
+ spaced gears with an extra-low climbing range or the gearing of a real
+ groupset from Shimano, SRAM or Campagnolo.
+
+ START WITH THE BIKE
+ Required first-run setup asks only what is physically on the bike: the
+ chainrings and either its cassette or a Zwift Cog/other single sprocket. Virtual
+ Gears then recommends where to leave the chain and starts with the ready-made
+ Standard 24 virtual gears. Named groupsets and custom gears remain available
+ later in Settings.
+
+ ONE PHYSICAL FACT CANNOT BE GUESSED
+ Your bike never shifts. It stays in one gear and Virtual Gears changes gear by
+ changing the wheel size the trainer works from, so what your legs feel is that
+ parked gear multiplied by the wheel size the app sets. Virtual Gears therefore
+ needs to know which gear your bike is parked in. It works out the quietest gear
+ that still keeps every gear reachable and recommends it. If setup is unfinished,
+ Settings gives one next action: fix gearing first, then confirm the parked gear.
A TRAINER PROXY, WITH SHIFTING WHEN YOU WANT IT
Open the app and it finds your trainer, connects to it and appears to your riding
@@ -91,8 +106,15 @@ the iPhone and when advertising itself as a trainer.
GEARS YOU CAN SEE
Your gears are drawn, not listed as numbers — one bar per gear, short bars for
small steps and tall bars for the ones your legs will notice. Choose the 24
- virtual gears, or pick your real chainrings and cassette and get exactly the gears
- you would actually ride. Cross-chained and duplicate combinations are left out.
+ virtual gears, or pick the groupset your bike has — Shimano, SRAM or Campagnolo,
+ with your own chainrings and cassette available if it is not listed — and get the
+ gears you would actually ride. The ladder is walked the way an electronic
+ groupset shifts: one cog at a time, changing chainring at the right moment, so
+ cross-chained and repeated combinations never appear and no shift is too small
+ to feel.
+
+ Virtual Gears is not affiliated with or endorsed by Zwift, Wahoo, Shimano, SRAM
+ or Campagnolo. Those names describe only the gearing being simulated.
BUILT FOR RIDING, NOT FOR READING
Two large shift buttons stay easy to hit without looking down or sitting up,
@@ -117,7 +139,8 @@ the iPhone and when advertising itself as a trainer.
to compatible apps that have none of their own, without requiring a plugin or
account. Apps that set their own wheel size are honoured — the gears are rebuilt
around whatever size the app asks for. If an app sends no size, Virtual Gears uses
- the Normal wheel circumference saved in Settings, 2070 mm by default.
+ the optional Wheel circumference saved in Settings, or the 2105 mm (700×25 road)
+ default.
OPTIONAL SHIFT BUTTONS
Wake an original Zwift Click before opening Virtual Gears and it connects
@@ -139,9 +162,9 @@ the iPhone and when advertising itself as a trainer.
NOTE ON WHEEL CIRCUMFERENCE
Virtual Gears cannot read a wheel circumference previously set in the Wahoo app.
- If you use a custom value, enter the same value as Normal wheel circumference in
- Virtual Gears Settings before shifting. Values from 1800 to 2400 mm are supported.
- A value sent by the riding app takes precedence.
+ If you use a custom value, enter the same value as Wheel circumference in
+ Virtual Gears Settings before shifting. Common-size shortcuts and direct entry
+ support values from 1800 to 2400 mm. A value sent by the riding app takes precedence.
NO ACCOUNTS, NO INTERNET, NO TRACKING
The app has no networking code in it at all. Nothing about your ride leaves your
@@ -177,8 +200,8 @@ Issues and Discussions must stay enabled on the repository.
### Screenshots
-Required: **6.9-inch iPhone**. The six portrait images in `docs/screenshots/`
-are 1320 × 2868, captured on an iPhone 17 Pro Max simulator, so they can be
+Required: **6.9-inch iPhone**. The six portrait images listed below are
+1320 × 2868, captured on an iPhone 17 Pro Max simulator, so they can be
uploaded as they are. Apple scales them down for smaller phones; one set is
enough. The two landscape images are 2868 × 1320 documentation views; they are
not part of the portrait upload set below.
@@ -186,12 +209,15 @@ not part of the portrait upload set below.
Upload these six, in this order:
1. `riding.png` — the ride screen, which is what the app is for.
-2. `gears.png` — the 24 virtual gears drawn as bars.
-3. `gears-real-bike.png` — a real 50/34 with 11-34 turned into sixteen gears.
-4. `headwind-control.png` — optional Automatic/Manual Headwind control with
+2. `setup.png` — required first-run setup for the physical chainrings and
+ cassette.
+3. `bike-setup.png` — the directly visible Zwift Cog/other single-sprocket
+ choice and tooth count.
+4. `parked-gear.png` — the exact quiet, reachable chain position the app
+ recommends.
+5. `gears.png` — the 24 virtual gears drawn as bars.
+6. `headwind-control.png` — optional Automatic/Manual Headwind control with
one-tap speeds.
-5. `starting.png` — automatic reconnect plus the Bluetooth-free Try Demo entry.
-6. `settings.png` — the three remembered equipment rows and gear choice.
### Privacy answers ("App Privacy" section)
@@ -272,7 +298,7 @@ For each update, raise `MARKETING_VERSION` (1.0 → 1.1) and
upload again. `CURRENT_PROJECT_VERSION` must increase on every single upload, even
a re-upload of the same version.
-The current TestFlight build is 1.0 (11). Build 5 added the Demo Mode that shows
+The current TestFlight build is 1.0 (15). Build 5 added the Demo Mode that shows
the wheel size and command bytes changing. Build 6 removed a wheel-size limit
that was never real: a physical KICKR V5 accepts every value the command can
express, so the app now states the range of riding-app wheel sizes it supports
@@ -324,6 +350,31 @@ making both live measurements readable and notifiable. The iPhone stays awake
while the trainer proxy is available so computer riding apps can discover it
before connecting.
+Build 1.0 (14) was uploaded to TestFlight on 18 August 2026. It adds the setup
+guide, custom virtual-gear ladders, exact normal wheel-circumference entry, and
+separate physical cassette or single-sprocket setup. It also includes defensive
+Bluetooth recovery and the first complete designed-state UX matrix.
+
+Build 1.0 (15) was uploaded to TestFlight on 20 August 2026. It makes first-time
+setup beginner-friendly with three paths: choose a groupset, enter the bike's
+parts, or use standard virtual gears. Every path records whether the bike uses a
+cassette, Zwift Cog, or another 9–30T single sprocket. Settings now presents one
+ordered **Finish setup** action, and changing simulated gears refreshes the
+parked-gear recommendation instead of preserving an unsafe chain position. This
+build includes refreshed public screenshots and 67 maintained app-owned UX
+states.
+
+Build 1.0 (16) was uploaded to TestFlight on 21 August 2026. It replaces that
+three-path guide with a required physical-bike-first flow: bike parts, then
+parked chain position. Standard 24 is selected automatically. Wheel
+circumference moves to optional Settings shortcuts with a 2105 mm default, and
+the maintained UX contract contains 65 app-owned states.
+
+Build 1.0 (17) was uploaded to TestFlight on 21 August 2026. It removes the
+white panels behind the setup wizard's main actions, groups physical chainring
+choices into numerically ordered one-ring and two-ring lists, and removes the
+redundant parked-chain reminder from the startup screen.
+
The live App Store description still carries the old "starts the session"
sentence. It is corrected in this file and needs the same edit in App Store
Connect on the next metadata change.
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
index 34ca71a..fd3e79d 100644
--- a/docs/how-it-works.md
+++ b/docs/how-it-works.md
@@ -21,12 +21,51 @@ The iPhone screen stays awake for as long as this trainer proxy is available.
iOS changes Bluetooth advertising after the phone locks, which can make a
waiting trainer disappear from riding apps on Windows and other computers.
+## The gear your bike is parked in
+
+Your bike does not shift at all. It sits in one gear for the whole ride, and that
+gear is half of what your legs feel:
+
+ feel = parked gear ratio x the wheel size we set
+
+Virtual Gears only controls the second half, so it has to be told the first.
+Skipping the question does not make it go away — it just means assuming an
+answer. And "a quiet, straight chain line" is true of a lot of very different
+gears:
+
+| Parked in | Ratio | If the app had assumed 2.4 |
+|---|---|---|
+| 31-tooth ring, Zwift Cog | 2.21 | about 8% easier than shown |
+| Small ring, middle cog | 2.43 | almost exactly right |
+| Big ring, middle cog | 3.33 | about 39% harder than shown |
+| Big ring, smallest cog | 4.55 | 90% harder — the easy half of the ladder would not exist |
+
+The step *sizes* stay right either way, because the scaling is relative. What
+moves is the whole ladder, which is why this would never look like a bug: the
+shifting feels fine, the gears are simply not the ones on screen.
+
+So Virtual Gears asks once, and makes the good answer the default. It works out
+the quietest gear that still keeps every gear reachable — the trainer's wheel-size
+command tops out at 6553.5 mm, which puts a hard floor under how easy a parked
+gear can be — and recommends that. Confirm it, or say what you actually used.
+
+Indoors the trainer is the loudest thing in the room and its flywheel speed comes
+from the parked ratio, so a middle cog on the small ring runs around half the
+flywheel speed of the big ring on the smallest cog. Because the app compensates
+for whatever you confirm, the parked gear can be chosen purely for quiet.
+
## Where the gears sit
-Every gear is scaled away from the **Normal wheel circumference** in Settings,
-2070 mm by default. The default ladder reaches about four times easier and 2.3
-times harder while keeping gear 12 as the starting point. A drivetrain too wide
-to fit is refused at setup rather than mid-ride.
+Every gear is scaled away from the optional **Wheel circumference** in Settings,
+2105 mm (700×25 road) by default, and from the gear the bike is parked in. The
+default ladder reaches about four times easier and 2.3 times harder while
+keeping gear 12 as the starting point. A drivetrain too wide to fit, or a parked
+gear that would put part of the ladder out of the trainer's reach, is refused at
+setup rather than mid-ride.
+
+The starting gear is a declared number rather than one derived from the safety
+limits, so editing an unrelated limit cannot quietly move which gear you begin
+in.
## When the riding app has its own idea
diff --git a/docs/how-it-works.svg b/docs/how-it-works.svg
index 00720c5..5624ebf 100644
--- a/docs/how-it-works.svg
+++ b/docs/how-it-works.svg
@@ -122,7 +122,7 @@
Your trainer
- wheel size 2070 mm
+ wheel size 2105 mm
wheel size 2613 mm
diff --git a/docs/index.md b/docs/index.md
index 3d78c8d..a91ed40 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -53,18 +53,78 @@ speed. Both remain optional and never hold up a ride.
+
+
+
+
+## What you get
+
+### Start with the bike
+
+Required first-run setup asks only what is physically on the bike: its
+chainrings and either its cassette or its Zwift Cog/other single sprocket. A
+Zwift Cog defaults to its usual 14 teeth. Virtual Gears then recommends where to
+leave the chain and starts with Standard 24 virtual gears. Named groupsets and
+custom virtual gears remain available later in Settings.
+
+Settings keeps unfinished setup in one ordered card. Gearing is fixed first,
+because that determines which physical parked gear is safe; the chain position
+comes next.
+
+
+
-## What you get
+### The physical fact it cannot guess
+
+Your bike never shifts. It sits on the trainer in a single gear for the whole
+ride, and Virtual Gears changes gear by changing the wheel size the trainer
+works from. What your legs feel is the parked gear multiplied by the wheel size
+we set — so the app has to know the parked gear, or every gear is scaled from a
+guess.
+
+"A quiet, straight chain line" is satisfied by gears more than twice as hard as
+each other, so this cannot be assumed. Instead the app names the gear to park in
+— the quietest one that still works with the gearing you chose — and you confirm
+or correct it in a tap. It is asked once and kept.
+
+Confirm something far from the recommendation and the app says plainly what it
+costs, and offers a one-tap return to the gear it suggested.
### Gears you can see
Either a 24-step virtual ladder, tuned with extra room for easy climbing, or a
-copy of a real bike described by its chainrings and cassette. A real 50/34 with
-an 11-34 cassette gives sixteen gears, running 34x34 up to 50x11 — the gears you
-would really ride, not every possible pairing of a ring with a cog.
+real groupset from Shimano, SRAM or Campagnolo — with your own chainrings and
+cassette still available if your bike is not listed. A real 50/34 with an 11-34
+cassette gives sixteen gears, running 34x34 up to 50x11 — the gears you would
+really ride, not every possible pairing of a ring with a cog.
+
+### How the ladder is built
+
+A Zwift Click has two buttons, so one sequence has to cover a whole two-ring
+drivetrain. That is the same problem Shimano solved with Synchronized Shift and
+SRAM with AXS Sequential, so Virtual Gears builds its ladder the same way rather
+than inventing a method: start on the small ring at the easiest cog, move one cog
+per press, and at the shift point change ring while jumping the cassette by a
+compensating amount, so the step still feels like an ordinary cassette step.
+Big-big and small-small are never used.
+
+Two things fall out of that, and both are tested against every groupset the app
+ships: no shift is too small to feel, and the app never invents a gap the parts
+did not already have. Large jumps that come from your real cassette — an 11-34's
+30 to 34 step — are kept, because they are real.
+
+Campagnolo has no synchronised mode, so Virtual Gears models Campagnolo *gearing*
+and its shift points rather than claiming a Campagnolo algorithm that does not
+exist.
+
+Virtual Gears is not affiliated with or endorsed by Zwift, Wahoo, Shimano, SRAM
+or Campagnolo. Those names are used only to describe the gearing being
+simulated.
+
+### Gears drawn, not listed
Whichever you choose is drawn rather than listed: one bar per gear, easiest to
hardest, on a scale where a tall step is a jump the legs will notice. How far
@@ -100,18 +160,20 @@ switching to another fan.
### A trainer proxy, with shifting when you want it
Open the app. It looks for your trainer, connects to it and appears to your
-riding app on its own. There is no setup ritual. **Start Shifting** engages the
-gears; **Stop Shifting** removes them without stopping or disconnecting the ride
-in your riding app.
+riding app on its own. On first run, finish the two required setup steps:
+describe the physical bike, then confirm the recommended chain position.
+**Start Shifting** engages the gears once that is done; **Stop Shifting** removes
+them without stopping or disconnecting the ride in your riding app.
The only question it asks is which device, and only when it finds more than one
trainer, Click or Headwind. A single device is simply used. Bluetooth signal
strength does not measure distance reliably, so multiple devices are listed by
name rather than ranked or guessed.
-The normal wheel circumference is 2070 mm by default and can be changed in
-Settings from 1800 to 2400 mm. Virtual Gears uses it when the riding app sends no
-wheel size; a size from the riding app takes precedence.
+Wheel circumference is optional in Settings. With no saved value, Virtual Gears
+uses 2105 mm (700×25 road). Common road, gravel and MTB shortcuts or direct
+entry can set any supported value from 1800 to 2400 mm. A size from the riding
+app takes precedence.
If no trainer is available, **Try Demo** opens a clearly marked simulated ride.
It includes the ride screen, shifting, gear choices, Settings and example Click,
diff --git a/docs/requirements.md b/docs/requirements.md
index c3c2940..225960b 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -29,9 +29,10 @@ cannot work with, rather than leaving you to wonder why nothing happens.
### If you set a custom wheel circumference
Virtual Gears cannot read the wheel circumference previously set through the
-Wahoo app. Set **Normal wheel circumference** in Virtual Gears Settings to the
-same value before shifting. The default is 2070 mm and the available range is
-1800–2400 mm. A wheel size supplied by the riding app takes precedence.
+Wahoo app. Set **Wheel circumference** in Virtual Gears Settings to the same
+value before shifting. With no saved value, the default is 2105 mm (700×25
+road). Common-size shortcuts and direct entry cover the supported 1800–2400 mm
+range. A wheel size supplied by the riding app takes precedence.
Stopping shifting restores that normal value, or the latest value from the
riding app, without disconnecting the riding app from Virtual Gears.
@@ -115,9 +116,12 @@ and the complete iPhone-to-Windows path has been ridden end to end.
4. Tap **Start Shifting** when you want gears. Shift with the two large buttons
on the phone.
-There is no setup screen to complete first. Start and Stop control only virtual
-shifting. Virtual Gears remains connected as a transparent trainer proxy, so
-stopping shifting does not pause, stop or disconnect the ride in your riding app.
+The two-step first-run setup is required. It asks what is physically on the bike
+and then confirms the recommended parked chain position. Standard 24 virtual
+gears are selected automatically; other simulated gears can be chosen later in
+Settings. Start and Stop control only virtual shifting. Virtual Gears remains
+connected as a transparent trainer proxy, so stopping shifting does not pause,
+stop or disconnect the ride in your riding app.
You can explore the app without any equipment by tapping **Try Demo** while it
looks for a trainer. The simulated ride does not use Bluetooth or control
@@ -171,8 +175,21 @@ confirm it before switching to another Headwind.
## Choosing your gears
The starting choice is a 24-step virtual ladder with extra room for easy
-climbing. If you would rather ride the gears of a real bike, describe it by its
-chainrings and cassette and the app builds that instead.
+climbing. If you would rather ride the gears of a real bike, pick a groupset
+from Shimano, SRAM or Campagnolo — or set the chainrings and cassette yourself
+if your bike is not listed — and the app builds that instead.
+
+The ladder is built the way Shimano Synchronized Shift and SRAM AXS Sequential
+build theirs: one cog per press, with the front change folded in and paired with
+a compensating rear jump. Campagnolo has no synchronised mode, so its entries
+model Campagnolo gearing and shift points rather than a Campagnolo algorithm.
+
+Virtual Gears also asks which gear your bike is parked in, because the bike never
+shifts and that ratio is what every virtual gear is scaled from. It recommends
+the quietest gear that works, so this is normally one tap.
+
+Virtual Gears is not affiliated with or endorsed by Zwift, Wahoo, Shimano, SRAM
+or Campagnolo.
You can change this mid-ride without interrupting the riding app. The trainer
must confirm the newly selected gear before the app shows it.
diff --git a/docs/safety.md b/docs/safety.md
index d5a6cad..de8ae02 100644
--- a/docs/safety.md
+++ b/docs/safety.md
@@ -85,17 +85,37 @@ happens; it appears only in the app's own log.
Wheel size is sent in tenths of a millimetre, so what the trainer receives is
exactly what the safety check judged.
+## The gear the bike is parked in has a floor
+
+The same 6553.5 mm command ceiling puts a limit on the other end of the
+calculation. Your bike is parked in one gear all ride and every virtual gear is
+scaled from it, so a parked gear that is too easy leaves the hardest gears
+unable to encode:
+
+ 2400 mm x 5.49 (hardest gear) / 6553.5 mm = 2.011
+
+Park below that and the top of the ladder stops working the moment a riding app
+sets a big wheel. This is why the app computes the gear it recommends instead of
+printing a fixed sentence: literal "small ring, middle cog" advice lands a 105 on
+34/17 = 2.00 and a GRX on 31/17 = 1.82, both under the floor.
+
+There is a limit at the other end too. Parked in the big ring on the smallest cog
+every gear still fits inside the command, but the easiest one would ask the
+trainer for a 238 mm wheel — a rider a riding app would draw as having stopped.
+Setup refuses a parked gear outside the workable window rather than letting the
+ride discover it.
+
## If you set a custom wheel circumference
The KICKR does not expose its current wheel circumference through FTMS, so
-Virtual Gears cannot read that setting when it connects. It uses the **Normal
-wheel circumference** saved in Settings, 2070 mm by default, unless the riding
-app supplies a different wheel size.
+Virtual Gears cannot read that setting when it connects. It uses the optional
+**Wheel circumference** saved in Settings, or 2105 mm (700×25 road) when none is
+saved, unless the riding app supplies a different wheel size.
If you use a custom value in the Wahoo app, enter the same value in Virtual
-Gears before shifting. Values from 1800 to 2400 mm are supported. Stopping
-shifting restores that saved normal value, or the latest value supplied by the
-riding app, while keeping the riding app connected.
+Gears before shifting. Common-size shortcuts and direct entry cover values from
+1800 to 2400 mm. Stopping shifting restores that saved value, or the latest
+value supplied by the riding app, while keeping the riding app connected.
## What it is not
diff --git a/docs/screenshots/bike-setup.png b/docs/screenshots/bike-setup.png
new file mode 100644
index 0000000..9dd63d6
Binary files /dev/null and b/docs/screenshots/bike-setup.png differ
diff --git a/docs/screenshots/gears-real-bike.png b/docs/screenshots/gears-real-bike.png
index 250ed75..09f7601 100644
Binary files a/docs/screenshots/gears-real-bike.png and b/docs/screenshots/gears-real-bike.png differ
diff --git a/docs/screenshots/gears.png b/docs/screenshots/gears.png
index e5fc458..222ed99 100644
Binary files a/docs/screenshots/gears.png and b/docs/screenshots/gears.png differ
diff --git a/docs/screenshots/headwind-control.png b/docs/screenshots/headwind-control.png
index 4b6fc39..4d99b6c 100644
Binary files a/docs/screenshots/headwind-control.png and b/docs/screenshots/headwind-control.png differ
diff --git a/docs/screenshots/parked-gear.png b/docs/screenshots/parked-gear.png
new file mode 100644
index 0000000..4a6c96d
Binary files /dev/null and b/docs/screenshots/parked-gear.png differ
diff --git a/docs/screenshots/riding.png b/docs/screenshots/riding.png
index f8258d4..44c4c1d 100644
Binary files a/docs/screenshots/riding.png and b/docs/screenshots/riding.png differ
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
index 05f2beb..518fd97 100644
Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ
diff --git a/docs/screenshots/setup.png b/docs/screenshots/setup.png
new file mode 100644
index 0000000..882c8f3
Binary files /dev/null and b/docs/screenshots/setup.png differ
diff --git a/docs/screenshots/starting.png b/docs/screenshots/starting.png
index 6044856..270c8dc 100644
Binary files a/docs/screenshots/starting.png and b/docs/screenshots/starting.png differ
diff --git a/docs/support.md b/docs/support.md
index e6a585a..954198b 100644
--- a/docs/support.md
+++ b/docs/support.md
@@ -26,10 +26,24 @@ Most problems have a known answer already:
- **Your trainer is not a KICKR V5.** Check
[which trainers work](requirements.md#which-trainers-work) first. Several
models cannot work at all, and the page says which.
-- **Gears feel wrong after using the app.** Set **Normal wheel circumference**
+- **Every gear feels too hard, or too easy, by about the same amount.** Check
+ the **Gear the bike is in** setting matches the gear the chain is actually
+ parked in. Every virtual gear is scaled from that ratio, so if it is wrong the
+ steps still feel right but the whole ladder is shifted. Tapping the
+ recommended gear and parking the chain there fixes it.
+- **The Start button says "Set the gear you are in".** Virtual Gears needs to
+ know which gear the bike is parked in before it can build the gears. Open
+ Settings, park the chain in the gear it recommends and confirm.
+- **Gears feel wrong after using the app.** Set **Wheel circumference**
in Virtual Gears Settings to the value you use in the Wahoo app. Virtual
- Gears uses 2070 mm by default. See
+ Gears uses 2105 mm (700×25 road) by default. See
[If you set a custom wheel circumference](requirements.md#if-you-set-a-custom-wheel-circumference).
+- **A Windows riding app disconnected mid-ride and won't come back.** A weak
+ Bluetooth link can time out, and some riding apps on Windows do not scan for
+ your phone again on their own afterwards. Restart the riding app to make it
+ look again. This has been confirmed to be the riding app's own reconnect
+ behaviour, not a Virtual Gears fault — the trainer stayed connected and
+ working the whole time.
## What to include in a bug report