a4a4dcf74f
Let the Workout generated interaction builder satisfy DispatchRegistry directly and guard canonical sources/docs against .into_registry() so the beginner path does not teach manual registry conversion. req: examples/001 req: examples/006 req: public_api/002
1379 lines
47 KiB
Rust
1379 lines
47 KiB
Rust
use hemplate::Hemplate;
|
|
use hemx::{Html, IntoEffect};
|
|
use hemx_axum::Form;
|
|
use hemx_host::{
|
|
browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest,
|
|
CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent, HostFailure,
|
|
HostFailureKind, SharePayload as HostShareData,
|
|
};
|
|
use std::collections::VecDeque;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
#[hemx::surface]
|
|
pub mod ui {}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ExercisePlan {
|
|
pub name: &'static str,
|
|
pub target_sets: u8,
|
|
pub reps: u8,
|
|
pub kg: f32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum WorkoutCommand {
|
|
CompleteSet,
|
|
StartNextSet,
|
|
FinishWorkout,
|
|
ChangeWeight { kg: f32 },
|
|
CorrectLastSet { kg: f32 },
|
|
SkipExercise,
|
|
RecordNote { text: String },
|
|
UndoLastAction,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum WorkoutEvent {
|
|
SetCompleted {
|
|
exercise: String,
|
|
set: u8,
|
|
reps: u8,
|
|
kg: f32,
|
|
},
|
|
RestFinished {
|
|
exercise: String,
|
|
set: u8,
|
|
},
|
|
WeightChanged {
|
|
exercise: String,
|
|
kg: f32,
|
|
},
|
|
SetEdited {
|
|
exercise: String,
|
|
set: u8,
|
|
kg: f32,
|
|
},
|
|
ExerciseSkipped {
|
|
exercise: String,
|
|
},
|
|
NoteRecorded {
|
|
text: String,
|
|
},
|
|
WorkoutFinished {
|
|
completed_sets: u8,
|
|
total_sets: u8,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum WorkoutPhase {
|
|
Ready,
|
|
Resting,
|
|
ReadyToFinish,
|
|
Finished,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct WorkoutProjection {
|
|
pub current_exercise: usize,
|
|
pub completed_sets_for_current: u8,
|
|
pub total_completed_sets: u8,
|
|
pub phase: WorkoutPhase,
|
|
pub next_action: String,
|
|
pub primary_action: String,
|
|
pub progress: String,
|
|
pub exported: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct WorkoutState {
|
|
pub plan: Vec<ExercisePlan>,
|
|
pub commands: Vec<WorkoutCommand>,
|
|
pub events: Vec<WorkoutEvent>,
|
|
pub projection: WorkoutProjection,
|
|
pub activity: VecDeque<String>,
|
|
pub host_status: String,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppState {
|
|
workout: Arc<Mutex<WorkoutState>>,
|
|
}
|
|
|
|
impl AppState {
|
|
pub fn demo() -> Self {
|
|
Self {
|
|
workout: Arc::new(Mutex::new(WorkoutState::demo())),
|
|
}
|
|
}
|
|
|
|
pub fn with_workout<R>(&self, f: impl FnOnce(&WorkoutState) -> R) -> R {
|
|
let workout = self.workout.lock().unwrap();
|
|
f(&workout)
|
|
}
|
|
|
|
fn update<R>(&self, f: impl FnOnce(&mut WorkoutState) -> R) -> R {
|
|
let mut workout = self.workout.lock().unwrap();
|
|
f(&mut workout)
|
|
}
|
|
}
|
|
|
|
impl WorkoutState {
|
|
pub fn demo() -> Self {
|
|
let mut state = Self {
|
|
plan: demo_plan(),
|
|
commands: Vec::new(),
|
|
events: Vec::new(),
|
|
projection: WorkoutProjection::start(),
|
|
activity: VecDeque::new(),
|
|
host_status: "Ready to keep the session private until you export.".into(),
|
|
};
|
|
state.refresh_next_action();
|
|
state
|
|
}
|
|
|
|
fn primary_command(&self) -> WorkoutCommand {
|
|
match self.projection.phase {
|
|
WorkoutPhase::Ready => WorkoutCommand::CompleteSet,
|
|
WorkoutPhase::Resting => WorkoutCommand::StartNextSet,
|
|
WorkoutPhase::ReadyToFinish | WorkoutPhase::Finished => WorkoutCommand::FinishWorkout,
|
|
}
|
|
}
|
|
|
|
fn accept(&mut self, command: WorkoutCommand) -> Option<WorkoutEvent> {
|
|
// req: local/001 req: local/004
|
|
let event = self.validate(&command)?;
|
|
self.commands.push(command);
|
|
self.events.push(event.clone());
|
|
self.project(&event);
|
|
Some(event)
|
|
}
|
|
|
|
fn validate(&self, command: &WorkoutCommand) -> Option<WorkoutEvent> {
|
|
match command {
|
|
WorkoutCommand::CompleteSet if self.projection.phase == WorkoutPhase::Ready => {
|
|
let exercise = self.current_exercise()?;
|
|
Some(WorkoutEvent::SetCompleted {
|
|
exercise: exercise.name.into(),
|
|
set: self.projection.completed_sets_for_current + 1,
|
|
reps: exercise.reps,
|
|
kg: exercise.kg,
|
|
})
|
|
}
|
|
WorkoutCommand::StartNextSet if self.projection.phase == WorkoutPhase::Resting => {
|
|
let (exercise, set) = self.next_ready_target()?;
|
|
Some(WorkoutEvent::RestFinished {
|
|
exercise: exercise.into(),
|
|
set,
|
|
})
|
|
}
|
|
WorkoutCommand::FinishWorkout
|
|
if self.projection.phase == WorkoutPhase::ReadyToFinish =>
|
|
{
|
|
Some(WorkoutEvent::WorkoutFinished {
|
|
completed_sets: self.projection.total_completed_sets,
|
|
total_sets: self.total_target_sets(),
|
|
})
|
|
}
|
|
WorkoutCommand::ChangeWeight { kg } if kg.is_finite() && *kg >= 0.0 => {
|
|
let index = self.editable_exercise_index()?;
|
|
let exercise = self.plan.get(index)?;
|
|
Some(WorkoutEvent::WeightChanged {
|
|
exercise: exercise.name.into(),
|
|
kg: *kg,
|
|
})
|
|
}
|
|
WorkoutCommand::CorrectLastSet { kg } if kg.is_finite() && *kg >= 0.0 => self
|
|
.last_completed_set()
|
|
.map(|(exercise, set)| WorkoutEvent::SetEdited {
|
|
exercise,
|
|
set,
|
|
kg: *kg,
|
|
}),
|
|
WorkoutCommand::SkipExercise if self.projection.phase != WorkoutPhase::Finished => {
|
|
let exercise = self.current_exercise()?;
|
|
Some(WorkoutEvent::ExerciseSkipped {
|
|
exercise: exercise.name.into(),
|
|
})
|
|
}
|
|
WorkoutCommand::RecordNote { text } if !text.trim().is_empty() => {
|
|
Some(WorkoutEvent::NoteRecorded {
|
|
text: text.trim().into(),
|
|
})
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn project(&mut self, event: &WorkoutEvent) {
|
|
// req: local/001 req: local/004
|
|
match event {
|
|
WorkoutEvent::SetCompleted { exercise, set, .. } => {
|
|
if let Some(index) = self.exercise_index(exercise) {
|
|
self.projection.current_exercise = index;
|
|
}
|
|
self.projection.completed_sets_for_current = *set;
|
|
self.projection.total_completed_sets = self
|
|
.projection
|
|
.total_completed_sets
|
|
.saturating_add(1)
|
|
.min(self.total_target_sets());
|
|
self.projection.phase =
|
|
if self.projection.total_completed_sets >= self.total_target_sets() {
|
|
WorkoutPhase::ReadyToFinish
|
|
} else {
|
|
WorkoutPhase::Resting
|
|
};
|
|
}
|
|
WorkoutEvent::RestFinished { exercise, .. } => {
|
|
if let Some(index) = self.exercise_index(exercise) {
|
|
self.projection.current_exercise = index;
|
|
self.projection.completed_sets_for_current =
|
|
completed_sets_for(&self.events, exercise);
|
|
}
|
|
self.projection.phase = WorkoutPhase::Ready;
|
|
}
|
|
WorkoutEvent::WeightChanged { exercise, kg }
|
|
| WorkoutEvent::SetEdited { exercise, kg, .. } => {
|
|
if let Some(index) = self.exercise_index(exercise) {
|
|
self.plan[index].kg = *kg;
|
|
}
|
|
}
|
|
WorkoutEvent::ExerciseSkipped { exercise } => {
|
|
let next = self
|
|
.exercise_index(exercise)
|
|
.unwrap_or(self.projection.current_exercise)
|
|
+ 1;
|
|
self.projection.current_exercise = next;
|
|
self.projection.completed_sets_for_current = 0;
|
|
self.projection.phase = if next >= self.plan.len() {
|
|
WorkoutPhase::ReadyToFinish
|
|
} else {
|
|
WorkoutPhase::Ready
|
|
};
|
|
}
|
|
WorkoutEvent::NoteRecorded { .. } => {}
|
|
WorkoutEvent::WorkoutFinished { .. } => {
|
|
self.projection.phase = WorkoutPhase::Finished;
|
|
}
|
|
}
|
|
self.refresh_next_action();
|
|
self.activity.push_front(event.summary());
|
|
while self.activity.len() > 5 {
|
|
self.activity.pop_back();
|
|
}
|
|
}
|
|
|
|
fn undo_last_event(&mut self) -> Option<String> {
|
|
// req: local/001 req: local/004
|
|
self.commands.push(WorkoutCommand::UndoLastAction);
|
|
let removed = self.events.pop()?;
|
|
let summary = removed.summary();
|
|
self.rebuild_projection();
|
|
self.activity.push_front(format!("undid {summary}"));
|
|
while self.activity.len() > 5 {
|
|
self.activity.pop_back();
|
|
}
|
|
Some(summary)
|
|
}
|
|
|
|
fn rebuild_projection(&mut self) {
|
|
let events = self.events.clone();
|
|
let host_status = self.host_status.clone();
|
|
let exported = self.projection.exported;
|
|
self.plan = demo_plan();
|
|
self.projection = WorkoutProjection::start();
|
|
self.projection.exported = exported;
|
|
self.activity.clear();
|
|
self.refresh_next_action();
|
|
for event in &events {
|
|
self.project(event);
|
|
}
|
|
self.host_status = host_status;
|
|
}
|
|
|
|
fn refresh_next_action(&mut self) {
|
|
self.projection.progress = self.progress_text();
|
|
match self.projection.phase {
|
|
WorkoutPhase::Ready => {
|
|
if let Some(exercise) = self.current_exercise() {
|
|
let name = exercise.name;
|
|
let target_sets = exercise.target_sets;
|
|
let reps = exercise.reps;
|
|
let kg = exercise.kg;
|
|
self.projection.primary_action = "Complete set".into();
|
|
self.projection.next_action = format!(
|
|
"Next: {name} set {}/{} · {reps} reps · {kg} kg",
|
|
self.projection.completed_sets_for_current + 1,
|
|
target_sets,
|
|
);
|
|
} else {
|
|
self.projection.phase = WorkoutPhase::ReadyToFinish;
|
|
self.refresh_next_action();
|
|
}
|
|
}
|
|
WorkoutPhase::Resting => {
|
|
let target = self
|
|
.next_ready_target()
|
|
.map(|(exercise, set)| format!("{exercise} set {set}"))
|
|
.unwrap_or_else(|| "finish workout".into());
|
|
self.projection.primary_action = format!("Start {target}");
|
|
self.projection.next_action = format!("Rest 90s · next: {target}");
|
|
}
|
|
WorkoutPhase::ReadyToFinish => {
|
|
self.projection.primary_action = "Finish workout".into();
|
|
self.projection.next_action =
|
|
"Workout complete · finish and export your session".into();
|
|
}
|
|
WorkoutPhase::Finished => {
|
|
self.projection.primary_action = "Session saved".into();
|
|
self.projection.next_action = "Session saved · export or replay anytime".into();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn current_exercise(&self) -> Option<&ExercisePlan> {
|
|
self.plan.get(self.projection.current_exercise)
|
|
}
|
|
|
|
fn editable_exercise_index(&self) -> Option<usize> {
|
|
match self.projection.phase {
|
|
WorkoutPhase::Finished | WorkoutPhase::ReadyToFinish => None,
|
|
WorkoutPhase::Resting
|
|
if self.current_exercise().is_some_and(|exercise| {
|
|
self.projection.completed_sets_for_current >= exercise.target_sets
|
|
}) =>
|
|
{
|
|
(self.projection.current_exercise + 1 < self.plan.len())
|
|
.then_some(self.projection.current_exercise + 1)
|
|
}
|
|
_ => self
|
|
.current_exercise()
|
|
.map(|_| self.projection.current_exercise),
|
|
}
|
|
}
|
|
|
|
fn exercise_index(&self, name: &str) -> Option<usize> {
|
|
self.plan.iter().position(|exercise| exercise.name == name)
|
|
}
|
|
|
|
fn last_completed_set(&self) -> Option<(String, u8)> {
|
|
self.events.iter().rev().find_map(|event| match event {
|
|
WorkoutEvent::SetCompleted { exercise, set, .. } => Some((exercise.clone(), *set)),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn next_ready_target(&self) -> Option<(&'static str, u8)> {
|
|
let exercise = self.current_exercise()?;
|
|
if self.projection.completed_sets_for_current >= exercise.target_sets {
|
|
let next = self.plan.get(self.projection.current_exercise + 1)?;
|
|
Some((next.name, 1))
|
|
} else {
|
|
Some((
|
|
exercise.name,
|
|
self.projection.completed_sets_for_current + 1,
|
|
))
|
|
}
|
|
}
|
|
|
|
fn total_target_sets(&self) -> u8 {
|
|
self.plan.iter().map(|exercise| exercise.target_sets).sum()
|
|
}
|
|
|
|
fn progress_text(&self) -> String {
|
|
let total = self.total_target_sets().max(1);
|
|
let done = self.projection.total_completed_sets.min(total);
|
|
let percent = u16::from(done) * 100 / u16::from(total);
|
|
format!("{done} of {total} sets · {percent}% complete")
|
|
}
|
|
|
|
pub fn event_log_text(&self) -> String {
|
|
if self.events.is_empty() {
|
|
return "No session events yet.".into();
|
|
}
|
|
self.events
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, event)| format!("{}: {}", index + 1, event.summary()))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
pub fn export_event_log(&self) -> String {
|
|
// req: local/003
|
|
self.events
|
|
.iter()
|
|
.map(WorkoutEvent::to_export_line)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn replay_export(&mut self, export: &str) -> Result<usize, String> {
|
|
// req: local/001 req: local/003 req: local/004
|
|
let lines = export
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.collect::<Vec<_>>();
|
|
if lines.is_empty() {
|
|
return Err("nothing to replay yet; complete a set first".into());
|
|
}
|
|
|
|
let mut events = Vec::with_capacity(lines.len());
|
|
for (index, line) in lines.iter().enumerate() {
|
|
let event = WorkoutEvent::from_export_line(line)
|
|
.ok_or_else(|| format!("line {} is not a workout event", index + 1))?;
|
|
events.push(event);
|
|
}
|
|
|
|
let mut replayed = WorkoutState::demo();
|
|
for event in &events {
|
|
replayed.events.push(event.clone());
|
|
replayed.project(event);
|
|
}
|
|
replayed.host_status = format!(
|
|
"Replayed {} exported events into a fresh projection; app policy still owns sync.",
|
|
events.len()
|
|
);
|
|
*self = replayed;
|
|
Ok(events.len())
|
|
}
|
|
|
|
fn recovery_text(&self) -> String {
|
|
match self.activity.front() {
|
|
Some(last) if last.starts_with("started ") => {
|
|
format!("Last action: {last}. If that was a double tap, Undo returns to rest.")
|
|
}
|
|
Some(last) if last.starts_with("changed ") || last.starts_with("corrected ") => {
|
|
format!("Last action: {last}. Undo restores the previous plan.")
|
|
}
|
|
Some(last) if last.starts_with("skipped ") => {
|
|
format!("Last action: {last}. Undo restores the skipped exercise.")
|
|
}
|
|
Some(last) if last.starts_with("undid ") => {
|
|
format!("Recovery complete: {last}. Continue from the restored next action.")
|
|
}
|
|
Some(last) => format!("Last action: {last}. Undo is available."),
|
|
None => "Undo appears here after your first session event.".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl WorkoutProjection {
|
|
fn start() -> Self {
|
|
Self {
|
|
current_exercise: 0,
|
|
completed_sets_for_current: 0,
|
|
total_completed_sets: 0,
|
|
phase: WorkoutPhase::Ready,
|
|
next_action: String::new(),
|
|
primary_action: String::new(),
|
|
progress: String::new(),
|
|
exported: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn demo_plan() -> Vec<ExercisePlan> {
|
|
vec![
|
|
ExercisePlan {
|
|
name: "Goblet squat",
|
|
target_sets: 3,
|
|
reps: 8,
|
|
kg: 24.0,
|
|
},
|
|
ExercisePlan {
|
|
name: "Push-up",
|
|
target_sets: 2,
|
|
reps: 10,
|
|
kg: 0.0,
|
|
},
|
|
]
|
|
}
|
|
|
|
fn completed_sets_for(events: &[WorkoutEvent], exercise: &str) -> u8 {
|
|
events
|
|
.iter()
|
|
.filter_map(|event| match event {
|
|
WorkoutEvent::SetCompleted {
|
|
exercise: event_exercise,
|
|
set,
|
|
..
|
|
} if event_exercise == exercise => Some(*set),
|
|
_ => None,
|
|
})
|
|
.max()
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn export_field(value: &str) -> String {
|
|
value.replace(['\t', '\n', '\r'], " ")
|
|
}
|
|
|
|
impl WorkoutEvent {
|
|
fn summary(&self) -> String {
|
|
match self {
|
|
Self::SetCompleted {
|
|
exercise,
|
|
set,
|
|
reps,
|
|
kg,
|
|
} => {
|
|
format!("completed {exercise} set {set}: {reps} reps @ {kg} kg")
|
|
}
|
|
Self::RestFinished { exercise, set } => format!("started {exercise} set {set}"),
|
|
Self::WeightChanged { exercise, kg } => format!("changed {exercise} to {kg} kg"),
|
|
Self::SetEdited { exercise, set, kg } => {
|
|
format!("corrected {exercise} set {set} to {kg} kg")
|
|
}
|
|
Self::ExerciseSkipped { exercise } => format!("skipped {exercise}"),
|
|
Self::NoteRecorded { text } => format!("note: {text}"),
|
|
Self::WorkoutFinished {
|
|
completed_sets,
|
|
total_sets,
|
|
} => format!("finished workout: {completed_sets}/{total_sets} sets complete"),
|
|
}
|
|
}
|
|
|
|
fn to_export_line(&self) -> String {
|
|
// req: local/003
|
|
match self {
|
|
Self::SetCompleted {
|
|
exercise,
|
|
set,
|
|
reps,
|
|
kg,
|
|
} => format!(
|
|
"set_completed\t{}\t{set}\t{reps}\t{kg}",
|
|
export_field(exercise)
|
|
),
|
|
Self::RestFinished { exercise, set } => {
|
|
format!("rest_finished\t{}\t{set}", export_field(exercise))
|
|
}
|
|
Self::WeightChanged { exercise, kg } => {
|
|
format!("weight_changed\t{}\t{kg}", export_field(exercise))
|
|
}
|
|
Self::SetEdited { exercise, set, kg } => {
|
|
format!("set_edited\t{}\t{set}\t{kg}", export_field(exercise))
|
|
}
|
|
Self::ExerciseSkipped { exercise } => {
|
|
format!("exercise_skipped\t{}", export_field(exercise))
|
|
}
|
|
Self::NoteRecorded { text } => format!("note_recorded\t{}", export_field(text)),
|
|
Self::WorkoutFinished {
|
|
completed_sets,
|
|
total_sets,
|
|
} => format!("workout_finished\t{completed_sets}\t{total_sets}"),
|
|
}
|
|
}
|
|
|
|
fn from_export_line(line: &str) -> Option<Self> {
|
|
// req: local/003
|
|
let fields = line.split('\t').collect::<Vec<_>>();
|
|
match fields.as_slice() {
|
|
["set_completed", exercise, set, reps, kg] => Some(Self::SetCompleted {
|
|
exercise: (*exercise).into(),
|
|
set: set.parse().ok()?,
|
|
reps: reps.parse().ok()?,
|
|
kg: kg.parse().ok()?,
|
|
}),
|
|
["rest_finished", exercise, set] => Some(Self::RestFinished {
|
|
exercise: (*exercise).into(),
|
|
set: set.parse().ok()?,
|
|
}),
|
|
["weight_changed", exercise, kg] => Some(Self::WeightChanged {
|
|
exercise: (*exercise).into(),
|
|
kg: kg.parse().ok()?,
|
|
}),
|
|
["set_edited", exercise, set, kg] => Some(Self::SetEdited {
|
|
exercise: (*exercise).into(),
|
|
set: set.parse().ok()?,
|
|
kg: kg.parse().ok()?,
|
|
}),
|
|
["exercise_skipped", exercise] => Some(Self::ExerciseSkipped {
|
|
exercise: (*exercise).into(),
|
|
}),
|
|
["note_recorded", text] => Some(Self::NoteRecorded {
|
|
text: (*text).into(),
|
|
}),
|
|
["workout_finished", completed_sets, total_sets] => Some(Self::WorkoutFinished {
|
|
completed_sets: completed_sets.parse().ok()?,
|
|
total_sets: total_sets.parse().ok()?,
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
pub struct Workout {
|
|
pub next_action: String,
|
|
pub primary_action: String,
|
|
pub status: String,
|
|
pub progress: String,
|
|
pub recovery_status: String,
|
|
pub finish_title: String,
|
|
pub finish_copy: String,
|
|
pub share_action: String,
|
|
pub event_log: String,
|
|
pub export_payload: String,
|
|
pub host_status: String,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
pub struct AppShell {
|
|
pub runtime_src: &'static str,
|
|
pub body: Html,
|
|
}
|
|
|
|
pub fn page(runtime_src: &'static str, state: &WorkoutState) -> Html {
|
|
ui::page(&AppShell {
|
|
runtime_src,
|
|
body: render(state),
|
|
})
|
|
}
|
|
|
|
pub fn view(state: &WorkoutState) -> Workout {
|
|
let export = state.export_event_log();
|
|
let has_events = !state.events.is_empty();
|
|
let (finish_title, finish_copy, share_action) = match state.projection.phase {
|
|
WorkoutPhase::Ready | WorkoutPhase::Resting if !has_events => (
|
|
"Finish unlocks as you train".into(),
|
|
"Complete a set to create a replayable local export.".into(),
|
|
"Export after first set".into(),
|
|
),
|
|
WorkoutPhase::Ready | WorkoutPhase::Resting => (
|
|
"Session log is ready".into(),
|
|
"Your progress is already replayable; finish all sets for the final export moment.".into(),
|
|
"Share current log".into(),
|
|
),
|
|
WorkoutPhase::ReadyToFinish => (
|
|
"All sets complete".into(),
|
|
"Finish to save the workout summary, then share or replay the exact local event log.".into(),
|
|
"Finish, then share".into(),
|
|
),
|
|
WorkoutPhase::Finished if state.projection.exported => (
|
|
"Export shared".into(),
|
|
"The shared text is still just a copy; this local event log remains the source.".into(),
|
|
"Share again".into(),
|
|
),
|
|
WorkoutPhase::Finished => (
|
|
"Workout saved".into(),
|
|
"Share the final event log, or replay it locally to prove recovery without hidden browser state.".into(),
|
|
"Share final export".into(),
|
|
),
|
|
};
|
|
Workout {
|
|
next_action: state.projection.next_action.clone(),
|
|
primary_action: state.projection.primary_action.clone(),
|
|
status: state.projection.progress.clone(),
|
|
progress: state.projection.progress.clone(),
|
|
recovery_status: state.recovery_text(),
|
|
finish_title,
|
|
finish_copy,
|
|
share_action,
|
|
event_log: state.event_log_text(),
|
|
export_payload: if export.is_empty() {
|
|
"Complete a set to create a replayable export.".into()
|
|
} else {
|
|
export
|
|
},
|
|
host_status: state.host_status.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn render(state: &WorkoutState) -> Html {
|
|
ui::page(&view(state))
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
#[hemx::form("change_weight")]
|
|
pub struct ChangeWeightInput {
|
|
kg: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
#[hemx::form("correct_last_set")]
|
|
pub struct CorrectLastSetInput {
|
|
kg: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
#[hemx::form("record_note")]
|
|
pub struct RecordNoteInput {
|
|
text: String,
|
|
}
|
|
|
|
pub fn interactions(state: AppState) -> impl hemx_axum::DispatchRegistry {
|
|
// req: codegen/002 req: examples/001
|
|
hemx_axum::state_interactions(ui::BUILD_FINGERPRINT, state)
|
|
.on_state(ui::workout::complete_set, complete_set)
|
|
.on(ui::workout::change_weight, change_weight_form)
|
|
.on(ui::workout::correct_last_set, correct_last_set_form)
|
|
.on_state(ui::workout::skip_exercise, skip_exercise)
|
|
.on(ui::workout::record_note, record_note_form)
|
|
.on_state(ui::workout::undo_last_action, undo_last_action)
|
|
.on_state(ui::workout::replay_export, replay_export)
|
|
.on_state(ui::workout::export_log, export_log)
|
|
.on_state(ui::workout::record_share_result, record_share_result)
|
|
.on_state(ui::workout::record_share_denied, record_share_denied)
|
|
.on_state(ui::workout::record_host_timeout, record_host_timeout)
|
|
.on_state(ui::workout::replay_broken_export, replay_broken_export)
|
|
.on_state(ui::workout::request_native_haptic, request_native_haptic)
|
|
.on_state(
|
|
ui::workout::record_native_haptic_ack,
|
|
record_native_haptic_ack,
|
|
)
|
|
}
|
|
|
|
fn effects(state: &WorkoutState, status: impl Into<String>) -> impl IntoEffect {
|
|
// req: local/001 req: local/004
|
|
let view = view(state);
|
|
(
|
|
ui::workout::next_action.text(&view.next_action),
|
|
ui::workout::primary_action.text(&view.primary_action),
|
|
ui::workout::status.text(status.into()),
|
|
ui::workout::progress.text(&view.progress),
|
|
ui::workout::recovery_status.text(&view.recovery_status),
|
|
ui::workout::finish_title.text(&view.finish_title),
|
|
ui::workout::finish_copy.text(&view.finish_copy),
|
|
ui::workout::share_action.text(&view.share_action),
|
|
ui::workout::event_log.text(&view.event_log),
|
|
ui::workout::export_payload.text(&view.export_payload),
|
|
ui::workout::host_status.text(&view.host_status),
|
|
)
|
|
}
|
|
|
|
pub fn complete_set(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
let command = state.primary_command();
|
|
let event = state.accept(command);
|
|
if matches!(event, Some(WorkoutEvent::SetCompleted { .. })) {
|
|
state.host_status =
|
|
"Set saved locally; native haptic can acknowledge it without owning state.".into();
|
|
}
|
|
effects(
|
|
state,
|
|
event.map_or_else(
|
|
|| "Session is already saved; export is ready.".into(),
|
|
|event| event.summary(),
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn change_weight(app: AppState, kg: f32) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
let event = state.accept(WorkoutCommand::ChangeWeight { kg });
|
|
effects(
|
|
state,
|
|
event.map_or_else(
|
|
|| "Enter a valid weight for the current or next exercise.".into(),
|
|
|event| event.summary(),
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn correct_last_set(app: AppState, kg: f32) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
let event = state.accept(WorkoutCommand::CorrectLastSet { kg });
|
|
effects(
|
|
state,
|
|
event.map_or_else(
|
|
|| "Complete a set before correcting its recorded weight.".into(),
|
|
|event| event.summary(),
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn skip_exercise(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
let event = state.accept(WorkoutCommand::SkipExercise);
|
|
effects(
|
|
state,
|
|
event.map_or_else(
|
|
|| "No exercise available to skip.".into(),
|
|
|event| event.summary(),
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn undo_last_action(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
let undone = state.undo_last_event();
|
|
effects(
|
|
state,
|
|
undone.map_or_else(
|
|
|| "Nothing to undo yet; complete a set first.".into(),
|
|
|summary| format!("Undid: {summary}"),
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn change_weight_form(app: AppState, Form(input): Form<ChangeWeightInput>) -> impl IntoEffect {
|
|
app.update(|state| match input.kg.trim().parse::<f32>() {
|
|
Ok(kg) if kg.is_finite() && kg >= 0.0 => {
|
|
let event = state.accept(WorkoutCommand::ChangeWeight { kg });
|
|
effects(
|
|
state,
|
|
event.map_or_else(
|
|
|| "Enter a valid weight for the current or next exercise.".into(),
|
|
|event| event.summary(),
|
|
),
|
|
)
|
|
}
|
|
_ => effects(
|
|
state,
|
|
String::from("Enter a valid non-negative weight; your session is unchanged."),
|
|
),
|
|
})
|
|
}
|
|
|
|
pub fn correct_last_set_form(
|
|
app: AppState,
|
|
Form(input): Form<CorrectLastSetInput>,
|
|
) -> impl IntoEffect {
|
|
app.update(|state| match input.kg.trim().parse::<f32>() {
|
|
Ok(kg) if kg.is_finite() && kg >= 0.0 => {
|
|
let event = state.accept(WorkoutCommand::CorrectLastSet { kg });
|
|
effects(
|
|
state,
|
|
event.map_or_else(
|
|
|| "Complete a set before correcting its recorded weight.".into(),
|
|
|event| event.summary(),
|
|
),
|
|
)
|
|
}
|
|
_ => effects(
|
|
state,
|
|
String::from("Enter a valid corrected weight; your session is unchanged."),
|
|
),
|
|
})
|
|
}
|
|
|
|
pub fn record_note_form(app: AppState, Form(input): Form<RecordNoteInput>) -> impl IntoEffect {
|
|
record_note(app, input.text)
|
|
}
|
|
|
|
pub fn record_note(app: AppState, text: impl Into<String>) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
let event = state.accept(WorkoutCommand::RecordNote { text: text.into() });
|
|
effects(
|
|
state,
|
|
event.map_or_else(
|
|
|| "Ignored empty note; nothing changed.".into(),
|
|
|event| event.summary(),
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn replay_export(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
// req: local/001 req: local/003 req: local/004
|
|
let export = state.export_event_log();
|
|
match state.replay_export(&export) {
|
|
Ok(count) => effects(state, format!("Replayed {count} exported workout events")),
|
|
Err(error) => effects(
|
|
state,
|
|
format!("Replay failed: {error}. Current session is unchanged."),
|
|
),
|
|
}
|
|
})
|
|
}
|
|
|
|
pub fn replay_broken_export(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
// req: local/001 req: local/003 req: local/004
|
|
match state.replay_export("not-a-workout-export") {
|
|
Ok(count) => effects(state, format!("Replayed {count} exported workout events")),
|
|
Err(error) => effects(
|
|
state,
|
|
format!("Replay failed: {error}. Current session is unchanged."),
|
|
),
|
|
}
|
|
})
|
|
}
|
|
|
|
pub fn export_log(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
// req: host/001 req: host/004 req: local/003
|
|
if state.events.is_empty() {
|
|
state.host_status = "Complete a set before opening the share sheet.".into();
|
|
return effects(state, "Nothing to export yet");
|
|
}
|
|
let manifest = CapabilityManifest::new([CapabilityUse::new(
|
|
Capability::Share,
|
|
CapabilityShape::Request,
|
|
)]);
|
|
let call = HostCall::Share {
|
|
id: HostCallId::new("workout-export"),
|
|
payload: HostShareData::text(state.export_event_log()),
|
|
};
|
|
state.host_status = match manifest.validate_call(&browser_pwa_host_profile(), &call) {
|
|
Ok(()) => "Share sheet requested; your local event log remains the source.".into(),
|
|
Err(error) => format!("Share unavailable: {error}. Export text stays below."),
|
|
};
|
|
effects(state, "Share export prepared")
|
|
})
|
|
}
|
|
|
|
pub fn record_share_result(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
// req: host/002 req: host/005 req: local/003
|
|
apply_host_event(
|
|
state,
|
|
HostEvent::ShareCompleted {
|
|
id: HostCallId::new("workout-export"),
|
|
completed: true,
|
|
},
|
|
);
|
|
effects(state, "Share result returned through app code")
|
|
})
|
|
}
|
|
|
|
pub fn record_share_denied(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
// req: host/002 req: host/005 req: local/003
|
|
apply_host_event(
|
|
state,
|
|
HostEvent::Failed(
|
|
HostFailure::new(HostFailureKind::PermissionDenied, "share permission denied")
|
|
.with_capability(Capability::Share),
|
|
),
|
|
);
|
|
effects(state, "Share permission denial returned through app code")
|
|
})
|
|
}
|
|
|
|
pub fn record_host_timeout(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
// req: host/002 req: host/005 req: local/003
|
|
apply_host_event(
|
|
state,
|
|
HostEvent::Failed(
|
|
HostFailure::new(HostFailureKind::Timeout, "share timed out")
|
|
.with_id(HostCallId::new("workout-export"))
|
|
.with_capability(Capability::Share),
|
|
),
|
|
);
|
|
effects(state, "Host timeout returned through app code")
|
|
})
|
|
}
|
|
|
|
pub fn request_native_haptic(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
// req: host/001 req: host/004
|
|
let manifest = CapabilityManifest::new([CapabilityUse::new(
|
|
Capability::Haptics,
|
|
CapabilityShape::Fire,
|
|
)]);
|
|
let call = HostCall::Haptic {
|
|
id: HostCallId::new("workout-set-haptic"),
|
|
pattern: HapticPattern::Success,
|
|
};
|
|
state.host_status = match manifest.validate_call(
|
|
&native_shell_host_profile("ios-android-webview-workout"),
|
|
&call,
|
|
) {
|
|
Ok(()) => "Native-shell haptic requested for the saved set.".into(),
|
|
Err(error) => format!("Native haptic unavailable: {error}"),
|
|
};
|
|
effects(state, "Native-shell host call checked against manifest")
|
|
})
|
|
}
|
|
|
|
pub fn record_native_haptic_ack(app: AppState) -> impl IntoEffect {
|
|
app.update(|state| {
|
|
// req: host/002 req: host/005
|
|
apply_host_event(
|
|
state,
|
|
HostEvent::Acknowledged {
|
|
id: HostCallId::new("workout-set-haptic"),
|
|
},
|
|
);
|
|
effects(state, "Native-shell host result accepted by app code")
|
|
})
|
|
}
|
|
|
|
fn host_failure_label(kind: HostFailureKind) -> &'static str {
|
|
match kind {
|
|
HostFailureKind::PermissionDenied => "permission denied",
|
|
HostFailureKind::Timeout => "timeout",
|
|
HostFailureKind::Unavailable => "unavailable",
|
|
HostFailureKind::Error => "error",
|
|
}
|
|
}
|
|
|
|
fn apply_host_event(state: &mut WorkoutState, event: HostEvent) {
|
|
match event {
|
|
HostEvent::ShareCompleted {
|
|
completed: true, ..
|
|
} => {
|
|
state.projection.exported = true;
|
|
state.host_status = "Shared. The replayable event log remains local truth.".into();
|
|
}
|
|
HostEvent::ShareCompleted {
|
|
completed: false, ..
|
|
} => {
|
|
state.host_status =
|
|
"Share cancelled; local event log unchanged. Try Share export when ready.".into();
|
|
}
|
|
HostEvent::Failed(failure)
|
|
if failure.kind == HostFailureKind::PermissionDenied
|
|
&& failure.capability == Some(Capability::Share) =>
|
|
{
|
|
state.host_status =
|
|
"Share permission denied. Local export text is still ready; try again or copy it."
|
|
.into();
|
|
}
|
|
HostEvent::Failed(failure) => {
|
|
state.host_status = format!(
|
|
"Host {}: {}. Keep the local export below and try Share export again.",
|
|
host_failure_label(failure.kind),
|
|
failure.message
|
|
);
|
|
}
|
|
HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => {
|
|
state.host_status =
|
|
"Native haptic acknowledged; workout state stayed app-owned.".into();
|
|
}
|
|
_ => {
|
|
state.host_status = "Host event ignored by app policy.".into();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use hemx::advanced::{Effect, Payload};
|
|
use hemx_test::run;
|
|
|
|
fn contains_payload_text(effects: &hemx_test::EffectInspector, needle: &str) -> bool {
|
|
effects.batch().ops.iter().any(|op| {
|
|
matches!(op, Effect::Put { payload: Payload::Text(text), .. } if text.contains(needle))
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn local_command_projects_before_ui_effects() {
|
|
// req: local/001 req: local/004
|
|
let app = AppState::demo();
|
|
let effects = run(|()| complete_set(app.clone()), ());
|
|
|
|
assert_eq!(
|
|
app.with_workout(|state| (
|
|
state.commands.len(),
|
|
state.events.len(),
|
|
state.projection.completed_sets_for_current,
|
|
state.projection.phase.clone(),
|
|
)),
|
|
(1, 1, 1, WorkoutPhase::Resting)
|
|
);
|
|
assert!(contains_payload_text(
|
|
&effects,
|
|
"completed Goblet squat set 1"
|
|
));
|
|
assert!(contains_payload_text(&effects, "Rest 90s"));
|
|
assert!(contains_payload_text(&effects, "Start Goblet squat set 2"));
|
|
assert_eq!(
|
|
app.with_workout(|state| (state.commands.len(), state.events.len())),
|
|
(1, 1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn primary_action_moves_from_rest_to_next_set_and_finish() {
|
|
// req: examples/001 req: local/001 req: local/004
|
|
let app = AppState::demo();
|
|
run(|()| complete_set(app.clone()), ());
|
|
let rest = run(|()| complete_set(app.clone()), ());
|
|
assert!(contains_payload_text(&rest, "started Goblet squat set 2"));
|
|
assert_eq!(
|
|
app.with_workout(|state| state.projection.phase.clone()),
|
|
WorkoutPhase::Ready
|
|
);
|
|
|
|
for _ in 0..7 {
|
|
run(|()| complete_set(app.clone()), ());
|
|
}
|
|
assert_eq!(
|
|
app.with_workout(|state| state.projection.phase.clone()),
|
|
WorkoutPhase::ReadyToFinish
|
|
);
|
|
let finished = run(|()| complete_set(app.clone()), ());
|
|
assert!(contains_payload_text(
|
|
&finished,
|
|
"finished workout: 5/5 sets complete"
|
|
));
|
|
assert!(contains_payload_text(&finished, "Workout saved"));
|
|
assert!(contains_payload_text(&finished, "Share final export"));
|
|
assert_eq!(
|
|
app.with_workout(|state| state.projection.phase.clone()),
|
|
WorkoutPhase::Finished
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn double_primary_action_recovers_through_undo() {
|
|
// req: examples/001 req: local/001 req: local/004
|
|
let app = AppState::demo();
|
|
run(|()| complete_set(app.clone()), ());
|
|
let double_action = run(|()| complete_set(app.clone()), ());
|
|
assert!(contains_payload_text(
|
|
&double_action,
|
|
"started Goblet squat set 2"
|
|
));
|
|
assert_eq!(
|
|
app.with_workout(|state| (state.events.len(), state.projection.phase.clone())),
|
|
(2, WorkoutPhase::Ready)
|
|
);
|
|
|
|
let undone = run(|()| undo_last_action(app.clone()), ());
|
|
assert!(contains_payload_text(
|
|
&undone,
|
|
"Undid: started Goblet squat set 2"
|
|
));
|
|
assert!(contains_payload_text(&undone, "Rest 90s"));
|
|
assert_eq!(
|
|
app.with_workout(|state| (state.events.len(), state.projection.phase.clone())),
|
|
(1, WorkoutPhase::Resting)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn correct_last_set_is_replayable_and_undoable() {
|
|
// req: examples/001 req: local/001 req: local/003 req: local/004
|
|
let app = AppState::demo();
|
|
run(|()| complete_set(app.clone()), ());
|
|
|
|
let corrected = run(|()| correct_last_set(app.clone(), 26.0), ());
|
|
assert!(contains_payload_text(
|
|
&corrected,
|
|
"corrected Goblet squat set 1 to 26 kg"
|
|
));
|
|
assert_eq!(
|
|
app.with_workout(|state| (state.events.len(), state.plan[0].kg)),
|
|
(2, 26.0)
|
|
);
|
|
let export = app.with_workout(WorkoutState::export_event_log);
|
|
assert!(export.contains("set_edited\tGoblet squat\t1\t26"));
|
|
|
|
let replay = run(|()| replay_export(app.clone()), ());
|
|
assert!(contains_payload_text(
|
|
&replay,
|
|
"Replayed 2 exported workout events"
|
|
));
|
|
assert_eq!(app.with_workout(|state| state.plan[0].kg), 26.0);
|
|
|
|
let undone = run(|()| undo_last_action(app.clone()), ());
|
|
assert!(contains_payload_text(
|
|
&undone,
|
|
"Undid: corrected Goblet squat set 1 to 26 kg"
|
|
));
|
|
assert_eq!(app.with_workout(|state| state.plan[0].kg), 24.0);
|
|
}
|
|
|
|
#[test]
|
|
fn skipped_exercise_recovers_through_undo() {
|
|
// req: examples/001 req: local/001 req: local/004
|
|
let app = AppState::demo();
|
|
|
|
let skipped = run(|()| skip_exercise(app.clone()), ());
|
|
assert!(contains_payload_text(&skipped, "skipped Goblet squat"));
|
|
assert!(contains_payload_text(&skipped, "Next: Push-up set 1/2"));
|
|
assert_eq!(
|
|
app.with_workout(|state| (
|
|
state.events.len(),
|
|
state.projection.current_exercise,
|
|
state.projection.phase.clone(),
|
|
)),
|
|
(1, 1, WorkoutPhase::Ready)
|
|
);
|
|
|
|
let undone = run(|()| undo_last_action(app.clone()), ());
|
|
assert!(contains_payload_text(
|
|
&undone,
|
|
"Undid: skipped Goblet squat"
|
|
));
|
|
assert!(contains_payload_text(&undone, "Next: Goblet squat set 1/3"));
|
|
assert_eq!(
|
|
app.with_workout(|state| (
|
|
state.events.len(),
|
|
state.projection.current_exercise,
|
|
state.projection.phase.clone(),
|
|
)),
|
|
(0, 0, WorkoutPhase::Ready)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn undo_recovers_last_session_action() {
|
|
// req: examples/001 req: local/001 req: local/004
|
|
let app = AppState::demo();
|
|
run(|()| complete_set(app.clone()), ());
|
|
let undone = run(|()| undo_last_action(app.clone()), ());
|
|
|
|
assert!(contains_payload_text(
|
|
&undone,
|
|
"Undid: completed Goblet squat set 1"
|
|
));
|
|
assert_eq!(
|
|
app.with_workout(|state| (
|
|
state.events.len(),
|
|
state.projection.completed_sets_for_current,
|
|
state.projection.phase.clone(),
|
|
)),
|
|
(0, 0, WorkoutPhase::Ready)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_weight_and_replay_failure_keep_session_recoverable() {
|
|
// req: examples/001 req: local/001 req: local/003 req: local/004
|
|
let app = AppState::demo();
|
|
run(|()| complete_set(app.clone()), ());
|
|
|
|
let invalid = run(
|
|
|input| change_weight_form(app.clone(), Form(input)),
|
|
ChangeWeightInput { kg: "oops".into() },
|
|
);
|
|
assert!(contains_payload_text(&invalid, "valid non-negative weight"));
|
|
assert_eq!(
|
|
app.with_workout(|state| (state.events.len(), state.projection.phase.clone())),
|
|
(1, WorkoutPhase::Resting)
|
|
);
|
|
|
|
let replay = run(|()| replay_broken_export(app.clone()), ());
|
|
assert!(contains_payload_text(&replay, "Replay failed: line 1"));
|
|
assert!(contains_payload_text(
|
|
&replay,
|
|
"Current session is unchanged"
|
|
));
|
|
assert_eq!(app.with_workout(|state| state.events.len()), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn export_payload_replays_into_projection_before_ui_effects() {
|
|
// req: local/001 req: local/003 req: local/004
|
|
let app = AppState::demo();
|
|
run(|()| complete_set(app.clone()), ());
|
|
run(|()| complete_set(app.clone()), ());
|
|
run(|()| change_weight(app.clone(), 28.0), ());
|
|
let export = app.with_workout(WorkoutState::export_event_log);
|
|
assert!(export.contains("set_completed\tGoblet squat\t1\t8\t24"));
|
|
assert!(export.contains("rest_finished\tGoblet squat\t2"));
|
|
assert!(export.contains("weight_changed\tGoblet squat\t28"));
|
|
|
|
let replay = run(|()| replay_export(app.clone()), ());
|
|
assert!(contains_payload_text(
|
|
&replay,
|
|
"Replayed 3 exported workout events"
|
|
));
|
|
assert_eq!(
|
|
app.with_workout(|state| {
|
|
(
|
|
state.events.len(),
|
|
state.projection.completed_sets_for_current,
|
|
state.plan[0].kg,
|
|
state.projection.phase.clone(),
|
|
)
|
|
}),
|
|
(3, 1, 28.0, WorkoutPhase::Ready)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn host_export_result_returns_through_app_code() {
|
|
// req: host/001 req: host/002 req: host/005 req: local/003
|
|
let app = AppState::demo();
|
|
run(|()| complete_set(app.clone()), ());
|
|
|
|
let export = run(|()| export_log(app.clone()), ());
|
|
assert!(contains_payload_text(&export, "Share export prepared"));
|
|
assert!(contains_payload_text(&export, "Share sheet requested"));
|
|
|
|
let shared = run(|()| record_share_result(app.clone()), ());
|
|
assert!(app.with_workout(|state| state.projection.exported));
|
|
assert!(contains_payload_text(
|
|
&shared,
|
|
"Share result returned through app code"
|
|
));
|
|
assert!(contains_payload_text(
|
|
&shared,
|
|
"replayable event log remains local truth"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn host_failure_results_keep_local_export_recoverable() {
|
|
// req: host/002 req: host/005 req: local/003
|
|
let app = AppState::demo();
|
|
run(|()| complete_set(app.clone()), ());
|
|
|
|
let denied = run(|()| record_share_denied(app.clone()), ());
|
|
assert!(contains_payload_text(&denied, "Share permission denial"));
|
|
assert!(contains_payload_text(
|
|
&denied,
|
|
"Local export text is still ready"
|
|
));
|
|
assert!(!app.with_workout(|state| state.projection.exported));
|
|
|
|
let timeout = run(|()| record_host_timeout(app.clone()), ());
|
|
assert!(contains_payload_text(&timeout, "Host timeout"));
|
|
assert!(contains_payload_text(
|
|
&timeout,
|
|
"Keep the local export below"
|
|
));
|
|
assert_eq!(app.with_workout(|state| state.events.len()), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn native_shell_haptic_result_returns_through_app_code() {
|
|
// req: host/001 req: host/002 req: host/005
|
|
let app = AppState::demo();
|
|
|
|
let request = run(|()| request_native_haptic(app.clone()), ());
|
|
assert!(contains_payload_text(
|
|
&request,
|
|
"Native-shell host call checked against manifest"
|
|
));
|
|
assert!(contains_payload_text(
|
|
&request,
|
|
"Native-shell haptic requested"
|
|
));
|
|
|
|
let ack = run(|()| record_native_haptic_ack(app.clone()), ());
|
|
assert!(contains_payload_text(
|
|
&ack,
|
|
"Native-shell host result accepted by app code"
|
|
));
|
|
assert!(contains_payload_text(
|
|
&ack,
|
|
"workout state stayed app-owned"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn rendered_page_has_phone_first_controls_without_user_js() {
|
|
// req: examples/001 req: examples/005
|
|
let html = render(&WorkoutState::demo()).to_string();
|
|
assert!(html.contains("Now-first Workout Copilot"));
|
|
assert!(html.contains("Complete set"));
|
|
assert!(html.contains("Finish unlocks as you train"));
|
|
assert!(html.contains("Export after first set"));
|
|
assert!(html.contains("Undo last action"));
|
|
assert!(html.contains("Correct last set"));
|
|
assert!(html.contains("Host proof panel"));
|
|
assert!(html.contains("Simulate share denied"));
|
|
assert!(html.contains("Simulate replay failure"));
|
|
assert!(!html.contains(&format!("<{}", "script")));
|
|
assert!(!html.contains("querySelector"));
|
|
}
|
|
}
|