diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs index 2d10856..95f830a 100644 --- a/examples/workout/src/lib.rs +++ b/examples/workout/src/lib.rs @@ -1104,6 +1104,33 @@ mod tests { ); } + #[test] + fn double_primary_action_recovers_through_undo() { + // req: examples/001 req: local/001 req: local/004 + let app = AppState::demo(); + run(|()| complete_set(app.clone()), ()); + let double_action = run(|()| complete_set(app.clone()), ()); + assert!(contains_payload_text( + &double_action, + "started Goblet squat set 2" + )); + assert_eq!( + app.with_workout(|state| (state.events.len(), state.projection.phase.clone())), + (2, WorkoutPhase::Ready) + ); + + let undone = run(|()| undo_last_action(app.clone()), ()); + assert!(contains_payload_text( + &undone, + "Undid: started Goblet squat set 2" + )); + assert!(contains_payload_text(&undone, "Rest 90s")); + assert_eq!( + app.with_workout(|state| (state.events.len(), state.projection.phase.clone())), + (1, WorkoutPhase::Resting) + ); + } + #[test] fn correct_last_set_is_replayable_and_undoable() { // req: examples/001 req: local/001 req: local/003 req: local/004 diff --git a/examples/workout/templates/workout.css b/examples/workout/templates/workout.css index 3149945..40fedf6 100644 --- a/examples/workout/templates/workout.css +++ b/examples/workout/templates/workout.css @@ -46,6 +46,9 @@ font-weight: 800; letter-spacing: -0.01em; } + button:focus, + input:focus, + summary:focus, button:focus-visible, input:focus-visible, summary:focus-visible { diff --git a/examples/workout/tests/browser_e2e.rs b/examples/workout/tests/browser_e2e.rs index c946cae..913ed98 100644 --- a/examples/workout/tests/browser_e2e.rs +++ b/examples/workout/tests/browser_e2e.rs @@ -58,20 +58,21 @@ async fn browser_proves_phone_first_workout_flow() -> WebDriverResult<()> { wait_for_body_text(&driver, "Rest 90s").await?; wait_for_body_text(&driver, "Start Goblet squat set 2").await?; wait_for_body_text(&driver, "Undo is available").await?; + assert_viewport(&driver, "phone after rest", true).await?; driver - .find(By::XPath("//button[contains(., 'Skip exercise')]")) + .find(By::Css(".primary-action")) .await? .click() .await?; - wait_for_body_text(&driver, "skipped Goblet squat").await?; + wait_for_body_text(&driver, "started Goblet squat set 2").await?; driver .find(By::XPath("//button[contains(., 'Undo last action')]")) .await? .click() .await?; - wait_for_body_text(&driver, "Undid: skipped Goblet squat").await?; + wait_for_body_text(&driver, "Undid: started Goblet squat set 2").await?; let host_panel = driver.find(By::Css(".host-proof")).await?; assert!(!host_panel.attr("open").await?.is_some()); @@ -147,14 +148,40 @@ async fn assert_viewport( const host = document.querySelector('.host-proof'); const appRect = app.getBoundingClientRect(); const h1Rect = h1.getBoundingClientRect(); + const primaryRect = primary.getBoundingClientRect(); const primaryStyle = getComputedStyle(primary); const gridColumns = getComputedStyle(app).gridTemplateColumns.split(' ').length; + const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + const labels = [...document.querySelectorAll('button,input,summary')] + .filter(visible) + .map((el) => (el.textContent || el.getAttribute('aria-label') || '').trim()); + const primaryIndex = labels.findIndex((label) => label.length > 0 && label === primary.textContent.trim()); + const undoIndex = labels.findIndex((label) => label.includes('Undo last action')); + const hostIndex = labels.findIndex((label) => label.includes('Host proof panel')); + primary.focus(); + const focusedStyle = getComputedStyle(primary); + const focusRingOk = document.activeElement === primary && + focusedStyle.outlineStyle !== 'none' && + parseFloat(focusedStyle.outlineWidth) >= 2; + const zeroDurations = (value) => value.split(',').every((part) => { + const trimmed = part.trim(); + return trimmed === '0s' || trimmed === '0ms'; + }); + const noMotion = [...document.querySelectorAll('*')].every((el) => { + const style = getComputedStyle(el); + return zeroDurations(style.transitionDuration) && zeroDurations(style.animationDuration); + }); return document.documentElement.scrollWidth <= window.innerWidth && h1.textContent.trim().length > 0 && primary.textContent.trim().length > 0 && - h1Rect.top < primary.getBoundingClientRect().top && - primary.getBoundingClientRect().height >= 48 && + h1Rect.top < primaryRect.top && + primaryRect.height >= 48 && primaryStyle.borderRadius !== '0px' && + focusRingOk && + noMotion && + primaryIndex === 0 && + undoIndex > primaryIndex && + hostIndex > undoIndex && appRect.width <= window.innerWidth && host.open === false && (arguments[0] ? gridColumns === 1 : gridColumns >= 2);