Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,27 @@ jobs:
- uses: actions/checkout@v7

- name: Run the tests
run: swift test
run: swift test --enable-code-coverage

- name: Require 90% core line coverage
run: |
binary="$(find .build -type f -name VirtualGearsPackageTests -print -quit)"
report="$(
xcrun llvm-cov report "$binary" \
-instr-profile=.build/debug/codecov/default.profdata \
-ignore-filename-regex='Tests|Tools'
)"
printf '%s\n' "$report"
coverage="$(
printf '%s\n' "$report" |
awk '/^TOTAL/ { gsub("%", "", $10); print $10 }'
)"
awk -v coverage="$coverage" 'BEGIN {
if (coverage + 0 < 90) {
printf "Core line coverage %.2f%% is below 90%%\n", coverage
exit 1
}
}'

build:
name: App build
Expand All @@ -44,3 +64,17 @@ jobs:
-scheme "${{ matrix.scheme }}" \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO

ui-tests:
name: UI tests
runs-on: macos-26
steps:
- uses: actions/checkout@v7

- name: Run UI regression tests
run: |
xcodebuild test \
-project VirtualGears.xcodeproj \
-scheme VirtualGears \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-parallel-testing-enabled NO
36 changes: 35 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,42 @@ does not add any physical-hardware evidence.
Run the hardware-independent test suite:

```bash
swift test
swift test --enable-code-coverage
```

CI requires at least 90% line coverage for `Sources/VirtualGearsCore`, excluding
tests and hardware investigation tools.

Run the simulator UI regression suite:

```bash
xcodebuild test \
-project VirtualGears.xcodeproj \
-scheme VirtualGears \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-parallel-testing-enabled NO
```

`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
physical-hardware evidence.

Open the iPhone project:

```bash
Expand All @@ -54,6 +87,7 @@ without code signing.
| `Sources/VirtualGearsCore` | Gear calculations, trainer commands, ride coordination and other hardware-independent logic |
| `VirtualGearsProduct` | SwiftUI screens and the real Bluetooth services |
| `Tests/VirtualGearsCoreTests` | Hardware-independent unit and ride-lifecycle tests |
| `VirtualGearsUITests` | Simulator UI, navigation, accessibility and layout regression tests |
| `Tools` | macOS tools for inspecting the KICKR, Zwift Click and advertised trainer name |
| `docs` | MkDocs website, screenshots and hardware findings |
| `VirtualGears.xcodeproj` | iPhone app project |
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ supported.
may show the iPhone's name instead.
4. Ride and shift with the large **Easier** and **Harder** buttons.

There is no start button and no setup wizard. If it finds more than one trainer,
it asks you to choose yours by name.
There is no setup wizard. Once the trainer is connected, tap **Start Shifting**
when you want Virtual Gears to apply its gears. If it finds more than one
trainer, it asks you to choose yours by name.

The same rule applies to optional equipment: one Click or Headwind is used
automatically; if several are found, Virtual Gears asks instead of guessing.
Expand All @@ -75,6 +76,8 @@ with physical hardware.
- **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.
- **Persistent equipment statuses** that keep the trainer, Click, fan and
riding app visible independently, including while the riding app is waiting.
- **Automatic optional-equipment discovery** for an original Zwift Click and
Wahoo KICKR HEADWIND.
- **Optional Zwift Click shifting** from the handlebar.
Expand Down
3 changes: 3 additions & 0 deletions Sources/VirtualGearsCore/AppConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ public extension AppConfiguration {
guard drivetrain != nil else {
return "Too wide a range for the trainer"
}
if usesVirtualGears {
return "\(gearCount) gears · extra-low climbing range"
}
return "\(gearCount) gears · \(rangeDescription)"
}

Expand Down
8 changes: 8 additions & 0 deletions Sources/VirtualGearsCore/DrivetrainCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ public struct CassetteOption: Identifiable, Equatable, Sendable {
guard let smallest = cogs.first, let largest = cogs.last else { return "" }
return "\(smallest)-\(largest)"
}

/// The name plus what tells it apart from the others that share it. Several
/// cassettes are called "11-28"; on a screen that lists them under their own
/// cog-count headings that is clear, but anywhere else — the selected value,
/// VoiceOver — it names three different parts at once.
public var qualifiedName: String {
"\(name) · \(speeds) cogs"
}
}

