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<_>>() .collect::<Vec<_>>()
.join("\n") .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 { impl WorkoutEvent {
@@ -230,6 +262,52 @@ impl WorkoutEvent {
Self::NoteRecorded { text } => format!("note: {text}"), 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)] #[derive(Hemplate)]
@@ -237,9 +315,23 @@ pub struct Workout {
pub next_action: String, pub next_action: String,
pub status: String, pub status: String,
pub event_log: String, pub event_log: String,
pub export_payload: String,
pub host_status: 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 { pub fn view(state: &WorkoutState) -> Workout {
Workout { Workout {
next_action: state.projection.next_action.clone(), next_action: state.projection.next_action.clone(),
@@ -249,6 +341,7 @@ pub fn view(state: &WorkoutState) -> Workout {
state.events.len() state.events.len()
), ),
event_log: state.event_log_text(), event_log: state.event_log_text(),
export_payload: state.export_event_log(),
host_status: state.host_status.clone(), 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::next_action.text(&state.projection.next_action),
ui::workout::status.text(status.into()), ui::workout::status.text(status.into()),
ui::workout::event_log.text(state.event_log_text()), 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::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 { pub fn export_log(app: &AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
// req: host/001 req: host/004 req: local/003 // 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] #[test]
fn host_export_result_returns_through_app_code() { fn host_export_result_returns_through_app_code() {
// req: host/001 req: host/002 req: host/005 req: local/003 // req: host/001 req: host/002 req: host/005 req: local/003
+8 -2
View File
@@ -22,7 +22,7 @@ async fn main() {
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }
fn app(state: AppState) -> Router { pub fn app(state: AppState) -> Router {
Router::new() Router::new()
.route("/", get(page).post(interact)) .route("/", get(page).post(interact))
.route(runtime_js_path(), get(runtime)) .route(runtime_js_path(), get(runtime))
@@ -30,7 +30,9 @@ fn app(state: AppState) -> Router {
} }
async fn page(State(state): State<AppState>) -> impl IntoResponse { async fn page(State(state): State<AppState>) -> 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( 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, { .on(workout::export_log, {
let state = state.clone(); let state = state.clone();
move |_| workout_app::export_log(&state) move |_| workout_app::export_log(&state)
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hemx workout example</title>
<script +src="self.runtime_src" defer></script>
</head>
<body>{+= self.body =+}</body>
</html>
+3
View File
@@ -21,6 +21,9 @@
<section class="local-truth"> <section class="local-truth">
<h2>Private event log</h2> <h2>Private event log</h2>
<pre data-hemx-slot="event_log">{+ self.event_log +}</pre> <pre data-hemx-slot="event_log">{+ self.event_log +}</pre>
<h3>Replay export</h3>
<pre data-hemx-slot="export_payload">{+ self.export_payload +}</pre>
<button type="button" data-hemx-handle="replay_export">Replay exported log</button>
</section> </section>
<section class="host"> <section class="host">
+188
View File
@@ -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!("<script src=\"{}\" defer", runtime_js_path())));
let runtime = get(&server, runtime_js_path());
assert_eq!(runtime.status, 200);
assert!(runtime.header("content-type").contains("javascript"));
assert!(!runtime.text().is_empty());
let completed = post(
&server,
"/",
&handle_body_from_html(&home.text(), "Complete set"),
);
assert_effect_response(&completed);
let completed_batch = completed.effects();
assert_payload_contains(&completed_batch, "completed Goblet squat set 1");
assert_payload_contains(&completed_batch, "Next: Goblet squat set 2/3");
let replayed = post(
&server,
"/",
&handle_body_from_html(&home.text(), "Replay exported log"),
);
assert_effect_response(&replayed);
let replayed_batch = replayed.effects();
assert_payload_contains(&replayed_batch, "Replayed 1 exported workout events");
assert_payload_contains(
&replayed_batch,
"Replayed 1 exported events into a fresh projection",
);
}
fn handle_body_from_html(html: &str, label: &str) -> String {
format!("__h={}", handle_id_from_html(html, label))
}
fn handle_id_from_html(html: &str, label: &str) -> String {
let label_at = html.find(label).expect("label in html");
let prefix = &html[..label_at];
let hid_at = prefix.rfind("data-hid=\"").expect("handle before label") + "data-hid=\"".len();
let end = prefix[hid_at..].find('"').expect("handle quote");
prefix[hid_at..hid_at + end].to_owned()
}
fn assert_effect_response(response: &Response) {
assert_eq!(response.status, 200, "response: {response:?}");
assert!(response.header("content-type").contains("application/hemx"));
assert_eq!(
response.header("x-hemx-fingerprint"),
BUILD_FINGERPRINT.0.to_string()
);
assert!(!response.effects().is_empty());
}
fn assert_payload_contains(batch: &EffectInspector, needle: &str) {
assert!(
batch.payload_contains(needle),
"missing payload {needle:?} in {batch:#?}"
);
}
fn get(server: &Server, path: &str) -> Response {
request(server, "GET", path, "")
}
fn post(server: &Server, path: &str, body: &str) -> Response {
request(server, "POST", path, body)
}
fn request(server: &Server, method: &str, path: &str, body: &str) -> Response {
let mut stream = TcpStream::connect(&server.addr).expect("connect workout example");
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\n\r\n{body}",
server.addr,
body.len()
);
stream.write_all(request.as_bytes()).unwrap();
let mut raw = Vec::new();
stream.read_to_end(&mut raw).unwrap();
Response::parse(raw)
}
#[derive(Debug)]
struct Response {
status: u16,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl Response {
fn parse(raw: Vec<u8>) -> Self {
let split = raw
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("http response");
let head = String::from_utf8(raw[..split].to_vec()).expect("utf8 headers");
let body = raw[(split + 4)..].to_vec();
let mut lines = head.lines();
let status = lines
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|status| status.parse().ok())
.expect("status code");
let headers = lines
.filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_owned()))
.collect();
Self {
status,
headers,
body,
}
}
fn header(&self, name: &str) -> String {
let name = name.to_ascii_lowercase();
self.headers
.iter()
.find_map(|(key, value)| (key == &name).then(|| value.clone()))
.unwrap_or_default()
}
fn text(&self) -> String {
String::from_utf8(self.body.clone()).expect("utf8 body")
}
fn effects(&self) -> EffectInspector {
inspect_wire(&self.body)
}
}