diff --git a/libs/cua-driver/docs/tool-output-format.md b/libs/cua-driver/docs/tool-output-format.md index 0ae00df560..c0c8f65127 100644 --- a/libs/cua-driver/docs/tool-output-format.md +++ b/libs/cua-driver/docs/tool-output-format.md @@ -35,3 +35,11 @@ Observation tools retain their typed tool-specific structured payloads. records in `structuredContent` and can attach a PNG as image content. A multimodal harness interprets the image; Cua Driver does not OCR it or assign task meaning. + +On Windows, `get_window_state.elements_complete` is true only when the +unprojected UI Automation walk visited every exposed node without a traversal +bound or enumeration failure skipping a node or subtree. Reaching +`max_elements` or `max_depth` exactly at the end of a tree remains complete. +`query` projects the returned Markdown and structured element rows, but does +not make the underlying snapshot incomplete; compare `total_element_count` +with `returned_element_count` to measure that projection. diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/harness_wpf_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/harness_wpf_test.rs index 31e9da2cd0..ead46bb74f 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/harness_wpf_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/harness_wpf_test.rs @@ -335,7 +335,32 @@ fn harness_wpf_query_projects_structured_elements() { returned < total, "query did not compact {returned}/{total} elements" ); + assert_eq!( + response.structured()["elements_complete"].as_bool(), + Some(true), + "query projection must preserve the complete underlying UIA snapshot" + ); let _ = element_token_by_id(&response, "btn-increment"); + + let bounded = driver.call( + "get_window_state", + serde_json::json!({ + "pid": pid as i64, + "window_id": wid, + "max_elements": 1, + "include_screenshot": false + }), + ); + assert!( + !bounded.is_error(), + "bounded snapshot failed: {}", + bounded.text() + ); + assert_eq!( + bounded.structured()["elements_complete"].as_bool(), + Some(false), + "a bound that skips the WPF root's descendants must report an incomplete snapshot" + ); Observation::delivered(vec![OracleKind::AxState], Evidence::default()) }, ); diff --git a/libs/cua-driver/rust/crates/platform-windows/src/msaa.rs b/libs/cua-driver/rust/crates/platform-windows/src/msaa.rs index a1ed48da86..cdc364cfd5 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/msaa.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/msaa.rs @@ -87,6 +87,7 @@ unsafe fn walk_unsafe(hwnd: u64) -> UiaTreeResult { "- Window \n" ), nodes: Vec::new(), + elements_complete: false, }; } let root: IAccessible = IAccessible::from_raw(raw_root); @@ -110,6 +111,10 @@ unsafe fn walk_unsafe(hwnd: u64) -> UiaTreeResult { UiaTreeResult { tree_markdown, nodes, + // This compatibility path cannot prove the complete UIA search + // domain (and intentionally omits UIA-only state), even when its own + // bounded MSAA recursion reaches every exposed child. + elements_complete: false, } } diff --git a/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs index ef1477e1d6..dcdb836f77 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs @@ -1102,7 +1102,9 @@ impl Tool for GetWindowStateTool { Set `query` to a case-insensitive substring to project BOTH `tree_markdown` \ and `structuredContent.elements` to matching rows plus their ancestor chain. \ Original element indices are preserved. `total_element_count` reports the \ - complete snapshot; `returned_element_count` reports the projection.\n\n\ + unprojected walked snapshot; `returned_element_count` reports the projection. \ + `elements_complete` describes the underlying walk and is unaffected by query \ + projection.\n\n\ Always returns BOTH the element tree AND a screenshot — ground on both \ and cross-check (the tree lies on some surfaces). Choose the modality at \ ACTION time: an element ax action (element_index/element_token → \ @@ -1114,8 +1116,9 @@ impl Tool for GetWindowStateTool { Optional `max_elements` / `max_depth` bound the UIA walk to mitigate \ context-window blow-up on Electron / large web apps that produce 10k+ \ element trees. When applied, BOTH the markdown and the structured \ - elements are truncated identically. Omit both for current default behaviour \ - (≤5 000 elements, depth ≤25).\n\n\ + elements are truncated identically. `elements_complete` is false when a bound \ + actually skips a node or subtree; ending exactly at a bound remains complete. \ + Omit both for current default behaviour (≤5 000 elements, depth ≤25).\n\n\ CHROMIUM COVERAGE: a browser-owned permission bubble can be \ composited outside the requested native window. Chromium-family \ snapshots therefore describe this limit in structuredContent.capture_coverage. \ @@ -1132,7 +1135,7 @@ impl Tool for GetWindowStateTool { "capture_mode": cua_driver_core::capture_mode::capture_mode_schema(), "include_screenshot":{"type":"boolean","description":"Default true — returns a grounding screenshot alongside the tree. Set false to skip the grab and return tree only (the cheap path for re-indexing before an element ax action)."}, "screenshot_out_file":{"type":"string","description":"When set, write the PNG to this file path instead of embedding base64 in the response. The structured output will contain `screenshot_file_path` instead."}, - "query":{"type":"string","description":"Optional case-insensitive substring. Projects both tree_markdown and structured elements to matches plus ancestors while preserving original indices. Compare total_element_count with returned_element_count."}, + "query":{"type":"string","description":"Optional case-insensitive substring. Projects both tree_markdown and structured elements to matches plus ancestors while preserving original indices. Compare total_element_count with returned_element_count; elements_complete continues to describe the underlying unprojected walk."}, "max_elements":{"type":"integer","minimum":1,"description":"Cap on the total number of UIA nodes walked. Truncates depth-first; markdown and structured elements truncate together. Omit for the default (5 000). Lower for Electron / large web apps that produce 10k+ element trees."}, "max_depth":{"type":"integer","minimum":1,"description":"Cap on the UIA-tree walk depth. Nodes whose rendered indent would exceed this are omitted. Omit for the default (25). Lower for deep menu / Electron trees."} },"additionalProperties":false}), @@ -1322,10 +1325,7 @@ impl Tool for GetWindowStateTool { } } structured["element_count"] = json!(count); - // UIA currently does not expose whether a bounded walk - // exhausted every subtree. Keep negative existence - // conservative until that proof is available. - structured["elements_complete"] = json!(false); + structured["elements_complete"] = json!(tr.elements_complete); structured["tree_markdown"] = json!(tr.tree_markdown); // Surface 6: register a snapshot in the global token diff --git a/libs/cua-driver/rust/crates/platform-windows/src/uia/mod.rs b/libs/cua-driver/rust/crates/platform-windows/src/uia/mod.rs index 193d89809d..eec9d646ad 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/uia/mod.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/uia/mod.rs @@ -15,7 +15,7 @@ use windows::Win32::System::Com::{ }; use windows::Win32::UI::Accessibility::{ CUIAutomation, IUIAutomation, IUIAutomationCacheRequest, IUIAutomationElement, - IUIAutomationExpandCollapsePattern, IUIAutomationInvokePattern, + IUIAutomationElementArray, IUIAutomationExpandCollapsePattern, IUIAutomationInvokePattern, IUIAutomationSelectionItemPattern, IUIAutomationTogglePattern, ToggleState_Off, ToggleState_On, TreeScope_Children, TreeScope_Subtree, UIA_AutomationIdPropertyId, UIA_BoundingRectanglePropertyId, UIA_ControlTypePropertyId, UIA_ExpandCollapsePatternId, @@ -38,14 +38,6 @@ pub const DEFAULT_MAX_DEPTH: usize = 25; /// Default cap; callers can override via [`walk_tree_bounded`]. pub const DEFAULT_MAX_TOTAL_ELEMENTS: usize = 5000; -// Historical aliases — referenced by the thin `walk_cached` shim that -// keeps the pre-#22865 call signature compiling. `walk_tree_bounded` -// reads from the caller-supplied caps instead. -#[allow(dead_code)] -const MAX_DEPTH: usize = DEFAULT_MAX_DEPTH; -#[allow(dead_code)] -const MAX_TOTAL_ELEMENTS: usize = DEFAULT_MAX_TOTAL_ELEMENTS; - /// A single node in the accessibility tree. /// /// Same shape for the UIA primary path AND the MSAA fallback (used for @@ -96,6 +88,46 @@ pub struct UiaNode { pub struct UiaTreeResult { pub tree_markdown: String, pub nodes: Vec, + /// True when the unprojected native walk visited every node exposed by + /// the selected root without a bound or enumeration failure skipping a + /// node/subtree. A `query` only projects the response and does not change + /// this underlying snapshot property. + pub elements_complete: bool, +} + +#[derive(Debug)] +struct WalkState { + visited: usize, + max_elements: usize, + max_depth: usize, + complete: bool, +} + +impl WalkState { + fn new(max_elements: usize, max_depth: usize) -> Self { + Self { + visited: 0, + max_elements, + max_depth, + complete: true, + } + } + + /// Admit one node to the walk. Reaching a bound exactly is not itself + /// truncation; completeness changes only when a node beyond that bound is + /// actually encountered and skipped. + fn enter(&mut self, depth: usize) -> bool { + if depth > self.max_depth || self.visited >= self.max_elements { + self.complete = false; + return false; + } + self.visited += 1; + true + } + + fn mark_incomplete(&mut self) { + self.complete = false; + } } /// Walk the UIA tree for the window with the given HWND. @@ -257,6 +289,7 @@ unsafe fn walk_tree_unsafe( return UiaTreeResult { tree_markdown: format!("UIA init failed: {e}"), nodes: Vec::new(), + elements_complete: false, } } }; @@ -268,10 +301,15 @@ unsafe fn walk_tree_unsafe( return UiaTreeResult { tree_markdown: format!("CreateCacheRequest failed: {e}"), nodes: Vec::new(), + elements_complete: false, } } }; + // Cache setup is part of completeness: a failed request can omit nodes or + // the properties needed to classify them. + let mut cache_request_complete = true; + // Properties to pre-fetch. for prop in &[ UIA_ControlTypePropertyId, @@ -285,7 +323,7 @@ unsafe fn walk_tree_unsafe( UIA_ToggleToggleStatePropertyId, UIA_SelectionItemIsSelectedPropertyId, ] { - let _ = cache_req.AddProperty(*prop); + cache_request_complete &= cache_req.AddProperty(*prop).is_ok(); } // Patterns to pre-fetch (for action detection). @@ -299,16 +337,17 @@ unsafe fn walk_tree_unsafe( UIA_TextPatternId, UIA_ScrollPatternId, ] { - let _ = cache_req.AddPattern(*pat); + cache_request_complete &= cache_req.AddPattern(*pat).is_ok(); } // Fetch entire subtree in one call. - let _ = cache_req.SetTreeScope(TreeScope_Subtree); + cache_request_complete &= cache_req.SetTreeScope(TreeScope_Subtree).is_ok(); // Apply control-view filter (same as ControlViewWalker). - if let Ok(ctrl_cond) = automation.ControlViewCondition() { - let _ = cache_req.SetTreeFilter(&ctrl_cond); - } + cache_request_complete &= automation + .ControlViewCondition() + .and_then(|condition| cache_req.SetTreeFilter(&condition)) + .is_ok(); let hwnd_win = windows::Win32::Foundation::HWND(hwnd as *mut _); @@ -352,6 +391,7 @@ unsafe fn walk_tree_unsafe( return UiaTreeResult { tree_markdown: format!("ElementFromHandle failed: {e}"), nodes: Vec::new(), + elements_complete: false, } } }; @@ -376,6 +416,7 @@ unsafe fn walk_tree_unsafe( "BuildUpdatedCache failed after {attempt} attempts: {e}" ), nodes: Vec::new(), + elements_complete: false, }; } std::thread::sleep(std::time::Duration::from_millis(40)); @@ -387,7 +428,10 @@ unsafe fn walk_tree_unsafe( let mut nodes: Vec = Vec::new(); let mut lines: Vec<(usize, String)> = Vec::new(); let mut counter = 0usize; - let mut total = 0usize; + let mut walk_state = WalkState::new(max_elements, max_depth); + if !cache_request_complete { + walk_state.mark_incomplete(); + } walk_cached_bounded( &root_elem, @@ -397,10 +441,9 @@ unsafe fn walk_tree_unsafe( &mut nodes, &mut lines, &mut counter, - &mut total, - max_elements, - max_depth, + &mut walk_state, ); + let mut elements_complete = walk_state.complete; // Fallback for CoreWindow-class apps (Calculator, Settings, older UWPs). // `ElementFromHandle(hwnd)` on a `Windows.UI.Core.CoreWindow` HWND returns @@ -416,94 +459,42 @@ unsafe fn walk_tree_unsafe( // Stage the fallback walk into fresh accumulators and only swap them in // if the fallback actually finds actionable elements. Otherwise the // wrapper-only node from the primary walk stays the result — better than - // erasing it AND leaving the consumed `MAX_TOTAL_ELEMENTS` budget intact + // erasing it AND leaving the consumed `max_elements` budget intact // for the fallback (which would then truncate large trees prematurely). if nodes.iter().filter(|n| n.element_index.is_some()).count() == 0 { - // Skip the desktop-root walk-by-pid fallback for VCL / SAL - // targets (LibreOffice, OpenOffice). The fallback does its own - // `BuildUpdatedCache(TreeScope.Subtree)` per matched top-level - // window — which is exactly the bulk-cache RPC shape that SAL - // hangs on. The primary two-call path (ElementFromHandle + - // BuildUpdatedCache on the dialog's own HWND) ALREADY dodged the - // hang on that one specific call, but the fallback re-introduces - // it. Returning the empty tree here lets the outer - // get_window_state timeout fire its structured diagnostic - // promptly instead of stalling 4 s on the fallback's hang. - // - // The diagnostic tells callers exactly how to drive the SAL - // dialog without the tree: pixel click off the screenshot - // get_window_state always returns, or press_key with - // delivery_mode:"foreground" for accelerator-style dismissal. That's enough for the - // common modal-dismissal case (Yes/No/Esc on a Confirmation), - // which is what SAL dialogs almost always need. - let is_sal = { - use windows::Win32::UI::WindowsAndMessaging::GetClassNameW; - let mut buf = [0u16; 64]; - let n = GetClassNameW(hwnd_win, &mut buf); - n > 0 && { - let class = String::from_utf16_lossy(&buf[..n as usize]); - class.starts_with("SAL") + // An HWND walk with no actionable rows cannot prove that the target + // exposes no matching controls. The CoreWindow fallback below may + // replace this with a complete process-root snapshot. + elements_complete = false; + if let Some(target_pid) = pid_from_hwnd(hwnd_win) { + let mut fallback_nodes: Vec = Vec::new(); + let mut fallback_lines: Vec<(usize, String)> = Vec::new(); + let mut fallback_counter = 0usize; + let mut fallback_walk_state = WalkState::new(max_elements, max_depth); + if !cache_request_complete { + fallback_walk_state.mark_incomplete(); } - }; - if !is_sal { - if let Some(target_pid) = pid_from_hwnd(hwnd_win) { - let mut fallback_nodes: Vec = Vec::new(); - let mut fallback_lines: Vec<(usize, String)> = Vec::new(); - let mut fallback_counter = 0usize; - let mut fallback_total = 0usize; - - tracing::debug!( - target: "uia", - "ElementFromHandle returned empty tree for hwnd 0x{hwnd:x}; \ - falling back to GetRootElement + filter ProcessId={target_pid}" - ); - walk_root_by_pid( - &automation, - &cache_req, - target_pid, - &mut fallback_nodes, - &mut fallback_lines, - &mut fallback_counter, - &mut fallback_total, - max_elements, - max_depth, - ); - - if fallback_nodes.iter().any(|n| n.element_index.is_some()) { - nodes = fallback_nodes; - lines = fallback_lines; - // counter/total aren't read after this point — they're - // only used by walk_cached's &mut params for element - // indexing inside that call. - } - } - } else { + tracing::debug!( target: "uia", - "SAL target hwnd 0x{hwnd:x} returned empty primary tree; \ - skipping walk_root_by_pid fallback (known to re-hang on SAL Subtree fetch). \ - Caller should use press_key/screenshot fallbacks per the get_window_state diagnostic." + "ElementFromHandle returned empty tree for hwnd 0x{hwnd:x}; \ + falling back to GetRootElement + filter ProcessId={target_pid}" ); - // Return a tree_markdown that mirrors the get_window_state - // timeout diagnostic so callers get the same actionable - // fallback options even though the walk itself didn't hit - // the 4 s outer timeout (because we skipped the hang-prone - // fallback). Without this the caller sees an empty tree - // and no error, which is less actionable. - let stub = format!( - "- Window \n\ - (SAL providers don't expose modal-dialog children via \ - ElementFromHandle, and the desktop-root fallback walk that \ - would normally find them is known to hang on SAL Subtree \ - BuildUpdatedCache. Use one of: \ - (a) pixel `click(x, y)` off the screenshot `get_window_state` \ - returns alongside this tree; \ - (b) `press_key` with `delivery_mode:\"foreground\"` (Esc / Enter / Y / N).)\n" + walk_root_by_pid( + &automation, + &cache_req, + target_pid, + &mut fallback_nodes, + &mut fallback_lines, + &mut fallback_counter, + &mut fallback_walk_state, ); - return UiaTreeResult { - tree_markdown: stub, - nodes: Vec::new(), - }; + + if fallback_nodes.iter().any(|n| n.element_index.is_some()) { + nodes = fallback_nodes; + lines = fallback_lines; + elements_complete = fallback_walk_state.complete; + } } } @@ -517,6 +508,7 @@ unsafe fn walk_tree_unsafe( UiaTreeResult { tree_markdown, nodes, + elements_complete, } } @@ -550,14 +542,13 @@ unsafe fn walk_root_by_pid( nodes: &mut Vec, lines: &mut Vec<(usize, String)>, counter: &mut usize, - total: &mut usize, - max_elements: usize, - max_depth: usize, + walk_state: &mut WalkState, ) { let root = match automation.GetRootElement() { Ok(r) => r, Err(e) => { tracing::debug!(target: "uia", "GetRootElement failed: {e}"); + walk_state.mark_incomplete(); return; } }; @@ -565,6 +556,7 @@ unsafe fn walk_root_by_pid( Ok(c) => c, Err(e) => { tracing::debug!(target: "uia", "CreateTrueCondition failed: {e}"); + walk_state.mark_incomplete(); return; } }; @@ -572,14 +564,26 @@ unsafe fn walk_root_by_pid( Ok(a) => a, Err(e) => { tracing::debug!(target: "uia", "root.FindAll(Children) failed: {e}"); + walk_state.mark_incomplete(); + return; + } + }; + let count = match kids.Length() { + Ok(count) => count, + Err(e) => { + tracing::debug!(target: "uia", "root child count failed: {e}"); + walk_state.mark_incomplete(); return; } }; - let count = kids.Length().unwrap_or(0); for i in 0..count { let elem = match kids.GetElement(i) { Ok(e) => e, - Err(_) => continue, + Err(e) => { + tracing::debug!(target: "uia", "root child {i} fetch failed: {e}"); + walk_state.mark_incomplete(); + continue; + } }; // Read ProcessId without a cache — root.FindAll didn't use one. // VARIANT for VT_I4 (UIA's ProcessId type) puts the int at @@ -591,63 +595,35 @@ unsafe fn walk_root_by_pid( if raw.Anonymous.Anonymous.vt != 3 /* VT_I4 */ { + walk_state.mark_incomplete(); continue; } raw.Anonymous.Anonymous.Anonymous.lVal as u32 } - Err(_) => continue, + Err(e) => { + tracing::debug!(target: "uia", "root child {i} ProcessId read failed: {e}"); + walk_state.mark_incomplete(); + continue; + } }; if pid != target_pid { continue; } // Match — pull a cached subtree from this element using the same - // cache_req shape as the primary path so walk_cached sees the same - // properties + patterns. + // cache_req shape as the primary path so walk_cached_bounded sees + // the same properties + patterns. let cached = match elem.BuildUpdatedCache(cache_req) { Ok(e) => e, Err(e) => { tracing::debug!(target: "uia", "BuildUpdatedCache on pid={target_pid} match failed: {e}"); + walk_state.mark_incomplete(); continue; } }; - walk_cached_bounded( - &cached, - 0, - None, - false, - nodes, - lines, - counter, - total, - max_elements, - max_depth, - ); + walk_cached_bounded(&cached, 0, None, false, nodes, lines, counter, walk_state); } } -#[allow(dead_code)] -unsafe fn walk_cached( - element: &IUIAutomationElement, - depth: usize, - nodes: &mut Vec, - lines: &mut Vec<(usize, String)>, - counter: &mut usize, - total: &mut usize, -) { - walk_cached_bounded( - element, - depth, - None, - false, - nodes, - lines, - counter, - total, - MAX_TOTAL_ELEMENTS, - MAX_DEPTH, - ); -} - #[allow(clippy::too_many_arguments)] unsafe fn walk_cached_bounded( element: &IUIAutomationElement, @@ -657,14 +633,11 @@ unsafe fn walk_cached_bounded( nodes: &mut Vec, lines: &mut Vec<(usize, String)>, counter: &mut usize, - total: &mut usize, - max_elements: usize, - max_depth: usize, + walk_state: &mut WalkState, ) { - if depth > max_depth || *total >= max_elements { + if !walk_state.enter(depth) { return; } - *total += 1; let control_type = read_cached_control_type(element); let name = read_cached_bstr_name(element); @@ -744,27 +717,56 @@ unsafe fn walk_cached_bounded( } // Recurse using cached children (no additional RPC). - if let Ok(children) = element.GetCachedChildren() { - let len = children.Length().unwrap_or(0); - for i in 0..len { - if let Ok(child) = children.GetElement(i) { - walk_cached_bounded( - &child, - depth + 1, - emitted_parent, - in_web_content || control_type.eq_ignore_ascii_case("Document"), - nodes, - lines, - counter, - total, - max_elements, - max_depth, - ); - } + let children = match cached_children(element) { + Ok(Some(children)) => children, + // UIA returns S_OK with a null array for a cached leaf. windows-rs + // normally maps that null interface to E_POINTER, so use the raw + // result below to distinguish a complete leaf from a real HRESULT + // failure. + Ok(None) => return, + Err(_) => { + walk_state.mark_incomplete(); + return; + } + }; + let len = match children.Length() { + Ok(len) => len, + Err(_) => { + walk_state.mark_incomplete(); + return; + } + }; + for i in 0..len { + match children.GetElement(i) { + Ok(child) => walk_cached_bounded( + &child, + depth + 1, + emitted_parent, + in_web_content || control_type.eq_ignore_ascii_case("Document"), + nodes, + lines, + counter, + walk_state, + ), + Err(_) => walk_state.mark_incomplete(), } } } +unsafe fn cached_children( + element: &IUIAutomationElement, +) -> windows::core::Result> { + let mut raw = std::ptr::null_mut(); + let result = + (Interface::vtable(element).GetCachedChildren)(Interface::as_raw(element), &mut raw); + result.ok()?; + if raw.is_null() { + Ok(None) + } else { + Ok(Some(IUIAutomationElementArray::from_raw(raw))) + } +} + fn read_cached_control_type(element: &IUIAutomationElement) -> String { unsafe { element @@ -1055,3 +1057,106 @@ fn filter_tree(markdown: &str, query: &str) -> String { r.push('\n'); r } + +#[cfg(test)] +mod tests { + use super::*; + + fn star(child_count: usize) -> Vec> { + let mut children = vec![Vec::new(); child_count + 1]; + children[0] = (1..=child_count).collect(); + children + } + + fn chain(len: usize) -> Vec> { + let mut children = vec![Vec::new(); len]; + for i in 0..len.saturating_sub(1) { + children[i] = vec![i + 1]; + } + children + } + + fn walk_synthetic_tree( + children: &[Vec], + node: usize, + depth: usize, + state: &mut WalkState, + ) { + if !state.enter(depth) { + return; + } + for child in &children[node] { + walk_synthetic_tree(children, *child, depth + 1, state); + } + } + + #[test] + fn walk_state_is_complete_only_when_no_bound_skips_a_node() { + struct Case { + name: &'static str, + children: Vec>, + max_elements: usize, + max_depth: usize, + expected_visited: usize, + expected_complete: bool, + } + + let cases = [ + Case { + name: "default bounds admit a 55-node tree", + children: star(54), + max_elements: DEFAULT_MAX_TOTAL_ELEMENTS, + max_depth: DEFAULT_MAX_DEPTH, + expected_visited: 55, + expected_complete: true, + }, + Case { + name: "visiting exactly max_elements is still complete", + children: star(54), + max_elements: 55, + max_depth: DEFAULT_MAX_DEPTH, + expected_visited: 55, + expected_complete: true, + }, + Case { + name: "skipping a node past max_elements is incomplete", + children: star(54), + max_elements: 54, + max_depth: DEFAULT_MAX_DEPTH, + expected_visited: 54, + expected_complete: false, + }, + Case { + name: "a leaf at exactly max_depth is still complete", + children: chain(3), + max_elements: 10, + max_depth: 2, + expected_visited: 3, + expected_complete: true, + }, + Case { + name: "skipping a descendant past max_depth is incomplete", + children: chain(3), + max_elements: 10, + max_depth: 1, + expected_visited: 2, + expected_complete: false, + }, + ]; + + for case in cases { + let mut state = WalkState::new(case.max_elements, case.max_depth); + walk_synthetic_tree(&case.children, 0, 0, &mut state); + assert_eq!( + state.visited, case.expected_visited, + "{}: visited", + case.name + ); + assert_eq!( + state.complete, case.expected_complete, + "{}: complete", + case.name + ); + } + } +}