/// Real parts a rider can buy, so a saved setup shifts like the bike it names.
Expand Down
72 changes: 72 additions & 0 deletions Sources/VirtualGearsCore/GearRailPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation

/// How one gear is drawn on the position rail under the gear number.
///
/// The rail used to be a row of identical dots with one of them coloured. That
/// can only be read by counting, which is not something a rider does at 90rpm,
/// so the rail said little the number above it had not already said. Filling in
/// the gears behind the rider turns it into a picture of where they are in the
/// range, which is the one thing the number cannot show.
public enum GearRailMarker: String, CaseIterable, Equatable, Sendable {
/// The gear the trainer has confirmed.
case selected
/// An easier gear the rider has already come up through.
case behind
/// A harder gear still available.
case ahead
/// The gear the rider has asked for and the trainer has not confirmed yet.
case requested

/// How tall this marker is drawn, as a fraction of the tallest one.
///
/// Every kind has its own height so the rail still reads when colour does
/// not: in greyscale, to a rider who cannot tell the colours apart, or
/// through a phone screen with sweat on it.
public var relativeHeight: Double {
switch self {
case .selected: 1
case .requested: 0.75
case .behind: 0.5
case .ahead: 0.3
}
}
}

public enum GearRail {
/// Describes each position on the rail for a gear range of `count` gears.
///
/// A requested gear is only ever drawn while it differs from the confirmed
/// one, so the rail never shows a gear the trainer has not agreed to as if
/// it were the gear being ridden.
public static func markers(
count: Int,
selected: Int?,
requested: Int?
) -> [GearRailMarker] {
guard count > 0 else { return [] }
let range = 0..<count
let confirmed = selected.flatMap { range.contains($0) ? $0 : nil }
let pending = requested.flatMap { range.contains($0) ? $0 : nil }
return range.map { index in
if let pending, pending != confirmed, index == pending { return .requested }
guard let confirmed else { return .ahead }
if index == confirmed { return .selected }
return index < confirmed ? .behind : .ahead
}
}
}

/// Sizes for the gear read-out.
public enum GearReadoutMetrics {
/// The size of the line under the gear number.
///
/// It carries "of 24" and the chainring and cog, which are worth having but
/// are not what the rider is looking for. It used to be drawn at a fixed
/// large title, which made it the second loudest thing on the screen and
/// took room from the gear itself. It is now a caption to the number: tied
/// to it, but never competing with it, and never smaller than a size that
/// can be read from a bike.
public static func secondaryPointSize(forPrimary primary: Double) -> Double {
min(24, max(15, primary * 0.09))
}
}
25 changes: 24 additions & 1 deletion Sources/VirtualGearsCore/ProxyCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ public final class ProxyCoordinator {
&& (confirmedGearIndex ?? Int.max) < gearSequence.count - 1
}

/// Makes the transparent trainer proxy available without enabling gears.
///
/// The riding app owns its ride. Virtual Gears can therefore pass trainer
/// data and commands through as soon as the KICKR is usable, while the rider
/// separately decides when to apply virtual shifting.
public func makeTrainerProxyAvailable() {
guard kickr.isReady else { return }
peripheral.startAdvertising()
}

