feat(examples): add workout replay e2e

Wrap the workout page with the shared runtime, add export replay over command/event truth, and cover the running HTTP app with an end-to-end test that exercises runtime asset loading and product interactions.

req: examples/001

req: local/001

req: local/003

req: local/004

req: host/001
This commit is contained in:
slhx agent
2026-06-11 21:02:50 +02:00
parent 18095ea742
commit d5b74496ab
5 changed files with 339 additions and 2 deletions
+130
View File
@@ -212,6 +212,38 @@ impl WorkoutState {
.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) -> usize {
// req: local/001 req: local/003 req: local/004
let events = export
.lines()
.filter_map(WorkoutEvent::from_export_line)
.collect::<Vec<_>>();
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;
events.len()
}
}
fn export_field(value: &str) -> String {
value.replace(['\t', '\n', '\r'], " ")
}
impl WorkoutEvent {
@@ -230,6 +262,52 @@ impl WorkoutEvent {
Self::NoteRecorded { text } => format!("note: {text}"),
}
}
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::WeightChanged { exercise, kg } => {
format!("weight_changed\t{}\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)),
}
}
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()?,
}),
["weight_changed", exercise, kg] => Some(Self::WeightChanged {
exercise: (*exercise).into(),
kg: kg.parse().ok()?,
}),
["exercise_skipped", exercise] => Some(Self::ExerciseSkipped {
exercise: (*exercise).into(),
}),
["note_recorded", text] => Some(Self::NoteRecorded {
text: (*text).into(),
}),
_ => None,
}
}
}
#[derive(Hemplate)]
@@ -237,9 +315,23 @@ pub struct Workout {
pub next_action: String,
pub status: 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::render(&AppShell {
runtime_src,
body: render(state),
})
}
pub fn view(state: &WorkoutState) -> Workout {
Workout {
next_action: state.projection.next_action.clone(),
@@ -249,6 +341,7 @@ pub fn view(state: &WorkoutState) -> Workout {
state.events.len()
),
event_log: state.event_log_text(),
export_payload: state.export_event_log(),
host_status: state.host_status.clone(),
}
}
@@ -263,6 +356,7 @@ fn effects(state: &WorkoutState, status: impl Into<String>) -> impl IntoEffect {
ui::workout::next_action.text(&state.projection.next_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),
)
}
@@ -307,6 +401,15 @@ pub fn record_note(app: &AppState, text: impl Into<String>) -> impl IntoEffect {
})
}
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();
let count = state.replay_export(&export);
effects(state, format!("Replayed {count} exported workout events"))
})
}
pub fn export_log(app: &AppState) -> impl IntoEffect {
app.update(|state| {
// req: host/001 req: host/004 req: local/003
@@ -441,6 +544,33 @@ mod tests {
);
}
#[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), ());
run(|()| change_weight(&app, 28.0), ());
let export = app.with_workout(WorkoutState::export_event_log);
assert!(export.contains("set_completed\tGoblet squat\t1\t8\t24"));
assert!(export.contains("weight_changed\tGoblet squat\t28"));
let replay = run(|()| replay_export(&app), ());
assert!(contains_payload_text(
&replay,
"Replayed 2 exported workout events"
));
assert_eq!(
app.with_workout(|state| {
(
state.events.len(),
state.projection.completed_sets_for_current,
state.plan[0].kg,
)
}),
(2, 1, 28.0)
);
}
#[test]
fn host_export_result_returns_through_app_code() {
// req: host/001 req: host/002 req: host/005 req: local/003