Another project
0

Configure Feed

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

at main 13 kB View raw
1use std::path::PathBuf; 2use std::process::ExitCode; 3 4use bone_jig::{RunResult, RunStatus, Scenario, ScenarioSource, Step, StepOutcome, run_scenario}; 5use bone_render::{PixelDiff, PixelDiffThreshold, decode_png, encode_png_rgba}; 6use thiserror::Error; 7 8const USAGE: &str = "usage: 9 bone-jig run <scenario.ron|-> --out <dir> [--document <part>] 10 bone-jig diff <a.png> <b.png> [--out <diff.png>] [--threshold <0-255>]"; 11 12const EXIT_FAILED: u8 = 1; 13const EXIT_USAGE: u8 = 2; 14const EXIT_ERROR: u8 = 3; 15 16#[derive(Debug, PartialEq, Eq, Error)] 17enum CliError { 18 #[error("missing command")] 19 MissingCommand, 20 #[error("unknown command: {0}")] 21 UnknownCommand(String), 22 #[error("missing argument: {0}")] 23 MissingArg(&'static str), 24 #[error("unexpected argument: {0}")] 25 UnexpectedArg(String), 26 #[error("threshold {0:?} is not an integer in 0-255")] 27 BadThreshold(String), 28} 29 30#[derive(Debug, PartialEq)] 31enum Command { 32 Run { 33 scenario: ScenarioSource, 34 out: PathBuf, 35 document: Option<PathBuf>, 36 }, 37 Diff { 38 left: PathBuf, 39 right: PathBuf, 40 out: Option<PathBuf>, 41 threshold: PixelDiffThreshold, 42 }, 43} 44 45fn parse(args: &[String]) -> Result<Command, CliError> { 46 match args { 47 [] => Err(CliError::MissingCommand), 48 [command, rest @ ..] if command == "run" => parse_run(rest, RunArgs::default()), 49 [command, rest @ ..] if command == "diff" => parse_diff(rest, DiffArgs::default()), 50 [command, ..] => Err(CliError::UnknownCommand(command.clone())), 51 } 52} 53 54#[derive(Default)] 55struct RunArgs { 56 scenario: Option<ScenarioSource>, 57 out: Option<PathBuf>, 58 document: Option<PathBuf>, 59} 60 61fn scenario_source(arg: &str) -> ScenarioSource { 62 if arg == "-" { 63 ScenarioSource::Stdin 64 } else { 65 ScenarioSource::Path(PathBuf::from(arg)) 66 } 67} 68 69fn parse_run(args: &[String], acc: RunArgs) -> Result<Command, CliError> { 70 match args { 71 [] => Ok(Command::Run { 72 scenario: acc.scenario.ok_or(CliError::MissingArg("<scenario.ron>"))?, 73 out: acc.out.ok_or(CliError::MissingArg("--out <dir>"))?, 74 document: acc.document, 75 }), 76 [flag, value, rest @ ..] if flag == "--out" => parse_run( 77 rest, 78 RunArgs { 79 out: Some(PathBuf::from(value)), 80 ..acc 81 }, 82 ), 83 [flag, value, rest @ ..] if flag == "--document" => parse_run( 84 rest, 85 RunArgs { 86 document: Some(PathBuf::from(value)), 87 ..acc 88 }, 89 ), 90 [flag] if flag == "--out" => Err(CliError::MissingArg("--out <dir>")), 91 [flag] if flag == "--document" => Err(CliError::MissingArg("--document <part>")), 92 [positional, rest @ ..] if acc.scenario.is_none() && !positional.starts_with("--") => { 93 parse_run( 94 rest, 95 RunArgs { 96 scenario: Some(scenario_source(positional)), 97 ..acc 98 }, 99 ) 100 } 101 [unexpected, ..] => Err(CliError::UnexpectedArg(unexpected.clone())), 102 } 103} 104 105#[derive(Default)] 106struct DiffArgs { 107 images: Vec<PathBuf>, 108 out: Option<PathBuf>, 109 threshold: Option<PixelDiffThreshold>, 110} 111 112fn parse_threshold(value: &str) -> Result<PixelDiffThreshold, CliError> { 113 value 114 .parse::<u8>() 115 .map(|n| PixelDiffThreshold::new(f64::from(n) / 255.0)) 116 .map_err(|_| CliError::BadThreshold(value.to_owned())) 117} 118 119fn parse_diff(args: &[String], acc: DiffArgs) -> Result<Command, CliError> { 120 match args { 121 [] => match <[PathBuf; 2]>::try_from(acc.images) { 122 Ok([left, right]) => Ok(Command::Diff { 123 left, 124 right, 125 out: acc.out, 126 threshold: acc.threshold.unwrap_or(PixelDiffThreshold::EXACT), 127 }), 128 Err(_) => Err(CliError::MissingArg("<a.png> <b.png>")), 129 }, 130 [flag, value, rest @ ..] if flag == "--out" => parse_diff( 131 rest, 132 DiffArgs { 133 out: Some(PathBuf::from(value)), 134 ..acc 135 }, 136 ), 137 [flag, value, rest @ ..] if flag == "--threshold" => parse_diff( 138 rest, 139 DiffArgs { 140 threshold: Some(parse_threshold(value)?), 141 ..acc 142 }, 143 ), 144 [flag] if flag == "--out" => Err(CliError::MissingArg("--out <diff.png>")), 145 [flag] if flag == "--threshold" => Err(CliError::MissingArg("--threshold <0-255>")), 146 [positional, rest @ ..] if acc.images.len() < 2 && !positional.starts_with("--") => { 147 let mut images = acc.images; 148 images.push(PathBuf::from(positional)); 149 parse_diff(rest, DiffArgs { images, ..acc }) 150 } 151 [unexpected, ..] => Err(CliError::UnexpectedArg(unexpected.clone())), 152 } 153} 154 155fn main() -> ExitCode { 156 let args: Vec<String> = std::env::args().skip(1).collect(); 157 match parse(&args) { 158 Ok(Command::Run { 159 scenario, 160 out, 161 document, 162 }) => execute_run(&scenario, &out, document), 163 Ok(Command::Diff { 164 left, 165 right, 166 out, 167 threshold, 168 }) => execute_diff(&left, &right, out.as_deref(), threshold), 169 Err(e) => { 170 eprintln!("{e}"); 171 eprintln!("{USAGE}"); 172 ExitCode::from(EXIT_USAGE) 173 } 174 } 175} 176 177fn with_document(scenario: Scenario, document: Option<PathBuf>) -> Scenario { 178 match document { 179 None => scenario, 180 Some(path) => Scenario { 181 steps: std::iter::once(Step::OpenDocument(path)) 182 .chain(scenario.steps) 183 .collect(), 184 }, 185 } 186} 187 188fn report_failure(result: &RunResult) { 189 result 190 .steps 191 .iter() 192 .enumerate() 193 .filter_map(|(index, record)| match &record.outcome { 194 StepOutcome::Failed(message) => Some((index, message)), 195 StepOutcome::Ok => None, 196 }) 197 .for_each(|(index, message)| eprintln!("step {index} failed: {message}")); 198} 199 200fn execute_run( 201 source: &ScenarioSource, 202 out: &std::path::Path, 203 document: Option<PathBuf>, 204) -> ExitCode { 205 let scenario = match Scenario::load(source) { 206 Ok(s) => with_document(s, document), 207 Err(e) => { 208 eprintln!("{e}"); 209 return ExitCode::from(EXIT_ERROR); 210 } 211 }; 212 match run_scenario(&scenario, out, &mut std::io::stdout().lock()) { 213 Ok(result) if result.status == RunStatus::Passed => ExitCode::SUCCESS, 214 Ok(result) => { 215 report_failure(&result); 216 ExitCode::from(EXIT_FAILED) 217 } 218 Err(e) => { 219 eprintln!("{e}"); 220 ExitCode::from(EXIT_ERROR) 221 } 222 } 223} 224 225fn load_png(path: &std::path::Path) -> Result<(bone_render::ViewportExtent, Vec<u8>), String> { 226 let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; 227 decode_png(&bytes).map_err(|e| format!("decode {}: {e}", path.display())) 228} 229 230fn diff_image(left: &[u8], right: &[u8], limit: u8) -> Vec<u8> { 231 left.chunks_exact(4) 232 .zip(right.chunks_exact(4)) 233 .flat_map(|(a, b)| { 234 let over = a 235 .iter() 236 .zip(b.iter()) 237 .map(|(x, y)| x.abs_diff(*y)) 238 .max() 239 .unwrap_or(0) 240 > limit; 241 if over { 242 [255, 0, 0, 255] 243 } else { 244 [0, 0, 0, 255] 245 } 246 }) 247 .collect() 248} 249 250fn execute_diff( 251 left: &std::path::Path, 252 right: &std::path::Path, 253 out: Option<&std::path::Path>, 254 threshold: PixelDiffThreshold, 255) -> ExitCode { 256 let ((extent_left, rgba_left), (extent_right, rgba_right)) = 257 match (load_png(left), load_png(right)) { 258 (Ok(l), Ok(r)) => (l, r), 259 (Err(e), _) | (_, Err(e)) => { 260 eprintln!("{e}"); 261 return ExitCode::from(EXIT_ERROR); 262 } 263 }; 264 if extent_left != extent_right { 265 eprintln!("size mismatch: {extent_left} vs {extent_right}"); 266 return ExitCode::from(EXIT_FAILED); 267 } 268 let report = match PixelDiff::compare_bytes(extent_left, &rgba_left, &rgba_right, threshold) { 269 Ok(r) => r, 270 Err(e) => { 271 eprintln!("{e}"); 272 return ExitCode::from(EXIT_ERROR); 273 } 274 }; 275 println!( 276 "pixels over threshold: {} of {}", 277 report.over_threshold(), 278 extent_left.pixel_count(), 279 ); 280 if let Some(worst) = report.worst() { 281 println!( 282 "worst: ({},{}) delta={}", 283 worst.x(), 284 worst.y(), 285 worst.max_delta(), 286 ); 287 } 288 if let Some(path) = out { 289 let rgba = diff_image(&rgba_left, &rgba_right, threshold.as_u8()); 290 let written = encode_png_rgba(extent_left, &rgba) 291 .map_err(|e| e.to_string()) 292 .and_then(|png| { 293 std::fs::write(path, png).map_err(|e| format!("write {}: {e}", path.display())) 294 }); 295 if let Err(e) = written { 296 eprintln!("{e}"); 297 return ExitCode::from(EXIT_ERROR); 298 } 299 } 300 if report.is_clean() { 301 ExitCode::SUCCESS 302 } else { 303 ExitCode::from(EXIT_FAILED) 304 } 305} 306 307#[cfg(test)] 308mod tests { 309 use super::*; 310 311 fn strings(parts: &[&str]) -> Vec<String> { 312 parts.iter().map(|s| (*s).to_owned()).collect() 313 } 314 315 #[test] 316 fn run_parses_scenario_out_and_document() { 317 let cmd = parse(&strings(&[ 318 "run", 319 "shell.ron", 320 "--out", 321 "artifacts", 322 "--document", 323 "clamp.step", 324 ])); 325 assert_eq!( 326 cmd, 327 Ok(Command::Run { 328 scenario: ScenarioSource::Path(PathBuf::from("shell.ron")), 329 out: PathBuf::from("artifacts"), 330 document: Some(PathBuf::from("clamp.step")), 331 }), 332 ); 333 } 334 335 #[test] 336 fn run_dash_reads_stdin() { 337 let cmd = parse(&strings(&["run", "-", "--out", "artifacts"])); 338 assert_eq!( 339 cmd, 340 Ok(Command::Run { 341 scenario: ScenarioSource::Stdin, 342 out: PathBuf::from("artifacts"), 343 document: None, 344 }), 345 ); 346 } 347 348 #[test] 349 fn run_requires_out() { 350 assert_eq!( 351 parse(&strings(&["run", "shell.ron"])), 352 Err(CliError::MissingArg("--out <dir>")), 353 ); 354 } 355 356 #[test] 357 fn diff_parses_threshold_and_out() { 358 let cmd = parse(&strings(&[ 359 "diff", 360 "a.png", 361 "b.png", 362 "--threshold", 363 "8", 364 "--out", 365 "d.png", 366 ])); 367 assert_eq!( 368 cmd, 369 Ok(Command::Diff { 370 left: PathBuf::from("a.png"), 371 right: PathBuf::from("b.png"), 372 out: Some(PathBuf::from("d.png")), 373 threshold: PixelDiffThreshold::new(8.0 / 255.0), 374 }), 375 ); 376 } 377 378 #[test] 379 fn diff_defaults_to_exact_threshold() { 380 let cmd = parse(&strings(&["diff", "a.png", "b.png"])); 381 assert_eq!( 382 cmd, 383 Ok(Command::Diff { 384 left: PathBuf::from("a.png"), 385 right: PathBuf::from("b.png"), 386 out: None, 387 threshold: PixelDiffThreshold::EXACT, 388 }), 389 ); 390 } 391 392 #[test] 393 fn diff_rejects_bad_threshold() { 394 assert_eq!( 395 parse(&strings(&["diff", "a.png", "b.png", "--threshold", "256"])), 396 Err(CliError::BadThreshold("256".to_owned())), 397 ); 398 assert_eq!( 399 parse(&strings(&["diff", "a.png"])), 400 Err(CliError::MissingArg("<a.png> <b.png>")), 401 ); 402 } 403 404 #[test] 405 fn unknown_command_is_rejected() { 406 assert_eq!( 407 parse(&strings(&["serve"])), 408 Err(CliError::UnknownCommand("serve".to_owned())), 409 ); 410 assert_eq!(parse(&[]), Err(CliError::MissingCommand)); 411 } 412 413 #[test] 414 fn with_document_prepends_an_open_step() { 415 let scenario = Scenario { 416 steps: vec![Step::Advance(bone_app::FrameCount::ONE)], 417 }; 418 let amended = with_document(scenario, Some(PathBuf::from("clamp.step"))); 419 assert_eq!( 420 amended.steps[0], 421 Step::OpenDocument(PathBuf::from("clamp.step")), 422 ); 423 assert_eq!(amended.steps.len(), 2); 424 } 425 426 #[test] 427 fn diff_image_marks_only_over_threshold_pixels() { 428 let left = [0_u8, 0, 0, 255, 100, 0, 0, 255]; 429 let right = [0_u8, 0, 0, 255, 0, 0, 0, 255]; 430 let image = diff_image(&left, &right, 8); 431 assert_eq!(image, vec![0, 0, 0, 255, 255, 0, 0, 255]); 432 } 433}