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
70 lines
3.2 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|