Files
workouts/WorkoutsTests/ExerciseInfoTests.swift
rzen 0f5f11e5e2 Add spoken exercise instructions (on-device TTS)
Speak exercise setup and form cues aloud with AVSpeechSynthesizer:

- Library detail: a speaker toolbar button reads the full reference aloud.
- Active workout: an opt-in "Speak Exercise Cues" setting speaks a brief
  cue (Setup/Execution/Cues) when an exercise starts, hands-free.
- Settings › Voice: pick the voice (auto-prefers an installed enhanced/
  premium English voice), a premium-download nudge shown only while on a
  basic voice, and a Speed/Pitch/Volume sheet with Reset to Defaults.

On-device and offline; ducks other audio rather than stopping it. iPhone
only for now. Shared SpeechSettings is the single source of truth for the
voice/prosody, read fresh per utterance so changes preview live.

Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
2026-07-09 13:27:40 -04:00

70 lines
3.2 KiB
Swift

import Foundation
import Testing
@testable import Workouts
/// Locks the `info.md` parsing contract: the bundled reference pages must parse into
/// the summary / metadata / sections shape the library detail screen renders, and
/// every shipped motion rig must come with one (the authoring convention).
struct ExerciseInfoTests {
@Test func bundledChestPressInfoParses() throws {
let info = try #require(ExerciseInfoLibrary.info(for: "Chest Press"))
#expect(info.summary.contains("machine bench press"))
#expect(info.category == "Main circuit")
#expect(info.type == "Machine-based horizontal press")
#expect(info.isMachineBased)
#expect(info.targets == ["Pectorals", "anterior deltoids", "triceps"])
#expect(info.sections.map(\.title) == ["Setup", "Execution", "Cues", "Common Mistakes", "Progression"])
let execution = try #require(info.sections.first { $0.title == "Execution" })
#expect(execution.items.count == 3)
guard case .step(let firstStep) = execution.items[0] else {
Issue.record("expected Execution to start with a numbered step")
return
}
// Hard-wrapped source lines must rejoin into one step.
#expect(firstStep.contains("stop short of locking the elbows"))
let cues = try #require(info.sections.first { $0.title == "Cues" })
guard case .bullet = cues.items[0] else {
Issue.record("expected Cues to be bullets")
return
}
}
/// The spoken script (fed to `AVSpeechSynthesizer`) leads with the exercise name and
/// its summary, and `brief` drops the reference-only sections so a hands-free workout
/// cue reads Setup/Execution/Cues but not Common Mistakes/Progression.
@Test func spokenScriptBriefDropsReferenceSections() throws {
let info = try #require(ExerciseInfoLibrary.info(for: "Chest Press"))
let full = info.spokenScript(name: "Chest Press", brief: false)
#expect(full.hasPrefix("Chest Press."))
#expect(full.contains("Setup."))
#expect(full.contains("Execution."))
#expect(full.contains("Cues."))
#expect(full.contains("Common Mistakes."))
#expect(full.contains("Progression."))
let brief = info.spokenScript(name: "Chest Press", brief: true)
#expect(brief.hasPrefix("Chest Press."))
#expect(brief.contains("Setup."))
#expect(brief.contains("Execution."))
#expect(brief.contains("Cues."))
#expect(!brief.contains("Common Mistakes."))
#expect(!brief.contains("Progression."))
}
/// Every bundled motion rig ships a parseable info page — the exporter copies
/// `info.md` per entry, and the detail screen counts on the shape.
@Test func everyBundledExerciseHasParseableInfo() throws {
#expect(!ExerciseMotionLibrary.exerciseNames.isEmpty)
for name in ExerciseMotionLibrary.exerciseNames {
let info = try #require(ExerciseInfoLibrary.info(for: name), "\(name) has no bundled info.md")
#expect(!info.summary.isEmpty, "\(name): empty summary")
#expect(!info.targets.isEmpty, "\(name): no targets")
#expect(!info.sections.isEmpty, "\(name): no sections")
}
}
}