Another project
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}