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<_>>()
|
||||
.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
|
||||
|
||||
@@ -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<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(
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user