From fffdfd2721fed5171d4dd9780f893b9bb323ab8a Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 21:17:51 +0900 Subject: [PATCH] test: assert panel rows-ready fixture data --- crates/tui/src/multi_pod.rs | 4 + tests/e2e/src/lib.rs | 166 +++++++++++++++++++++++++++++++++++- tests/e2e/tests/panel.rs | 101 +++++++++++++++------- 3 files changed, 236 insertions(+), 35 deletions(-) diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 0d0926ef..259d09ab 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -151,9 +151,13 @@ pub(crate) async fn run( #[cfg(feature = "e2e-test")] { if !emitted_panel_ready { + // `panel_ready` is a first-visible-frame signal only. E2E tests that need + // list/data readiness must wait for a concrete `rows_rendered` fixture row. crate::e2e_observer::emit("panel", "panel_ready", serde_json::json!({})); emitted_panel_ready = true; } + // Emit every drawn row snapshot separately so tests can assert data-backed row + // readiness without conflating it with the first frame. app.emit_rows_rendered(); } diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index 9d469df9..e551e9f1 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -267,12 +267,78 @@ pub struct RenderedPanelRow { pub rect: PanelRect, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExpectedPanelTicketRow { + pub id: String, + pub title: String, + pub status: String, +} + +impl ExpectedPanelTicketRow { + pub fn new(id: impl Into, title: impl Into, status: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + status: status.into(), + } + } + + pub fn matches(&self, row: &RenderedPanelRow) -> bool { + row.key.kind == "ticket" + && row.key.id == self.id + && row.title == self.title + && row.status.as_deref() == Some(self.status.as_str()) + } + + fn description(&self) -> String { + format!( + "ticket row id={} title={:?} status={}", + self.id, self.title, self.status + ) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RowsRendered { pub selected: Option, pub rows: Vec, } +impl RowsRendered { + pub fn fixture_ticket_row( + &self, + expected: &ExpectedPanelTicketRow, + ) -> Option<&RenderedPanelRow> { + self.rows.iter().find(|row| expected.matches(row)) + } + + pub fn has_fixture_ticket_row(&self, expected: &ExpectedPanelTicketRow) -> bool { + self.fixture_ticket_row(expected).is_some() + } +} + +fn rows_rendered_event_has_fixture_ticket( + event: &HarnessEvent, + expected: &ExpectedPanelTicketRow, +) -> bool { + serde_json::from_value::(event.data.clone()) + .map(|rows| rows.has_fixture_ticket_row(expected)) + .unwrap_or(false) +} + +fn describe_rows(rows: &RowsRendered) -> String { + rows.rows + .iter() + .map(|row| { + format!( + "{}:{} title={:?} status={:?}", + row.key.kind, row.key.id, row.title, row.status + ) + }) + .collect::>() + .join(", ") +} + #[derive(Debug, Clone)] pub enum KeyPress { CtrlC, @@ -445,6 +511,69 @@ impl PanelHarness { } } + /// Waits for the legacy `panel_ready` observer event, which means the first + /// panel frame was painted. It intentionally does not mean workspace rows or + /// Ticket data are ready. + pub fn wait_for_first_visible_frame(&mut self, timeout: Duration) -> Result { + self.wait_for( + "first visible panel frame (panel_ready, before rows readiness)", + timeout, + |event| event.event == "panel_ready", + ) + } + + /// Waits until a concrete fixture Ticket row has rendered. This is the + /// startup rows-ready signal; it validates id + title + state rather than + /// using only the number of rendered rows. + pub fn wait_for_fixture_ticket_rows_ready( + &mut self, + expected: &ExpectedPanelTicketRow, + timeout: Duration, + ) -> Result { + let description = expected.description(); + let event = self.wait_for( + format!("fixture Ticket rows ready ({description})"), + timeout, + |event| { + event.event == "rows_rendered" + && rows_rendered_event_has_fixture_ticket(event, expected) + }, + )?; + serde_json::from_value(event.data).map_err(HarnessError::from) + } + + pub fn assert_fixture_ticket_row_not_rendered( + &mut self, + expected: &ExpectedPanelTicketRow, + duration: Duration, + ) -> Result<()> { + let start = Instant::now(); + while start.elapsed() < duration { + if let Some(rows) = self + .events()? + .iter() + .filter(|event| event.event == "rows_rendered") + .filter_map(|event| serde_json::from_value::(event.data.clone()).ok()) + .find(|rows| rows.has_fixture_ticket_row(expected)) + { + self.flush_output_artifact()?; + return Err(HarnessError::Protocol(format!( + "fixture Ticket row rendered before data-backed rows readiness was expected: {}; rows: {}", + expected.description(), + describe_rows(&rows) + ))); + } + if let Some(status) = self.child.try_wait()? { + self.flush_output_artifact()?; + return Err(HarnessError::Protocol(format!( + "process exited with {status} while asserting fixture Ticket rows stayed delayed" + ))); + } + thread::sleep(Duration::from_millis(20)); + } + Ok(()) + } + pub fn wait_for_rows(&mut self, min_rows: usize) -> Result { let event = self.wait_for("rows_rendered", DEFAULT_WAIT, |event| { event.event == "rows_rendered" @@ -781,6 +910,9 @@ pub struct FixtureCleanupReport { pub report_path: PathBuf, } +pub const READY_FIXTURE_TICKET_TITLE: &str = "Ready E2E Ticket"; +pub const PLANNING_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket"; + #[derive(Debug)] pub struct FixtureWorkspace { temp_root: Option, @@ -792,6 +924,8 @@ pub struct FixtureWorkspace { pub xdg_config_home: PathBuf, pub xdg_runtime_dir: PathBuf, pub artifacts_dir: PathBuf, + pub ready_ticket_id: String, + pub planning_ticket_id: String, } impl FixtureWorkspace { @@ -832,7 +966,7 @@ impl FixtureWorkspace { fs::create_dir_all(dir)?; } - let fixture = Self { + let mut fixture = Self { temp_root: Some(temp_root), root, workspace, @@ -842,6 +976,8 @@ impl FixtureWorkspace { xdg_config_home, xdg_runtime_dir, artifacts_dir, + ready_ticket_id: String::new(), + planning_ticket_id: String::new(), }; fixture.write_fixture_metadata("created", None)?; @@ -867,7 +1003,7 @@ impl FixtureWorkspace { &fixture.xdg_config_home, &fixture.xdg_runtime_dir, &fixture.artifacts_dir, - "Ready E2E Ticket", + READY_FIXTURE_TICKET_TITLE, )?; run_yoi( binary, @@ -880,7 +1016,7 @@ impl FixtureWorkspace { &fixture.artifacts_dir, &["ticket", "state", &first, "ready"], )?; - let _second = create_ticket( + let second = create_ticket( binary, &fixture.workspace, &fixture.home, @@ -889,12 +1025,22 @@ impl FixtureWorkspace { &fixture.xdg_config_home, &fixture.xdg_runtime_dir, &fixture.artifacts_dir, - "Planning E2E Ticket", + PLANNING_FIXTURE_TICKET_TITLE, )?; + fixture.ready_ticket_id = first; + fixture.planning_ticket_id = second; fixture.write_fixture_metadata("ready", None)?; Ok(fixture) } + pub fn ready_fixture_ticket_row(&self) -> ExpectedPanelTicketRow { + ExpectedPanelTicketRow::new( + self.ready_ticket_id.clone(), + READY_FIXTURE_TICKET_TITLE, + "ready", + ) + } + pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig { PanelHarnessConfig { binary, @@ -999,6 +1145,18 @@ impl FixtureWorkspace { "xdg_config_home": &self.xdg_config_home, "xdg_runtime_dir": &self.xdg_runtime_dir, "artifacts_dir": &self.artifacts_dir, + "tickets": { + "ready": { + "id": &self.ready_ticket_id, + "title": READY_FIXTURE_TICKET_TITLE, + "state": "ready" + }, + "planning": { + "id": &self.planning_ticket_id, + "title": PLANNING_FIXTURE_TICKET_TITLE, + "state": "planning" + } + }, "env_runtime_policy": { "tested_yoi_uses_env_clear": true, "host_runtime_inherited": false, diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 7990e9f6..09845e11 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -1,17 +1,62 @@ use std::time::{Duration, Instant}; const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500); -const FULL_READY_BUDGET: Duration = Duration::from_secs(5); +const ROWS_READY_BUDGET: Duration = Duration::from_secs(5); use yoi_e2e::{ - FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, RenderedPanelRow, yoi_binary, + ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, + PanelRect, PanelRowKey, RenderedPanelRow, RowsRendered, yoi_binary, }; +#[test] +fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { + let expected = ExpectedPanelTicketRow::new("0000000000000", "Ready E2E Ticket", "ready"); + let wrong_title = RenderedPanelRow { + key: PanelRowKey { + kind: "ticket".to_string(), + id: "0000000000000".to_string(), + }, + title: "Different Ticket".to_string(), + status: Some("ready".to_string()), + action: None, + rect: PanelRect { + x: 0, + y: 0, + width: 10, + height: 1, + }, + }; + let wrong_kind = RenderedPanelRow { + key: PanelRowKey { + kind: "pod".to_string(), + id: "0000000000000".to_string(), + }, + title: "Ready E2E Ticket".to_string(), + status: Some("ready".to_string()), + action: None, + rect: PanelRect { + x: 0, + y: 0, + width: 10, + height: 1, + }, + }; + + assert!(!expected.matches(&wrong_title)); + assert!(!expected.matches(&wrong_kind)); + let rows = RowsRendered { + selected: None, + rows: vec![wrong_title, wrong_kind], + }; + assert!(!rows.has_fixture_ticket_row(&expected)); +} + #[test] fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> { let binary = yoi_binary()?; let fixture = FixtureWorkspace::new(&binary)?; assert_fixture_paths_are_isolated(&fixture); + let ready_ticket = fixture.ready_fixture_ticket_row(); let started = Instant::now(); let mut panel = @@ -19,17 +64,15 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res let remaining = FIRST_VISIBLE_RENDER_BUDGET .checked_sub(started.elapsed()) .unwrap_or_else(|| Duration::from_millis(0)); - panel.wait_for("first visible panel render", remaining, |event| { - event.event == "panel_ready" - })?; + panel.wait_for_first_visible_frame(remaining)?; let first_visible_elapsed = started.elapsed(); eprintln!( - "panel first visible render: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", + "panel first visible frame: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", panel.artifacts().dir.display() ); assert!( first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET, - "first visible render took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", + "first visible frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", panel.artifacts().dir.display() ); @@ -42,7 +85,7 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res events[..ready_index] .iter() .all(|event| event.event != "background_task_started"), - "initial render must be emitted before reload/background work starts; artifacts at {}", + "initial frame must be emitted before reload/background work starts; artifacts at {}", panel.artifacts().dir.display() ); @@ -54,12 +97,13 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res event.event == "background_task_started" && event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload") }) - .expect("held reload should start after first visible render"); + .expect("held reload should start after first visible frame"); assert!( ready_index < reload_started_index, - "first visible render and reload ordering should remain separate; artifacts at {}", + "first visible frame and reload ordering should remain separate; artifacts at {}", panel.artifacts().dir.display() ); + panel.assert_fixture_ticket_row_not_rendered(&ready_ticket, Duration::from_millis(150))?; panel.press(KeyPress::CtrlC)?; let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; @@ -70,51 +114,46 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res } #[test] -fn panel_full_ready_has_separate_startup_budget() -> yoi_e2e::Result<()> { +fn panel_fixture_ticket_row_ready_has_startup_budget() -> yoi_e2e::Result<()> { let binary = yoi_binary()?; let fixture = FixtureWorkspace::new(&binary)?; assert_fixture_paths_are_isolated(&fixture); + let ready_ticket = fixture.ready_fixture_ticket_row(); let started = Instant::now(); let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET .checked_sub(started.elapsed()) .unwrap_or_else(|| Duration::from_millis(0)); - panel.wait_for( - "first visible panel render", - first_visible_remaining, - |event| event.event == "panel_ready", - )?; + panel.wait_for_first_visible_frame(first_visible_remaining)?; let first_visible_elapsed = started.elapsed(); eprintln!( - "panel first visible render: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", + "panel first visible frame: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", panel.artifacts().dir.display() ); assert!( first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET, - "first visible render took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", + "first visible frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", panel.artifacts().dir.display() ); - let full_ready_remaining = FULL_READY_BUDGET + let rows_ready_remaining = ROWS_READY_BUDGET .checked_sub(started.elapsed()) .unwrap_or_else(|| Duration::from_millis(0)); - panel.wait_for("full ready fixture rows", full_ready_remaining, |event| { - event.event == "rows_rendered" - && event - .data - .get("rows") - .and_then(serde_json::Value::as_array) - .is_some_and(|rows| rows.len() >= 2) - })?; - let full_ready_elapsed = started.elapsed(); + let rows = panel.wait_for_fixture_ticket_rows_ready(&ready_ticket, rows_ready_remaining)?; + assert!( + rows.has_fixture_ticket_row(&ready_ticket), + "rows-ready event must contain concrete ready fixture Ticket row; artifacts at {}", + panel.artifacts().dir.display() + ); + let rows_ready_elapsed = started.elapsed(); eprintln!( - "panel full ready: {full_ready_elapsed:?} (budget {FULL_READY_BUDGET:?}); artifacts at {}", + "panel fixture rows ready: {rows_ready_elapsed:?} (budget {ROWS_READY_BUDGET:?}); artifacts at {}", panel.artifacts().dir.display() ); assert!( - full_ready_elapsed <= FULL_READY_BUDGET, - "full ready took {full_ready_elapsed:?}, budget {FULL_READY_BUDGET:?}; artifacts at {}", + rows_ready_elapsed <= ROWS_READY_BUDGET, + "fixture rows ready took {rows_ready_elapsed:?}, budget {ROWS_READY_BUDGET:?}; artifacts at {}", panel.artifacts().dir.display() );