public init(
kickr: any TrainerLink,
click: any ShifterLink,
Expand Down Expand Up @@ -340,7 +350,7 @@ public final class ProxyCoordinator {
hasAppliedVirtualGear = true
defaults.set(wheelSize, forKey: interruptedShiftingKey)
guard startMayProceed(id) else { throw CancellationError() }
peripheral.startAdvertising()
makeTrainerProxyAvailable()
try await waitUntilPeripheralReady(shiftingID: id)
lifecycle.markActive()
log("Ride session started")
Expand Down Expand Up @@ -1166,5 +1176,18 @@ extension ProxyCoordinator {
TrainerSafety.referenceCircumferenceMillimeters
updateDisplayedGear()
}

public func stageScreenshotFailure(_ message: String) {
lifecycle.refuseStart(message)
}

public func stageScreenshotPendingShift() {
_ = gearEngine?.requestShift(by: 1)
requestedGearIndex = gearEngine?.requestedIndex
}

public func stageScreenshotReconnecting() {
lifecycle.markReconnecting()
}
}
#endif
48 changes: 48 additions & 0 deletions Tests/VirtualGearsCoreTests/AppConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ final class AppConfigurationTests: XCTestCase {
XCTAssertTrue(configuration.canFinishSetup)
}

func testForgettingAHeadwindClearsBothStoredFields() {
var configuration = trainerReady()
configuration.rememberHeadwind(named: "HEADWIND 9267", id: UUID())
XCTAssertTrue(configuration.usesHeadwind)

configuration.forgetHeadwind()

XCTAssertNil(configuration.headwindName)
XCTAssertNil(configuration.headwindUUID)
XCTAssertFalse(configuration.usesHeadwind)
XCTAssertTrue(configuration.canFinishSetup)
}

// MARK: - Gears the trainer can actually copy

func testTheStartingChoiceIsSafeWithoutTheRiderTouchingAnything() {
Expand All @@ -83,6 +96,10 @@ final class AppConfigurationTests: XCTestCase {
XCTAssertNotNil(configuration.drivetrain)
XCTAssertTrue(configuration.hasSafeCircumference)
XCTAssertEqual(configuration.drivetrainName, "Virtual gears")
XCTAssertEqual(
configuration.gearSummary,
"24 gears · extra-low climbing range"
)
}

/// Gears wider than the trainer can copy must block a ride rather than be
Expand Down Expand Up @@ -145,6 +162,37 @@ final class AppConfigurationTests: XCTestCase {
XCTAssertEqual(configuration.gearCount, drivetrain.gears.count)
}

func testRealDrivetrainsUsePartNamesAndDescribeTheirRange() {
let expectedDescriptions = [
"close together, for flat roads",
"a normal road spread",
"wide, with easy climbing gears",
"very wide, for steep climbs",
]
var observedDescriptions = Set<String>()

for chainring in DrivetrainCatalog.chainrings {
for cassette in DrivetrainCatalog.cassettes {
var configuration = trainerReady()
configuration.usesVirtualGears = false
configuration.chainringID = chainring.id
configuration.cassetteID = cassette.id
guard configuration.drivetrain != nil else { continue }

XCTAssertEqual(
configuration.drivetrainName,
"\(chainring.name) · \(cassette.name)"
)
for description in expectedDescriptions
where configuration.gearSummary.contains(description) {
observedDescriptions.insert(description)
}
}
}

XCTAssertEqual(observedDescriptions, Set(expectedDescriptions))
}

// MARK: - Surviving an update

/// The configuration is stored on the phone between launches, so a rider
Expand Down
25 changes: 25 additions & 0 deletions Tests/VirtualGearsCoreTests/HeadwindProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import XCTest
@testable import VirtualGearsCore

final class HeadwindProtocolTests: XCTestCase {
func testEveryModeDescribesItsControlBehavior() {
let expectations: [(HeadwindMode, String, Bool)] = [
(.off, "Off", false),
(.heartRate, "Heart-rate sensor", true),
(.speed, "Speed sensor", true),
(.manual, "Manual", false),
(.sleep, "Sleeping", false),
]

for (mode, label, isSensorControlled) in expectations {
XCTAssertEqual(mode.label, label)
XCTAssertEqual(mode.isSensorControlled, isSensorControlled)
}
}

func testEncodesEveryModeCommand() throws {
XCTAssertEqual(
Array(try HeadwindCommand.setMode(.heartRate).encode()),
Expand Down Expand Up @@ -63,5 +78,15 @@ final class HeadwindProtocolTests: XCTestCase {
) {
XCTAssertEqual($0 as? HeadwindProtocolError, .unknownMode(0xFF))
}
XCTAssertThrowsError(
try HeadwindMessageDecoder.decode(Data([0xFE, 0x04, 0x01, 0xFF]))
) {
XCTAssertEqual($0 as? HeadwindProtocolError, .unknownMode(0xFF))
}
XCTAssertThrowsError(
try HeadwindMessageDecoder.decode(Data([0x00, 0x00, 0x00, 0x00]))
) {
XCTAssertEqual($0 as? HeadwindProtocolError, .malformedMessage)
}
}
}
Loading