diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs index f9133cb..1a25e07 100644 --- a/examples/workout/src/lib.rs +++ b/examples/workout/src/lib.rs @@ -212,6 +212,38 @@ impl WorkoutState { .collect::>() .join("\n") } + + pub fn export_event_log(&self) -> String { + // req: local/003 + self.events + .iter() + .map(WorkoutEvent::to_export_line) + .collect::>() + .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::>(); + 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 { + // req: local/003 + let fields = line.split('\t').collect::>(); + 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) -> 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) -> 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 diff --git a/examples/workout/src/main.rs b/examples/workout/src/main.rs index e4cd86e..1572ed7 100644 --- a/examples/workout/src/main.rs +++ b/examples/workout/src/main.rs @@ -22,7 +22,7 @@ async fn main() { axum::serve(listener, app).await.unwrap(); } -fn app(state: AppState) -> Router { +pub fn app(state: AppState) -> Router { Router::new() .route("/", get(page).post(interact)) .route(runtime_js_path(), get(runtime)) @@ -30,7 +30,9 @@ fn app(state: AppState) -> Router { } async fn page(State(state): State) -> impl IntoResponse { - axum::response::Html(state.with_workout(|workout| workout_app::render(workout).into_string())) + axum::response::Html( + state.with_workout(|workout| workout_app::page(runtime_js_path(), workout).into_string()), + ) } async fn interact( @@ -63,6 +65,10 @@ async fn interact( ) } }) + .on(workout::replay_export, { + let state = state.clone(); + move |_| workout_app::replay_export(&state) + }) .on(workout::export_log, { let state = state.clone(); move |_| workout_app::export_log(&state) diff --git a/examples/workout/templates/app_shell.heml b/examples/workout/templates/app_shell.heml new file mode 100644 index 0000000..cbcc637 --- /dev/null +++ b/examples/workout/templates/app_shell.heml @@ -0,0 +1,10 @@ + + + + + + hemx workout example + + +{+= self.body =+} + diff --git a/examples/workout/templates/workout.heml b/examples/workout/templates/workout.heml index e9e98d2..2afb8c8 100644 --- a/examples/workout/templates/workout.heml +++ b/examples/workout/templates/workout.heml @@ -21,6 +21,9 @@

Private event log

{+ self.event_log +}
+

Replay export

+
{+ self.export_payload +}
+
diff --git a/examples/workout/tests/e2e.rs b/examples/workout/tests/e2e.rs new file mode 100644 index 0000000..3816167 --- /dev/null +++ b/examples/workout/tests/e2e.rs @@ -0,0 +1,188 @@ +use hemx_axum::runtime_js_path; +use hemx_test::{inspect_wire, EffectInspector}; +use hemx_workout_example::ui::BUILD_FINGERPRINT; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +struct Server { + child: Child, + addr: String, +} + +impl Server { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port"); + let addr = listener.local_addr().unwrap().to_string(); + drop(listener); + + let bin = env!("CARGO_BIN_EXE_hemx-workout-example"); + let child = Command::new(bin) + .env("HEMX_WORKOUT_ADDR", &addr) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("start hemx-workout-example"); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if TcpStream::connect(&addr).is_ok() { + return Self { child, addr }; + } + std::thread::sleep(Duration::from_millis(25)); + } + panic!("hemx-workout-example did not listen on {addr}"); + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn workout_is_e2e_working_over_http() { + // req: examples/001 req: local/001 req: local/003 req: local/004 req: host/001 + let server = Server::start(); + + let home = get(&server, "/"); + assert_eq!(home.status, 200); + assert!(home.header("content-type").contains("text/html")); + assert!(home.text().contains("Now-first Workout Copilot")); + assert!(home.text().contains("Private event log")); + assert!(home.text().contains("Replay export")); + assert!(home + .text() + .contains(&format!("