From 90b687e706c0b4a6eb4b2ec5969f05840fceeed0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 20 Sep 2026 06:17:03 +0000 Subject: [PATCH] chore: sync public mirror from internal --- .repository-projection.json | 6 +- .../tui-rs/examples/onboarding-preview.rs | 38 +- packages/ui-preview-rs/README.md | 60 +- packages/ui-preview-rs/src/authoring.rs | 778 +++++++++++++++++- packages/ui-preview-rs/src/lib.rs | 1 + packages/ui-preview-rs/src/review.html | 620 ++++++++++++-- packages/ui-preview-rs/src/review.rs | 54 +- scripts/dev/test-ui-workbench.py | 78 ++ scripts/dev/ui-workbench.py | 91 +- 9 files changed, 1590 insertions(+), 136 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index c951932b1..d08342415 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "582b01208b58857ec89ea02e0a1330a84f0650f7", + "sourceSha": "03ccf8fea13e310f4d2fd6dd92736749692f3344", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "72a207b043c3f3167e8f44fd68143b17abe2fc3f", + "priorProjectedBase": "9c490dd81e4c467066295a5f88d01d2f05366a08", "definitionDigest": "064b6b9745d392dde277afd7dfdf028cff32b6178a8b9c99c518343219bab429", "toolDigest": "c8000985e1bd708bf1f8754b27cc1a2aad67af10ee23cf5317bd1fba670b4456", - "contentDigest": "0b2113cfef482d03f1d99af1630641768d0445721e8d46efdd31817cd8941614", + "contentDigest": "d54a206dc5baa9988e5cb4e69f4027b92b950195e9267c6562a98b15e9d198e4", "publicationEligible": true } diff --git a/packages/tui-rs/examples/onboarding-preview.rs b/packages/tui-rs/examples/onboarding-preview.rs index 52e1782ee..bbf88f852 100644 --- a/packages/tui-rs/examples/onboarding-preview.rs +++ b/packages/tui-rs/examples/onboarding-preview.rs @@ -10,6 +10,7 @@ use maestro_tui::{ }; use maestro_ui_preview::{ Scene, + authoring::{MenuRecipe, menu_recipe_fixtures, menu_studio}, review::{self, Capture}, }; @@ -178,6 +179,8 @@ fn run() -> Result<(), String> { "{}", serde_json::to_string(&serde_json::json!({ "fixtures": support::theme_selector_story::fixture_requests(), + "menu_starter": MenuRecipe::starter(), + "menu_fixtures": menu_recipe_fixtures(), "presets": support::theme_selector_story::presets() .into_iter() .zip([ @@ -196,6 +199,23 @@ fn run() -> Result<(), String> { ); return Ok(()); } + if args == ["--studio-stdin"] { + use std::io::Read; + let mut body = String::new(); + std::io::stdin() + .take(16_385) + .read_to_string(&mut body) + .map_err(|error| error.to_string())?; + if body.len() > 16_384 { + return Err("menu recipe exceeds 16384 bytes".into()); + } + let recipe: MenuRecipe = serde_json::from_str(&body).map_err(|error| error.to_string())?; + println!( + "{}", + serde_json::to_string(&menu_studio(recipe)?).map_err(|error| error.to_string())? + ); + return Ok(()); + } if args == ["--replay-stdin"] { use std::io::Read; let mut body = String::new(); @@ -215,7 +235,10 @@ fn run() -> Result<(), String> { return Ok(()); } if args.len() > 1 || args.first().is_some_and(|a| a != "--html" && a != "--json") { - return Err("usage: onboarding-preview [--html|--json|--sequences|--replay-stdin]".into()); + return Err( + "usage: onboarding-preview [--html|--json|--sequences|--replay-stdin|--studio-stdin]" + .into(), + ); } let captures = captures()?; let output = if args.first().is_some_and(|a| a == "--html") { @@ -260,4 +283,17 @@ mod tests { assert_ne!(text("onboarding-ready"), text("onboarding-failed")); assert!(text("onboarding-failed").contains("Model request failed")); } + + #[test] + fn menu_authoring_fixtures_render_through_the_shared_recipe() { + for recipe in menu_recipe_fixtures() { + let response = menu_studio(recipe).unwrap(); + assert!(!response.transcript.is_empty()); + assert!( + response + .effect_policy + .contains("external actions and settings are untouched") + ); + } + } } diff --git a/packages/ui-preview-rs/README.md b/packages/ui-preview-rs/README.md index fd485b71b..80d558916 100644 --- a/packages/ui-preview-rs/README.md +++ b/packages/ui-preview-rs/README.md @@ -133,10 +133,13 @@ retaining their stable IDs; new stories do not need a second rendering switch. Use repository-relative source paths for links to GitHub; `file!()` is useful for local source identification but may need a repository path prefix. -The browser has grouped thumbnails, scene/size/frame URLs, renderer links and a -pinned comparison. Comparison follows the selected timestamp using the nearest -available earlier frame. Existing baseline acceptance remains explicit and is -never performed by the workbench. +The browser groups states under their product experience, preserves +scene/size/frame URLs, links to renderer source, and supports a pinned +comparison. Comparison follows the selected timestamp using the nearest +available earlier frame. Its accessible preview is a line-oriented transcript; +hidden terminal glyphs stay in visual cell data but are omitted from that +transcript. Existing baseline acceptance remains explicit and is never +performed by the workbench. ## Watch while designing @@ -160,8 +163,47 @@ signs in, executes a tool, or writes application settings. The server rejects non-loopback Host/Origin values, oversized bodies, text, event counts and dimensions. It invokes no browser-supplied command or path. `--components-only` remains a static catalog and exposes no replay endpoint. -The workbench preserves URL state -and scroll across successful refreshes. Failed builds retain the previous page -and show an error; they never update accepted baselines. It uses the configured -`CARGO_TARGET_DIR` or a user cache outside the checkout. In Mono it runs the -existing build-capacity check before each build. Ctrl+C stops serving. + +Choose **New menu scene** to open the Scene Inspector. Content edits keep stable +action IDs separate from labels and render through the same Rust `ActionPicker` +and `Menu` used by product code. State switches cover ready, loading, empty, +error, long-content, Unicode, and narrow fixtures. The Interaction tab replays +typing, navigation, selection, cancel, resize, and retry and reports simulated +effects by stable ID. The coverage table distinguishes available variants from +the variants exercised by the current draft; its automated-check column is +reported separately and is never inferred from a preview. + +**Export recipe** saves the bounded declarative draft. **Export replay** saves +the ordinary deterministic input sequence consumed by capture tests. **Export +Rust fixture** emits a runnable Cargo example that uses the production widgets; +the caller still owns real action dispatch and persistence. Import validates a +recipe with Rust before replacing the current draft. Draft form fields live in +browser session storage so a source-watch reload preserves even a temporarily +invalid edit. This storage is editor state only and never changes application +settings. + +To keep an exported scene, place its `.rs` file beside the owning preview +adapter. Its generated `register(&mut Registry)` function adds the recipe state +and every deterministic input prefix to the same registry used by `--json`, +`--html`, and PR visual evidence: + +```rust +#[path = "workspace_menu.rs"] +pub mod exported_workspace_menu; + +exported_workspace_menu::register(&mut registry)?; +``` + +Keep that generated module public when compiling with dead-code warnings: the +same file also exposes a public standalone `main` used by `cargo run --example`. + +The built-in recipe states and four interaction journeys use this path. They +add 21 bounded cases, including select, filter/cancel, retry/select, and +Unicode/resize/cancel prefixes. Registration rejects duplicate IDs or invalid +dimensions; it never accepts a baseline. + +The workbench preserves URL state and scroll across successful refreshes. +Failed builds retain the previous page and show an error; they never update +accepted baselines. It uses the configured `CARGO_TARGET_DIR` or a user cache +outside the checkout. In Mono it runs the existing build-capacity check before +each build. Ctrl+C stops serving. diff --git a/packages/ui-preview-rs/src/authoring.rs b/packages/ui-preview-rs/src/authoring.rs index 4ad39860d..d2338e516 100644 --- a/packages/ui-preview-rs/src/authoring.rs +++ b/packages/ui-preview-rs/src/authoring.rs @@ -1,15 +1,21 @@ //! Small, portable interaction receipts for deterministic UI stories. -use crate::{Scene, registry::Story}; +use crate::{ + Scene, + registry::{Registry, Story}, +}; use crossterm::event::KeyCode; -use maestro_ui::{ActionPicker, Menu, PickerOptions, UiTheme}; +use maestro_ui::{ActionPicker, Menu, PickerOptions, PickerOutcome, PickerStatus, UiTheme}; use ratatui::{Terminal, backend::TestBackend, widgets::ListItem}; use serde::{Deserialize, Serialize}; -use std::{fs::OpenOptions, io::Write, path::Path}; +use std::{collections::HashSet, fs::OpenOptions, io::Write, path::Path}; +use unicode_width::UnicodeWidthStr; pub const REPLAY_VERSION: u8 = 1; pub const MAX_INPUTS: usize = 64; pub const MAX_TEXT_BYTES: usize = 4_096; pub const REPLAY_STEP_MS: u64 = 80; +pub const MENU_RECIPE_VERSION: u8 = 1; +pub const MAX_MENU_ITEMS: usize = 64; #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -178,6 +184,597 @@ pub fn write_scaffold(id: &str, output: &Path) -> Result<(), String> { file.write_all(source.as_bytes()).map_err(|e| e.to_string()) } +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum MenuRecipeState { + Ready, + Empty, + Loading, + Error, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MenuRecipeItem { + pub id: String, + pub label: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MenuRecipe { + pub version: u8, + pub id: String, + pub label: String, + pub title: String, + pub placeholder: String, + pub empty: String, + pub items: Vec, + pub state: MenuRecipeState, + pub status_message: String, + pub width: u16, + pub height: u16, + pub inputs: Vec, +} + +impl MenuRecipe { + #[must_use] + pub fn starter() -> Self { + Self { + version: MENU_RECIPE_VERSION, + id: "new-menu".into(), + label: "New menu".into(), + title: "Choose a workspace".into(), + placeholder: "Filter workspaces".into(), + empty: "No matching workspaces".into(), + items: [ + ("application", "Application"), + ("documentation", "Documentation"), + ("docs-site", "docs-site"), + ] + .map(|(id, label)| MenuRecipeItem { + id: id.into(), + label: label.into(), + }) + .into(), + state: MenuRecipeState::Ready, + status_message: String::new(), + width: 60, + height: 20, + inputs: Vec::new(), + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.version != MENU_RECIPE_VERSION { + return Err(format!("unsupported menu recipe version: {}", self.version)); + } + validate_id(&self.id)?; + validate_text("label", &self.label, 160, false)?; + validate_text("title", &self.title, 160, false)?; + validate_text("placeholder", &self.placeholder, 160, false)?; + validate_text("empty message", &self.empty, 240, false)?; + validate_text("status message", &self.status_message, 240, true)?; + validate_dimensions(self.width, self.height)?; + if self.items.len() > MAX_MENU_ITEMS { + return Err(format!("menu recipe exceeds {MAX_MENU_ITEMS} items")); + } + let mut total = 0usize; + let mut unique = HashSet::new(); + for item in &self.items { + validate_id(&item.id)?; + validate_text("menu item label", &item.label, 160, false)?; + total = total.saturating_add(item.id.len() + item.label.len()); + if !unique.insert(&item.id) { + return Err("menu recipe item IDs must be unique".into()); + } + } + if total > MAX_TEXT_BYTES { + return Err(format!("menu recipe items exceed {MAX_TEXT_BYTES} bytes")); + } + if matches!( + self.state, + MenuRecipeState::Loading | MenuRecipeState::Error + ) && self.status_message.is_empty() + { + return Err("loading and error recipes need a status message".into()); + } + self.sequence().validate() + } + + #[must_use] + pub fn sequence(&self) -> StorySequence { + StorySequence { + version: REPLAY_VERSION, + id: self.id.clone(), + label: self.label.clone(), + width: self.width, + height: self.height, + inputs: self.inputs.clone(), + } + } + + pub fn story(&self, source: &str) -> Result { + self.validate()?; + let sequence = self.sequence(); + let recipe = self.clone(); + Story::replay(sequence, source, move |scene, inputs| { + render_menu_recipe(&recipe, scene, inputs).map(|rendered| rendered.buffer) + }) + } + + pub fn fixture_source(&self) -> Result { + self.validate()?; + let state = match self.state { + MenuRecipeState::Ready => "Ready", + MenuRecipeState::Empty => "Empty", + MenuRecipeState::Loading => "Loading", + MenuRecipeState::Error => "Error", + }; + let items = self + .items + .iter() + .map(|item| { + format!( + " maestro_ui_preview::authoring::MenuRecipeItem {{ id: {:?}.into(), label: {:?}.into() }},", + item.id, item.label + ) + }) + .collect::>() + .join("\n"); + let inputs = self + .inputs + .iter() + .map(story_input_source) + .map(|input| format!(" {input},")) + .collect::>() + .join("\n"); + Ok(format!( + r#"//! Generated review fixture using the production Menu and ActionPicker widgets. +//! The caller still owns every effect returned by the controller. +use maestro_ui_preview::authoring::{{ + MenuRecipe, MenuRecipeState, export_story, +}}; +use maestro_ui_preview::registry::Registry; + +/// Declarative fixture data; product code still owns any resulting effects. +pub fn recipe() -> MenuRecipe {{ + MenuRecipe {{ + version: {version}, + id: {id:?}.into(), + label: {label:?}.into(), + title: {title:?}.into(), + placeholder: {placeholder:?}.into(), + empty: {empty:?}.into(), + items: vec![ +{items} + ], + state: MenuRecipeState::{state}, + status_message: {status_message:?}.into(), + width: {width}, + height: {height}, + inputs: vec![ +{inputs} + ], + }} +}} + +/// Add this recipe to the ordinary capture and PR-evidence registry. +pub fn register(registry: &mut Registry) -> Result<(), String> {{ + registry.add(recipe().story(file!())?) +}} + +pub fn main() -> Result<(), String> {{ + let recipe = recipe(); + println!("{{}}", export_story(recipe.story(file!())?)?); + Ok(()) +}} +"#, + version = self.version, + id = self.id, + label = self.label, + title = self.title, + placeholder = self.placeholder, + empty = self.empty, + status_message = self.status_message, + width = self.width, + height = self.height, + )) + } +} + +fn validate_text(label: &str, value: &str, maximum: usize, empty: bool) -> Result<(), String> { + if (!empty && value.is_empty()) || value.len() > maximum || value.chars().any(char::is_control) + { + return Err(format!( + "{label} must be {}..{maximum} bytes without control characters", + usize::from(!empty) + )); + } + Ok(()) +} + +fn story_input_source(input: &StoryInput) -> String { + match input { + StoryInput::Key { key, ctrl } => format!( + "maestro_ui_preview::authoring::StoryInput::Key {{ key: maestro_ui_preview::authoring::StoryKey::{key:?}, ctrl: {ctrl} }}" + ), + StoryInput::Text { text } => { + format!("maestro_ui_preview::authoring::StoryInput::Text {{ text: {text:?}.into() }}") + } + StoryInput::Resize { width, height } => { + format!( + "maestro_ui_preview::authoring::StoryInput::Resize {{ width: {width}, height: {height} }}" + ) + } + StoryInput::Retry => "maestro_ui_preview::authoring::StoryInput::Retry".into(), + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum MenuSimulatedEffect { + Focused { id: String, label: String }, + Selected { id: String, label: String }, + Cancelled, + RetryRequested, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CoverageAvailability { + Available, + Missing, + NotApplicable, +} + +#[derive(Debug, Serialize)] +pub struct CoverageEntry { + pub variant: &'static str, + pub availability: CoverageAvailability, + pub exercised: bool, + pub automated_check: Option, + pub note: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct MenuStudioResponse { + pub capture: crate::review::Capture, + pub transcript: String, + pub effects: Vec, + pub recipe: MenuRecipe, + pub fixture_source: String, + pub coverage: Vec, + pub effect_policy: &'static str, +} + +struct MenuRender { + buffer: ratatui::buffer::Buffer, + effects: Vec, +} + +fn record_menu_outcome( + outcome: PickerOutcome, + effects: &mut Vec, +) { + match outcome { + PickerOutcome::Changed(Some(item)) => effects.push(MenuSimulatedEffect::Focused { + id: item.id, + label: item.label, + }), + PickerOutcome::Selected(item) => effects.push(MenuSimulatedEffect::Selected { + id: item.id, + label: item.label, + }), + PickerOutcome::Cancelled => effects.push(MenuSimulatedEffect::Cancelled), + PickerOutcome::Pending | PickerOutcome::Changed(None) => {} + } +} + +fn render_menu_recipe( + recipe: &MenuRecipe, + scene: &Scene, + inputs: &[StoryInput], +) -> Result { + let initial_items = if recipe.state == MenuRecipeState::Empty { + Vec::new() + } else { + recipe.items.clone() + }; + let mut picker = ActionPicker::new(initial_items) + .identified_by(|item| item.id.as_str()) + .map_err(|error| error.to_string())? + .searchable(|item| item.label.as_str()); + picker.open(); + picker.set_status(match recipe.state { + MenuRecipeState::Ready | MenuRecipeState::Empty => PickerStatus::Ready, + MenuRecipeState::Loading => PickerStatus::Loading(recipe.status_message.clone()), + MenuRecipeState::Error => PickerStatus::Error(recipe.status_message.clone()), + }); + let mut effects = Vec::new(); + for input in inputs { + match input { + StoryInput::Key { key, ctrl } => { + record_menu_outcome(picker.handle_key(key.code(), *ctrl), &mut effects); + } + StoryInput::Text { text } => { + record_menu_outcome(picker.insert_str(text), &mut effects); + } + StoryInput::Retry if recipe.state == MenuRecipeState::Error => { + effects.push(MenuSimulatedEffect::RetryRequested); + picker.set_status(PickerStatus::Ready); + } + StoryInput::Resize { .. } | StoryInput::Retry => {} + } + } + let mut terminal = Terminal::new(TestBackend::new(scene.width, scene.height)) + .map_err(|error| error.to_string())?; + terminal + .draw(|frame| { + Menu::new(recipe.title.as_str(), &mut picker) + .options(PickerOptions { + placeholder: recipe.placeholder.as_str(), + empty: recipe.empty.as_str(), + ..PickerOptions::default() + }) + .render_items(frame, frame.area(), UiTheme::default(), |item| { + ListItem::new(item.label.as_str()) + }); + }) + .map_err(|error| error.to_string())?; + Ok(MenuRender { + buffer: terminal.backend().buffer().clone(), + effects, + }) +} + +pub fn menu_studio(recipe: MenuRecipe) -> Result { + recipe.validate()?; + let sequence = recipe.sequence(); + let &(width, height, time_ms) = sequence + .cases()? + .last() + .ok_or("menu recipe has no capture case")?; + let scene = Scene { + id: recipe.id.clone(), + label: recipe.label.clone(), + width, + height, + time_ms, + }; + let rendered = render_menu_recipe(&recipe, &scene, &recipe.inputs)?; + let mut capture = crate::review::from_buffer(scene, &rendered.buffer)?; + capture.source = "products/maestro/packages/ui-preview-rs/src/authoring.rs".into(); + let transcript = crate::review::transcript(&capture); + let fixture_source = recipe.fixture_source()?; + let state = recipe.state; + let final_width = capture.scene.width; + let coverage = vec![ + coverage( + "ready", + state == MenuRecipeState::Ready, + "Explicit ready recipe state", + ), + coverage( + "loading", + state == MenuRecipeState::Loading, + "Explicit loading recipe state", + ), + coverage( + "empty", + state == MenuRecipeState::Empty, + "Explicit empty recipe state", + ), + coverage( + "error", + state == MenuRecipeState::Error, + "Explicit error recipe state", + ), + coverage( + "long content", + state == MenuRecipeState::Ready + && recipe + .items + .iter() + .any(|item| item.label.as_str().width() > 32), + "Available through caller-owned item text", + ), + coverage( + "Unicode", + state == MenuRecipeState::Ready + && recipe.items.iter().any(|item| !item.label.is_ascii()), + "Available through caller-owned item text", + ), + coverage( + "narrow", + final_width < 48, + "Available through bounded terminal width", + ), + CoverageEntry { + variant: "reduced motion", + availability: CoverageAvailability::NotApplicable, + exercised: false, + automated_check: None, + note: "This static menu recipe has no motion path", + }, + coverage( + "interaction", + !recipe.inputs.is_empty(), + "Keyboard and text inputs replay through ActionPicker", + ), + ]; + Ok(MenuStudioResponse { + capture, + transcript, + effects: rendered.effects, + recipe, + fixture_source, + coverage, + effect_policy: "interactions are simulated; external actions and settings are untouched", + }) +} + +fn coverage(variant: &'static str, exercised: bool, note: &'static str) -> CoverageEntry { + CoverageEntry { + variant, + availability: CoverageAvailability::Available, + exercised, + automated_check: None, + note, + } +} + +#[must_use] +pub fn menu_recipe_fixtures() -> Vec { + let starter = MenuRecipe::starter(); + let fixture = |id: &str, label: &str, state, status_message: &str| MenuRecipe { + id: id.into(), + label: label.into(), + state, + status_message: status_message.into(), + ..starter.clone() + }; + let mut long = fixture( + "menu-recipe-long", + "Menu recipe / long content", + MenuRecipeState::Ready, + "", + ); + long.items = (1..=24) + .map(|index| MenuRecipeItem { + id: format!("workspace-{index:02}"), + label: format!("workspace-with-a-reviewably-long-name-{index:02}"), + }) + .collect(); + let mut unicode = fixture( + "menu-recipe-unicode", + "Menu recipe / Unicode", + MenuRecipeState::Ready, + "", + ); + unicode.items = [ + ("cafe", "Café crème"), + ("dawn", "夜明け"), + ("greeting", "Καλημέρα"), + ("docs", "Docs 🌙"), + ] + .map(|(id, label)| MenuRecipeItem { + id: id.into(), + label: label.into(), + }) + .into(); + let mut narrow = fixture( + "menu-recipe-narrow", + "Menu recipe / narrow", + MenuRecipeState::Ready, + "", + ); + narrow.width = 30; + narrow.height = 14; + vec![ + fixture( + "menu-recipe-ready", + "Menu recipe / ready", + MenuRecipeState::Ready, + "", + ), + fixture( + "menu-recipe-loading", + "Menu recipe / loading", + MenuRecipeState::Loading, + "Loading workspaces…", + ), + fixture( + "menu-recipe-empty", + "Menu recipe / empty", + MenuRecipeState::Empty, + "", + ), + fixture( + "menu-recipe-error", + "Menu recipe / error", + MenuRecipeState::Error, + "Could not load workspaces. Retry.", + ), + long, + unicode, + narrow, + ] +} + +fn menu_recipe_interactions() -> Vec { + let mut select = MenuRecipe::starter(); + select.id = "menu-recipe-select".into(); + select.label = "Menu recipe / navigate and select".into(); + select.inputs = vec![ + StoryInput::Key { + key: StoryKey::Down, + ctrl: false, + }, + StoryInput::Key { + key: StoryKey::Enter, + ctrl: false, + }, + ]; + + let mut filter_cancel = MenuRecipe::starter(); + filter_cancel.id = "menu-recipe-filter-cancel".into(); + filter_cancel.label = "Menu recipe / filter and cancel".into(); + filter_cancel.inputs = vec![ + StoryInput::Text { text: "doc".into() }, + StoryInput::Key { + key: StoryKey::Escape, + ctrl: false, + }, + ]; + + let mut retry = menu_recipe_fixtures() + .into_iter() + .find(|recipe| recipe.state == MenuRecipeState::Error) + .expect("error fixture"); + retry.id = "menu-recipe-retry".into(); + retry.label = "Menu recipe / retry and select".into(); + retry.inputs = vec![ + StoryInput::Retry, + StoryInput::Text { text: "doc".into() }, + StoryInput::Key { + key: StoryKey::Enter, + ctrl: false, + }, + ]; + + let mut unicode_resize = menu_recipe_fixtures() + .into_iter() + .find(|recipe| recipe.id == "menu-recipe-unicode") + .expect("Unicode fixture"); + unicode_resize.id = "menu-recipe-unicode-resize".into(); + unicode_resize.label = "Menu recipe / Unicode, resize, and cancel".into(); + unicode_resize.inputs = vec![ + StoryInput::Text { text: "夜".into() }, + StoryInput::Resize { + width: 30, + height: 14, + }, + StoryInput::Key { + key: StoryKey::Escape, + ctrl: false, + }, + ]; + vec![select, filter_cancel, retry, unicode_resize] +} + +/// Register recipe-owned states and interaction prefixes in the ordinary +/// capture catalog consumed by local review and PR evidence. +pub fn register_menu_recipes(registry: &mut Registry) -> Result<(), String> { + for recipe in menu_recipe_fixtures() + .into_iter() + .chain(menu_recipe_interactions()) + { + registry.add(recipe.story("products/maestro/packages/ui-preview-rs/src/authoring.rs")?)?; + } + Ok(()) +} + /// The small default recipe: caller-owned labels, real shared Menu/ActionPicker, /// and deterministic replay. Use `Story::replay` directly for product adapters. pub fn menu_story( @@ -188,44 +785,28 @@ pub fn menu_story( placeholder: &'static str, empty: &'static str, ) -> Result { - Story::replay( - sequence, - source, - move |scene: &Scene, inputs: &[StoryInput]| { - let mut picker = ActionPicker::new(items.clone()) - .identified_by(String::as_str) - .map_err(|error| error.to_string())? - .searchable(String::as_str); - picker.open(); - for input in inputs { - match input { - StoryInput::Key { key, ctrl } => { - picker.handle_key(key.code(), *ctrl); - } - StoryInput::Text { text } => { - picker.insert_str(text); - } - StoryInput::Resize { .. } | StoryInput::Retry => {} - } - } - let mut terminal = Terminal::new(TestBackend::new(scene.width, scene.height)) - .map_err(|error| error.to_string())?; - terminal - .draw(|frame| { - Menu::new(title, &mut picker) - .options(PickerOptions { - placeholder, - empty, - ..PickerOptions::default() - }) - .render_items(frame, frame.area(), UiTheme::default(), |item| { - ListItem::new(item.as_str()) - }); - }) - .map_err(|error| error.to_string())?; - Ok(terminal.backend().buffer().clone()) - }, - ) + MenuRecipe { + version: MENU_RECIPE_VERSION, + id: sequence.id.clone(), + label: sequence.label.clone(), + title: title.into(), + placeholder: placeholder.into(), + empty: empty.into(), + items: items + .into_iter() + .enumerate() + .map(|(index, label)| MenuRecipeItem { + id: format!("item-{}", index + 1), + label, + }) + .collect(), + state: MenuRecipeState::Ready, + status_message: String::new(), + width: sequence.width, + height: sequence.height, + inputs: sequence.inputs.clone(), + } + .story(source) } pub fn export_story(story: Story) -> Result { @@ -300,4 +881,119 @@ mod tests { assert_eq!(std::fs::read_to_string(&output).unwrap(), before); std::fs::remove_file(output).unwrap(); } + + #[test] + fn menu_recipe_replays_stable_ids_and_round_trips_exact_capture() { + let mut recipe = MenuRecipe::starter(); + recipe.title = "Choose \"docs\" \\ 夜".into(); + recipe.items = vec![ + MenuRecipeItem { + id: "overview".into(), + label: "Overview".into(), + }, + MenuRecipeItem { + id: "docs".into(), + label: "Docs \\ 夜".into(), + }, + ]; + recipe.inputs = vec![ + StoryInput::Text { + text: "Docs".into(), + }, + StoryInput::Key { + key: StoryKey::Enter, + ctrl: false, + }, + ]; + let first = menu_studio(recipe.clone()).unwrap(); + assert!(matches!( + first.effects.last(), + Some(MenuSimulatedEffect::Selected { id, .. }) if id == "docs" + )); + let imported: MenuRecipe = + serde_json::from_str(&serde_json::to_string(&recipe).unwrap()).unwrap(); + let replayed = menu_studio(imported).unwrap(); + assert_eq!( + crate::review::json(std::slice::from_ref(&first.capture)).unwrap(), + crate::review::json(std::slice::from_ref(&replayed.capture)).unwrap() + ); + assert!(first.fixture_source.contains(r#"Choose \"docs\" \\ 夜"#)); + assert!(first.fixture_source.contains("id: \"docs\".into()")); + assert!( + first + .fixture_source + .contains("pub fn register(registry: &mut Registry)") + ); + } + + #[test] + fn menu_recipe_reports_error_retry_narrow_and_truthful_coverage() { + let mut recipe = menu_recipe_fixtures() + .into_iter() + .find(|recipe| recipe.state == MenuRecipeState::Error) + .unwrap(); + recipe.inputs.push(StoryInput::Retry); + recipe.inputs.push(StoryInput::Resize { + width: 30, + height: 14, + }); + let response = menu_studio(recipe).unwrap(); + assert_eq!( + (response.capture.scene.width, response.capture.scene.height), + (30, 14) + ); + assert!( + response + .effects + .contains(&MenuSimulatedEffect::RetryRequested) + ); + let coverage = |name| { + response + .coverage + .iter() + .find(|row| row.variant == name) + .unwrap() + }; + assert!(coverage("error").exercised); + assert!(coverage("narrow").exercised); + assert_eq!( + coverage("reduced motion").availability, + CoverageAvailability::NotApplicable + ); + assert!( + response + .coverage + .iter() + .all(|row| row.automated_check.is_none()) + ); + } + + #[test] + fn menu_recipe_rejects_duplicate_or_unbounded_caller_content() { + let mut recipe = MenuRecipe::starter(); + recipe.items.push(recipe.items[0].clone()); + assert!(recipe.validate().is_err()); + recipe = MenuRecipe::starter(); + recipe.items[0].label = "x".repeat(161); + assert!(recipe.validate().is_err()); + recipe = MenuRecipe::starter(); + recipe.items[0].id = "Display text is not identity".into(); + assert!(recipe.validate().is_err()); + } + + #[test] + fn menu_recipes_register_states_and_every_interaction_prefix() { + let mut registry = Registry::default(); + register_menu_recipes(&mut registry).unwrap(); + let captures = registry.captures().unwrap(); + assert_eq!(captures.len(), 21); + assert!(captures.iter().any(|capture| { + capture.scene.id == "menu-recipe-unicode-resize" + && capture.scene.width == 30 + && capture.scene.time_ms == 240 + })); + assert!(captures.iter().all(|capture| { + capture.source == "products/maestro/packages/ui-preview-rs/src/authoring.rs" + })); + } } diff --git a/packages/ui-preview-rs/src/lib.rs b/packages/ui-preview-rs/src/lib.rs index dc1bdc6fe..4b8be42f5 100644 --- a/packages/ui-preview-rs/src/lib.rs +++ b/packages/ui-preview-rs/src/lib.rs @@ -282,6 +282,7 @@ pub fn registry() -> Result { legacy_render, )?; menus::register(&mut registry)?; + authoring::register_menu_recipes(&mut registry)?; Ok(registry) } pub fn catalog() -> Vec { diff --git a/packages/ui-preview-rs/src/review.html b/packages/ui-preview-rs/src/review.html index 372149e6c..21db09e84 100644 --- a/packages/ui-preview-rs/src/review.html +++ b/packages/ui-preview-rs/src/review.html @@ -3,7 +3,7 @@ Deixic — UI library + />Deixic — UI scene workspace
-

DEIXIC / UI LIBRARY

-

Production widgets · supplied fixture state

+

DEIXIC / UI SCENE WORKSPACE

+

Explore experiences · author bounded fixtures · replay in Rust

-