Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 14 kB View raw
1use core::ops::ControlFlow; 2use std::io::Write; 3use std::path::{Path, PathBuf}; 4 5use bone_app::{AppCore, FrameCount, FrameTarget, InputEvent, KeyDown, NavKey, WindowPoint}; 6use bone_render::{ 7 OffscreenContext, PickIdError, PickIndex, Picker, RenderError, ViewportExtent, encode_png, 8}; 9use bone_ui::input::{KeyCode, ModifierMask, NamedKey}; 10use serde::Serialize; 11use thiserror::Error; 12 13use crate::scenario::{PointerTarget, Scenario, Step, modifier_mask}; 14use crate::target::resolve_target; 15use crate::tree::TreeDump; 16use crate::{DEFAULT_VIEWPORT, offscreen_context}; 17 18#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] 19pub enum RunStatus { 20 Passed, 21 Failed, 22} 23 24#[derive(Clone, Debug, PartialEq, Serialize)] 25pub enum StepOutcome { 26 Ok, 27 Failed(String), 28} 29 30#[derive(Clone, Debug, PartialEq, Serialize)] 31pub struct StepRecord { 32 pub step: Step, 33 pub outcome: StepOutcome, 34} 35 36#[derive(Clone, Debug, PartialEq, Serialize)] 37pub struct RunResult { 38 pub steps: Vec<StepRecord>, 39 pub status: RunStatus, 40} 41 42#[derive(Debug, Error)] 43pub enum JigError { 44 #[error("render: {0}")] 45 Render(#[from] RenderError), 46 #[error("pick id space: {0}")] 47 PickId(#[from] PickIdError), 48 #[error("{path}: {error}")] 49 Io { 50 path: PathBuf, 51 error: std::io::Error, 52 }, 53 #[error("encode result: {0}")] 54 ResultEncode(#[from] ron::Error), 55} 56 57struct OffscreenTarget(OffscreenContext); 58 59impl FrameTarget for OffscreenTarget { 60 fn picker(&self, index: PickIndex) -> Picker<'_> { 61 self.0.picker(index) 62 } 63 64 fn render( 65 &mut self, 66 build_passes: impl FnOnce( 67 &mut wgpu::CommandEncoder, 68 &wgpu::TextureView, 69 &wgpu::TextureView, 70 &wgpu::TextureView, 71 ), 72 ) { 73 self.0.render_passes(build_passes); 74 } 75} 76 77pub fn run_scenario( 78 scenario: &Scenario, 79 out_dir: &Path, 80 sink: &mut impl Write, 81) -> Result<RunResult, JigError> { 82 std::fs::create_dir_all(out_dir).map_err(|error| JigError::Io { 83 path: out_dir.to_path_buf(), 84 error, 85 })?; 86 let context = offscreen_context(DEFAULT_VIEWPORT)?; 87 let core = AppCore::new(context.gpu(), context.color_format(), DEFAULT_VIEWPORT)?; 88 let mut session = Session { 89 core, 90 target: OffscreenTarget(context), 91 out_dir, 92 sink, 93 }; 94 session.frame(); 95 let outcome = 96 scenario 97 .steps 98 .iter() 99 .enumerate() 100 .try_fold(Vec::new(), |mut records, (index, step)| { 101 let (outcome, flow) = match session.exec(index, step) { 102 Ok(()) => (StepOutcome::Ok, ControlFlow::Continue(())), 103 Err(message) => (StepOutcome::Failed(message), ControlFlow::Break(())), 104 }; 105 records.push(StepRecord { 106 step: step.clone(), 107 outcome, 108 }); 109 match flow { 110 ControlFlow::Continue(()) => ControlFlow::Continue(records), 111 ControlFlow::Break(()) => ControlFlow::Break(records), 112 } 113 }); 114 let (steps, status) = match outcome { 115 ControlFlow::Continue(records) => (records, RunStatus::Passed), 116 ControlFlow::Break(records) => (records, RunStatus::Failed), 117 }; 118 let result = RunResult { steps, status }; 119 let text = ron::ser::to_string_pretty(&result, ron::ser::PrettyConfig::default())?; 120 let path = out_dir.join("result.ron"); 121 std::fs::write(&path, text).map_err(|error| JigError::Io { path, error })?; 122 Ok(result) 123} 124 125struct Session<'a, W: Write> { 126 core: AppCore, 127 target: OffscreenTarget, 128 out_dir: &'a Path, 129 sink: &'a mut W, 130} 131 132impl<W: Write> Session<'_, W> { 133 fn exec(&mut self, index: usize, step: &Step) -> Result<(), String> { 134 match step { 135 Step::Resize { width, height } => { 136 let extent = ViewportExtent::new(*width, *height); 137 self.target.0.resize(extent).map_err(|e| e.to_string())?; 138 self.input(InputEvent::Resize(extent)); 139 self.frame(); 140 Ok(()) 141 } 142 Step::Theme(mode) => { 143 self.core.set_theme(*mode); 144 self.frame(); 145 Ok(()) 146 } 147 Step::PointerMove(target) => { 148 let point = self.resolve(index, target)?; 149 self.input(InputEvent::CursorMove(point)); 150 self.frame(); 151 Ok(()) 152 } 153 Step::Click { target, button } => { 154 let point = self.resolve(index, target)?; 155 self.input(InputEvent::CursorMove(point)); 156 self.frame(); 157 self.press_release(*button); 158 Ok(()) 159 } 160 Step::Drag { from, to, button } => { 161 let start = self.resolve(index, from)?; 162 self.input(InputEvent::CursorMove(start)); 163 self.frame(); 164 self.input(InputEvent::Pointer { 165 button: *button, 166 pressed: true, 167 }); 168 self.frame(); 169 let end = self.resolve(index, to)?; 170 self.input(InputEvent::CursorMove(end)); 171 self.frame(); 172 self.input(InputEvent::Pointer { 173 button: *button, 174 pressed: false, 175 }); 176 self.frame(); 177 self.frame(); 178 self.frame(); 179 Ok(()) 180 } 181 Step::Wheel(delta) => { 182 self.input(InputEvent::Wheel(*delta)); 183 self.frame(); 184 Ok(()) 185 } 186 Step::Key { code, modifiers } => { 187 self.key_down(*code, modifiers); 188 Ok(()) 189 } 190 Step::Text(text) => { 191 self.input(InputEvent::KeyDown(KeyDown { 192 code: None, 193 nav: None, 194 text: Some(text.clone()), 195 repeat: false, 196 })); 197 self.frame(); 198 Ok(()) 199 } 200 Step::Advance(frames) => { 201 (0..frames.get()).for_each(|_| self.frame()); 202 Ok(()) 203 } 204 Step::OpenDocument(path) => { 205 self.core 206 .open_document(path.clone()) 207 .map_err(|e| e.to_string())?; 208 self.frame(); 209 Ok(()) 210 } 211 Step::Snapshot(name) => { 212 let frame = self.target.0.capture().map_err(|e| e.to_string())?; 213 let png = encode_png(&frame).map_err(|e| e.to_string())?; 214 self.write_file(&artifact_file(index, &name.to_string(), "png"), &png) 215 } 216 Step::DumpTree(name) => { 217 let dump = self.dump()?; 218 let file = artifact_file(index, &name.to_string(), "tree.json"); 219 self.write_tree(&file, &dump)?; 220 writeln!(self.sink, "# {file}").map_err(|e| e.to_string())?; 221 self.sink 222 .write_all(dump.to_text().as_bytes()) 223 .map_err(|e| e.to_string()) 224 } 225 } 226 } 227 228 fn resolve(&mut self, index: usize, target: &PointerTarget) -> Result<WindowPoint, String> { 229 let update = self.core.access_tree(); 230 resolve_target(target, &update).map_err(|resolve_error| { 231 let file = artifact_file(index, "unresolved", "tree.json"); 232 let flushed = TreeDump::from_update(&update) 233 .map_err(|e| e.to_string()) 234 .and_then(|dump| self.write_tree(&file, &dump)); 235 match flushed { 236 Ok(()) => format!("{resolve_error}. searched tree written to {file}"), 237 Err(write_error) => format!("{resolve_error}. searched tree lost: {write_error}"), 238 } 239 }) 240 } 241 242 fn dump(&self) -> Result<TreeDump, String> { 243 TreeDump::from_update(&self.core.access_tree()).map_err(|e| e.to_string()) 244 } 245 246 fn write_tree(&self, file: &str, dump: &TreeDump) -> Result<(), String> { 247 let json = serde_json::to_vec_pretty(dump).map_err(|e| e.to_string())?; 248 self.write_file(file, &json) 249 } 250 251 fn write_file(&self, file: &str, bytes: &[u8]) -> Result<(), String> { 252 let path = self.out_dir.join(file); 253 std::fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display())) 254 } 255 256 fn key_down(&mut self, code: KeyCode, modifiers: &[crate::scenario::Modifier]) { 257 let mask = modifier_mask(modifiers); 258 if mask != ModifierMask::NONE { 259 self.input(InputEvent::Modifiers(mask)); 260 } 261 self.input(InputEvent::KeyDown(KeyDown { 262 code: Some(code), 263 nav: nav_key(code), 264 text: None, 265 repeat: false, 266 })); 267 self.frame(); 268 if mask != ModifierMask::NONE { 269 self.input(InputEvent::Modifiers(ModifierMask::NONE)); 270 self.frame(); 271 } 272 } 273 274 fn press_release(&mut self, button: bone_ui::input::PointerButton) { 275 self.input(InputEvent::Pointer { 276 button, 277 pressed: true, 278 }); 279 self.frame(); 280 self.input(InputEvent::Pointer { 281 button, 282 pressed: false, 283 }); 284 self.frame(); 285 self.frame(); 286 self.frame(); 287 } 288 289 fn input(&mut self, event: InputEvent) { 290 let _ack = self.core.handle_input(&self.target, event); 291 } 292 293 fn frame(&mut self) { 294 self.core.clock_mut().advance(FrameCount::ONE); 295 let _report = self.core.render_frame(&mut self.target); 296 } 297} 298 299fn artifact_file(index: usize, name: &str, ext: &str) -> String { 300 format!("{index:03}_{name}.{ext}") 301} 302 303fn nav_key(code: KeyCode) -> Option<NavKey> { 304 match code { 305 KeyCode::Named(NamedKey::ArrowLeft) => Some(NavKey::Left), 306 KeyCode::Named(NamedKey::ArrowRight) => Some(NavKey::Right), 307 KeyCode::Named(NamedKey::ArrowUp) => Some(NavKey::Up), 308 KeyCode::Named(NamedKey::ArrowDown) => Some(NavKey::Down), 309 KeyCode::Named(_) => None, 310 KeyCode::Char(c) => match c.get().to_ascii_lowercase() { 311 'z' => Some(NavKey::Zoom), 312 '=' | '+' => Some(NavKey::ZoomIn), 313 '-' | '_' => Some(NavKey::ZoomOut), 314 _ => None, 315 }, 316 } 317} 318 319#[cfg(test)] 320mod tests { 321 use crate::scenario::Scenario; 322 323 use super::*; 324 325 fn run_in_tempdir(scenario_text: &str) -> (tempfile::TempDir, RunResult, String) { 326 let dir = match tempfile::tempdir() { 327 Ok(d) => d, 328 Err(e) => panic!("tempdir: {e}"), 329 }; 330 let scenario = match Scenario::parse(scenario_text) { 331 Ok(s) => s, 332 Err(e) => panic!("parse: {e}"), 333 }; 334 let mut sink = Vec::new(); 335 let result = match run_scenario(&scenario, dir.path(), &mut sink) { 336 Ok(r) => r, 337 Err(e) => panic!("run: {e}"), 338 }; 339 let text = String::from_utf8_lossy(&sink).into_owned(); 340 (dir, result, text) 341 } 342 343 #[test] 344 fn snapshot_and_dump_write_numbered_artifacts() { 345 let (dir, result, text) = run_in_tempdir( 346 r#"Scenario( 347 steps: [ 348 Advance(2), 349 Snapshot("shell"), 350 DumpTree("shell"), 351 ], 352 )"#, 353 ); 354 assert_eq!(result.status, RunStatus::Passed); 355 assert_eq!(result.steps.len(), 3); 356 let png = match std::fs::read(dir.path().join("001_shell.png")) { 357 Ok(bytes) => bytes, 358 Err(e) => panic!("png: {e}"), 359 }; 360 assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); 361 assert!(dir.path().join("002_shell.tree.json").is_file()); 362 assert!(dir.path().join("result.ron").is_file()); 363 assert!(text.starts_with("# 002_shell.tree.json\n")); 364 assert!(text.contains("window"), "tree text was: {text}"); 365 } 366 367 #[test] 368 fn failed_resolution_flushes_the_searched_tree_and_halts() { 369 let (dir, result, _text) = run_in_tempdir( 370 r#"Scenario( 371 steps: [ 372 Snapshot("before"), 373 Click(target: Label("No Such Widget")), 374 Snapshot("after"), 375 ], 376 )"#, 377 ); 378 assert_eq!(result.status, RunStatus::Failed); 379 assert_eq!( 380 result.steps.len(), 381 2, 382 "steps after the failure must not run" 383 ); 384 assert!(matches!(result.steps[1].outcome, StepOutcome::Failed(_))); 385 assert!(dir.path().join("000_before.png").is_file()); 386 assert!(dir.path().join("001_unresolved.tree.json").is_file()); 387 let result_text = match std::fs::read_to_string(dir.path().join("result.ron")) { 388 Ok(t) => t, 389 Err(e) => panic!("result.ron: {e}"), 390 }; 391 assert!(result_text.contains("Failed"), "result was: {result_text}"); 392 } 393 394 #[test] 395 fn nav_key_zoom_is_case_insensitive() { 396 use bone_ui::input::KeyChar; 397 let zoom = |c| nav_key(KeyCode::Char(KeyChar::from_char(c))); 398 assert_eq!(zoom('z'), Some(NavKey::Zoom)); 399 assert_eq!(zoom('Z'), Some(NavKey::Zoom)); 400 assert_eq!(zoom('='), Some(NavKey::ZoomIn)); 401 assert_eq!(zoom('+'), Some(NavKey::ZoomIn)); 402 assert_eq!(zoom('-'), Some(NavKey::ZoomOut)); 403 assert_eq!(zoom('_'), Some(NavKey::ZoomOut)); 404 } 405 406 #[test] 407 fn clicking_a_labeled_button_resolves_and_passes() { 408 let (_dir, result, _text) = run_in_tempdir( 409 r#"Scenario( 410 steps: [ 411 Advance(1), 412 Click(target: Label("File")), 413 DumpTree("after-click"), 414 ], 415 )"#, 416 ); 417 assert_eq!(result.status, RunStatus::Passed); 418 } 419}