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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user