feat(workout): complete mobile session flow

Make Workout Copilot behave like a finished phone-first session: rest/next state, progress, undo recovery, finish/export, replayable local log, and host proof controls tucked behind product states.

req: examples/001

req: local/001

req: local/003

req: local/004

req: host/002
This commit is contained in:
slhx agent
2026-06-11 23:15:55 +02:00
parent bf7560b7a8
commit a514237b09
7 changed files with 561 additions and 161 deletions
+442 -109
View File
@@ -23,9 +23,12 @@ pub struct ExercisePlan {
#[derive(Clone, Debug, PartialEq)]
pub enum WorkoutCommand {
CompleteSet,
StartNextSet,
FinishWorkout,
ChangeWeight { kg: f32 },
SkipExercise,
RecordNote { text: String },
UndoLastAction,
}
#[derive(Clone, Debug, PartialEq)]
@@ -36,6 +39,10 @@ pub enum WorkoutEvent {
reps: u8,
kg: f32,
},
RestFinished {
exercise: String,
set: u8,
},
WeightChanged {
exercise: String,
kg: f32,
@@ -46,13 +53,29 @@ pub enum WorkoutEvent {
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,
}
@@ -92,35 +115,25 @@ impl AppState {
impl WorkoutState {
pub fn demo() -> Self {
let mut state = Self {
plan: 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,
},
],
plan: demo_plan(),
commands: Vec::new(),
events: Vec::new(),
projection: WorkoutProjection {
current_exercise: 0,
completed_sets_for_current: 0,
next_action: String::new(),
exported: false,
},
projection: WorkoutProjection::start(),
activity: VecDeque::new(),
host_status: "Export waits for an explicit host result.".into(),
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)?;
@@ -131,52 +144,104 @@ impl WorkoutState {
}
fn validate(&self, command: &WorkoutCommand) -> Option<WorkoutEvent> {
let exercise = self.plan.get(self.projection.current_exercise)?;
match command {
WorkoutCommand::CompleteSet => Some(WorkoutEvent::SetCompleted {
exercise: exercise.name.into(),
set: self.projection.completed_sets_for_current + 1,
reps: exercise.reps,
kg: exercise.kg,
}),
WorkoutCommand::ChangeWeight { kg } => Some(WorkoutEvent::WeightChanged {
exercise: exercise.name.into(),
kg: *kg,
}),
WorkoutCommand::SkipExercise => Some(WorkoutEvent::ExerciseSkipped {
exercise: exercise.name.into(),
}),
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::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(),
})
}
WorkoutCommand::RecordNote { .. } => None,
_ => None,
}
}
fn project(&mut self, event: &WorkoutEvent) {
// req: local/001 req: local/004
match event {
WorkoutEvent::SetCompleted { set, .. } => {
WorkoutEvent::SetCompleted { exercise, set, .. } => {
if let Some(index) = self.exercise_index(exercise) {
self.projection.current_exercise = index;
}
self.projection.completed_sets_for_current = *set;
if let Some(exercise) = self.plan.get(self.projection.current_exercise) {
if *set >= exercise.target_sets {
self.projection.current_exercise += 1;
self.projection.completed_sets_for_current = 0;
}
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 } => {
if let Some(index) = self.exercise_index(exercise) {
self.plan[index].kg = *kg;
}
}
WorkoutEvent::WeightChanged { kg, .. } => {
if let Some(exercise) = self.plan.get_mut(self.projection.current_exercise) {
exercise.kg = *kg;
}
}
WorkoutEvent::ExerciseSkipped { .. } => {
self.projection.current_exercise += 1;
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());
@@ -185,26 +250,126 @@ impl WorkoutState {
}
}
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.next_action = self
.plan
.get(self.projection.current_exercise)
.map(|exercise| {
format!(
"Next: {} set {}/{} · {} reps · {} kg",
exercise.name,
self.projection.completed_sets_for_current + 1,
exercise.target_sets,
exercise.reps,
exercise.kg
)
})
.unwrap_or_else(|| "Workout complete · export or replay the event log".into());
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 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 "[]".into();
return "No session events yet.".into();
}
self.events
.iter()
@@ -243,6 +408,53 @@ impl WorkoutState {
}
}
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'], " ")
}
@@ -258,9 +470,14 @@ impl WorkoutEvent {
} => {
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::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"),
}
}
@@ -276,6 +493,9 @@ impl WorkoutEvent {
"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))
}
@@ -283,6 +503,10 @@ impl WorkoutEvent {
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}"),
}
}
@@ -296,6 +520,10 @@ impl WorkoutEvent {
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()?,
@@ -306,6 +534,10 @@ impl WorkoutEvent {
["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,
}
}
@@ -314,7 +546,10 @@ impl WorkoutEvent {
#[derive(Hemplate)]
pub struct Workout {
pub next_action: String,
pub primary_action: String,
pub status: String,
pub progress: String,
pub recovery_status: String,
pub event_log: String,
pub export_payload: String,
pub host_status: String,
@@ -334,15 +569,23 @@ pub fn page(runtime_src: &'static str, state: &WorkoutState) -> Html {
}
pub fn view(state: &WorkoutState) -> Workout {
let export = state.export_event_log();
Workout {
next_action: state.projection.next_action.clone(),
status: format!(
"{} local commands · {} domain events · projection is the UI source",
state.commands.len(),
state.events.len()
),
primary_action: state.projection.primary_action.clone(),
status: state.projection.progress.clone(),
progress: state.projection.progress.clone(),
recovery_status: state
.activity
.front()
.map(|last| format!("Last action: {last}. Undo is available."))
.unwrap_or_else(|| "Undo appears here after your first session event.".into()),
event_log: state.event_log_text(),
export_payload: state.export_event_log(),
export_payload: if export.is_empty() {
"Complete a set to create a replayable export.".into()
} else {
export
},
host_status: state.host_status.clone(),
}
}
@@ -370,31 +613,47 @@ pub fn interactions(state: AppState) -> impl hemx_axum::DispatchRegistry {
.on(ui::workout::change_weight, change_weight_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::request_native_haptic, request_native_haptic)
.on_state(ui::workout::record_native_haptic_ack, record_native_haptic_ack)
.on_state(
ui::workout::record_native_haptic_ack,
record_native_haptic_ack,
)
.into_registry()
}
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(&state.projection.next_action),
ui::workout::next_action.text(&view.next_action),
ui::workout::primary_action.text(&view.primary_action),
ui::workout::status.text(status.into()),
ui::workout::event_log.text(state.event_log_text()),
ui::workout::export_payload.text(state.export_event_log()),
ui::workout::host_status.text(&state.host_status),
ui::workout::progress.text(&view.progress),
ui::workout::recovery_status.text(&view.recovery_status),
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 event = state.accept(WorkoutCommand::CompleteSet);
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(|| "No set available".into(), |event| event.summary()),
event.map_or_else(
|| "Session is already saved; export is ready.".into(),
|event| event.summary(),
),
)
})
}
@@ -404,7 +663,10 @@ pub fn change_weight(app: AppState, kg: f32) -> impl IntoEffect {
let event = state.accept(WorkoutCommand::ChangeWeight { kg });
effects(
state,
event.map_or_else(|| "No exercise available".into(), |event| event.summary()),
event.map_or_else(
|| "Enter a valid weight for the current or next exercise.".into(),
|event| event.summary(),
),
)
})
}
@@ -414,7 +676,23 @@ pub fn skip_exercise(app: AppState) -> impl IntoEffect {
let event = state.accept(WorkoutCommand::SkipExercise);
effects(
state,
event.map_or_else(|| "No exercise available".into(), |event| event.summary()),
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}"),
),
)
})
}
@@ -432,7 +710,10 @@ pub fn record_note(app: AppState, text: impl Into<String>) -> impl IntoEffect {
let event = state.accept(WorkoutCommand::RecordNote { text: text.into() });
effects(
state,
event.map_or_else(|| "Ignored empty note".into(), |event| event.summary()),
event.map_or_else(
|| "Ignored empty note; nothing changed.".into(),
|event| event.summary(),
),
)
})
}
@@ -449,19 +730,23 @@ pub fn replay_export(app: AppState) -> impl IntoEffect {
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.event_log_text()),
payload: HostShareData::text(state.export_event_log()),
};
state.host_status = match manifest.validate_call(&browser_pwa_host_profile(), &call) {
Ok(()) => "Export requested through host share; waiting for host result.".into(),
Err(error) => format!("Host export unavailable: {error}"),
Ok(()) => "Share sheet requested; your local event log remains the source.".into(),
Err(error) => format!("Share unavailable: {error}. Export text stays below."),
};
effects(state, "Export request checked against host manifest")
effects(state, "Share export prepared")
})
}
@@ -475,7 +760,7 @@ pub fn record_share_result(app: AppState) -> impl IntoEffect {
completed: true,
},
);
effects(state, "Browser/PWA host result accepted by app code")
effects(state, "Share result returned through app code")
})
}
@@ -494,9 +779,7 @@ pub fn request_native_haptic(app: AppState) -> impl IntoEffect {
&native_shell_host_profile("ios-android-webview-workout"),
&call,
) {
Ok(()) => {
"Native-shell haptic request accepted; waiting for host acknowledgment.".into()
}
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")
@@ -522,17 +805,16 @@ fn apply_host_event(state: &mut WorkoutState, event: HostEvent) {
completed: true, ..
} => {
state.projection.exported = true;
state.host_status =
"Host share completed; event log remains replayable local truth.".into();
state.host_status = "Shared. The replayable event log remains local truth.".into();
}
HostEvent::ShareCompleted {
completed: false, ..
} => {
state.host_status = "Host share cancelled; local event log unchanged.".into();
state.host_status = "Share cancelled; local event log unchanged.".into();
}
HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => {
state.host_status =
"Native-shell haptic acknowledged; workout state stayed app-owned.".into();
"Native haptic acknowledged; workout state stayed app-owned.".into();
}
_ => {
state.host_status = "Host event ignored by app policy.".into();
@@ -562,38 +844,90 @@ mod tests {
app.with_workout(|state| (
state.commands.len(),
state.events.len(),
state.projection.completed_sets_for_current
state.projection.completed_sets_for_current,
state.projection.phase.clone(),
)),
(1, 1, 1)
(1, 1, 1, WorkoutPhase::Resting)
);
assert!(contains_payload_text(
&effects,
"completed Goblet squat set 1"
));
assert!(contains_payload_text(
&effects,
"Next: Goblet squat set 2/3"
));
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_eq!(
app.with_workout(|state| state.projection.phase.clone()),
WorkoutPhase::Finished
);
}
#[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 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 2 exported workout events"
"Replayed 3 exported workout events"
));
assert_eq!(
app.with_workout(|state| {
@@ -601,9 +935,10 @@ mod tests {
state.events.len(),
state.projection.completed_sets_for_current,
state.plan[0].kg,
state.projection.phase.clone(),
)
}),
(2, 1, 28.0)
(3, 1, 28.0, WorkoutPhase::Ready)
);
}
@@ -614,21 +949,18 @@ mod tests {
run(|()| complete_set(app.clone()), ());
let export = run(|()| export_log(app.clone()), ());
assert!(contains_payload_text(
&export,
"Export request checked against host manifest"
));
assert!(contains_payload_text(&export, "waiting for host result"));
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,
"Browser/PWA host result accepted by app code"
"Share result returned through app code"
));
assert!(contains_payload_text(
&shared,
"event log remains replayable local truth"
"replayable event log remains local truth"
));
}
@@ -644,7 +976,7 @@ mod tests {
));
assert!(contains_payload_text(
&request,
"waiting for host acknowledgment"
"Native-shell haptic requested"
));
let ack = run(|()| record_native_haptic_ack(app.clone()), ());
@@ -664,8 +996,9 @@ mod tests {
let html = render(&WorkoutState::demo()).to_string();
assert!(html.contains("Now-first Workout Copilot"));
assert!(html.contains("Complete set"));
assert!(html.contains("Undo last action"));
assert!(html.contains("Share export"));
assert!(html.contains("Haptic"));
assert!(html.contains("Host proof panel"));
assert!(!html.contains(&format!("<{}", "script")));
assert!(!html.contains("querySelector"));
}