From 28550f75766e5b2b4d2c5c4525ad8af59e58196d Mon Sep 17 00:00:00 2001 From: Quentin Leon Date: Tue, 18 Aug 2026 00:18:44 -0700 Subject: [PATCH 1/3] feat(ui): add UI Toolkit authoring and verification - add `ucp ui` commands for listing, linting, inspecting, capturing, and checking UI Toolkit assets - support strict scenarios, data binding, repeaters, virtualized lists, and deterministic screenshots - add Unity bridge services plus Rust and Editor test coverage - document Unity 6+ workflows and publish the focused `ucp-ui` agent skill --- CHANGELOG.md | 8 + README.md | 4 + cli/src/commands/mod.rs | 7 + cli/src/commands/ui.rs | 1124 +++++++++++++++++ cli/src/main.rs | 198 +++ docs/agents/skills.md | 3 +- docs/authoring/ui-toolkit.md | 160 +++ docs/getting-started/introduction.md | 1 + plugins/ucp-surfaces/skills/ucp-ui/SKILL.md | 43 + scripts/generate-micro-skills.mjs | 25 +- skills/unity-control-protocol/SKILL.md | 17 +- .../Editor/Bridge/BridgeServer.cs | 3 + .../Editor/Controllers/UiController.cs | 70 + .../Editor/Controllers/UiController.cs.meta | 2 + unity-package/com.ucp.bridge/Editor/Ui.meta | 8 + .../com.ucp.bridge/Editor/Ui/UiHost.cs | 468 +++++++ .../com.ucp.bridge/Editor/Ui/UiHost.cs.meta | 2 + .../com.ucp.bridge/Editor/Ui/UiLintService.cs | 527 ++++++++ .../Editor/Ui/UiLintService.cs.meta | 2 + .../Editor/Ui/UiOperationManager.cs | 847 +++++++++++++ .../Editor/Ui/UiOperationManager.cs.meta | 2 + .../Editor/Ui/UiScenarioApplier.cs | 496 ++++++++ .../Editor/Ui/UiScenarioApplier.cs.meta | 11 + .../Editor/Ui/UiScenarioLoader.cs | 832 ++++++++++++ .../Editor/Ui/UiScenarioLoader.cs.meta | 11 + .../Editor/Ui/UiScenarioModels.cs | 374 ++++++ .../Editor/Ui/UiScenarioModels.cs.meta | 11 + .../Editor/Ui/UiVisualTreeInspector.cs | 679 ++++++++++ .../Editor/Ui/UiVisualTreeInspector.cs.meta | 2 + .../Tests/Editor/UiControllerTests.cs | 341 +++++ .../Tests/Editor/UiControllerTests.cs.meta | 2 + .../Tests/Editor/UiLintServiceTests.cs | 102 ++ .../Tests/Editor/UiLintServiceTests.cs.meta | 2 + .../Tests/Editor/UiScenarioTests.cs | 304 +++++ .../Tests/Editor/UiScenarioTests.cs.meta | 11 + website/.generated/docs/agents/skills.md | 3 +- .../.generated/docs/authoring/ui-toolkit.md | 160 +++ .../docs/getting-started/introduction.md | 1 + .../.generated/skills-micro/ucp-ui/SKILL.md | 43 + .../skills/unity-control-protocol/SKILL.md | 17 +- website/public/docs/agents/skills.md | 3 +- website/public/docs/authoring/ui-toolkit.md | 160 +++ website/public/docs/index.md | 1 + website/public/llms.txt | 1 + website/public/sitemap.xml | 3 + website/scripts/sync-content.mjs | 1 + website/src/lib/docs-content.ts | 4 + 47 files changed, 7090 insertions(+), 6 deletions(-) create mode 100644 cli/src/commands/ui.rs create mode 100644 docs/authoring/ui-toolkit.md create mode 100644 plugins/ucp-surfaces/skills/ucp-ui/SKILL.md create mode 100644 unity-package/com.ucp.bridge/Editor/Controllers/UiController.cs create mode 100644 unity-package/com.ucp.bridge/Editor/Controllers/UiController.cs.meta create mode 100644 unity-package/com.ucp.bridge/Editor/Ui.meta create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs.meta create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs.meta create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs.meta create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs.meta create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs.meta create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs.meta create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs create mode 100644 unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs.meta create mode 100644 unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs create mode 100644 unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs.meta create mode 100644 unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs create mode 100644 unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs.meta create mode 100644 unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs create mode 100644 unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs.meta create mode 100644 website/.generated/docs/authoring/ui-toolkit.md create mode 100644 website/.generated/skills-micro/ucp-ui/SKILL.md create mode 100644 website/public/docs/authoring/ui-toolkit.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc8ad5..dc95123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] + +### Added + +- Added the Unity 6+ `ucp ui` family for agent-driven UI Toolkit work: target discovery, importer- and clone-backed UXML/USS linting, bounded resolved-tree and binding inspection, DPI-correct Editor-panel PNG capture, and an end-to-end multi-state `ui check` workflow. +- Added strict `.ucp-ui.json` scenarios with JSON data overlays, allowlisted fixture mutations, eager `repeat` templates, and harness-owned virtualized `ListView` binding for reproducible dynamic layouts. +- Added focused Rust, bridge, fixture, lint, inspector, and audit coverage plus agent-facing UI Toolkit workflow documentation. + ## [0.6.2] - 2026-08-31 ### Added diff --git a/README.md b/README.md index 8501041..84361e0 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,10 @@ A few things that are awkward or impossible without it: - **Profile programmatically.** Run a session, then read the hierarchy sorted by self time, with `--fields` to keep the payload small and truncation reported as "showing 50 of 4,312". +### UI Toolkit authoring loops + +Lint UXML and USS with Unity's own importers, populate repeaters and virtualized lists from strict JSON scenarios, inspect resolved layout and bindings, and capture deterministic panel screenshots. `ucp ui check` runs the complete lint-to-visual-verification pass without loading or dirtying a scene. +
## Install diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 3618c8c..4a6e125 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -28,6 +28,7 @@ pub mod snapshot; pub mod spatial; pub mod tests; pub mod transform; +pub mod ui; pub mod vcs; pub mod view; @@ -389,6 +390,11 @@ pub enum Command { #[command(subcommand)] action: view::ViewAction, }, + /// Inspect, lint, and render UI Toolkit UXML and USS + Ui { + #[command(subcommand)] + action: ui::UiAction, + }, } /// Run editor-side automation scripts (Playwright-like). A script is an editor class implementing @@ -971,5 +977,6 @@ pub async fn run(cmd: Command, ctx: Context) -> anyhow::Result<()> { Command::Transform { action } => transform::run(action, &ctx).await, Command::Spatial { action } => spatial::run(action, &ctx).await, Command::View { action } => view::run(action, &ctx).await, + Command::Ui { action } => ui::run(action, &ctx).await, } } diff --git a/cli/src/commands/ui.rs b/cli/src/commands/ui.rs new file mode 100644 index 0000000..eed5c11 --- /dev/null +++ b/cli/src/commands/ui.rs @@ -0,0 +1,1124 @@ +use crate::client::BridgeClient; +use crate::output; +use clap::{Args, Subcommand, ValueEnum}; +use serde_json::{Map, Value}; +use std::collections::HashSet; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use super::{Context, UnityLifecyclePolicy}; + +const MAX_VIEWPORT_DIMENSION: u32 = 8192; +const MAX_VIEWPORT_PIXELS: u64 = 8_388_608; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum UiDetailArg { + Summary, + Normal, + Verbose, +} + +impl UiDetailArg { + fn as_str(self) -> &'static str { + match self { + Self::Summary => "summary", + Self::Normal => "normal", + Self::Verbose => "verbose", + } + } +} + +/// A UXML document or UI scenario plus the state used to render it. +#[derive(Args, Debug, Clone)] +pub struct UiTargetArgs { + /// UXML asset or .ucp-ui.json scenario to load + pub target: String, + /// Named scenario state to render + #[arg(long)] + pub state: Option, + /// Inline JSON object merged into the fixture data + #[arg(long, value_name = "JSON", conflicts_with = "data_file")] + pub data_json: Option, + /// JSON file whose object is merged into the fixture data + #[arg(long, value_name = "PATH", conflicts_with = "data_json")] + pub data_file: Option, + /// Logical viewport width in pixels + #[arg(long, requires = "height")] + pub width: Option, + /// Logical viewport height in pixels + #[arg(long, requires = "width")] + pub height: Option, +} + +/// Inspect, lint, and render UI Toolkit UXML and USS in an isolated Editor harness. +#[derive(Subcommand)] +pub enum UiAction { + /// Discover UXML documents and .ucp-ui.json scenarios + List { + /// Asset folder to scan + #[arg(long, default_value = "Assets")] + root: String, + /// Include UI targets from installed packages + #[arg(long)] + include_packages: bool, + /// Maximum targets to return + #[arg(long, default_value_t = 100)] + limit: usize, + }, + /// Import and validate UXML and USS assets + Lint { + /// UXML, USS, scenario, or folder paths to lint + #[arg(required = true, num_args = 1..)] + paths: Vec, + /// Treat warnings as a failed lint result + #[arg(long)] + fail_on_warnings: bool, + /// Maximum diagnostics to return + #[arg(long, default_value_t = 100)] + max_diagnostics: usize, + }, + /// Instantiate a UI and return its bounded visual tree + Inspect { + #[command(flatten)] + target: UiTargetArgs, + /// Restrict the snapshot to elements matching a UI Toolkit query + #[arg(long)] + query: Option, + /// Maximum visual-tree depth + #[arg(long, default_value_t = 6)] + depth: usize, + /// Maximum elements to return + #[arg(long, default_value_t = 200)] + max_elements: usize, + /// Snapshot detail level + #[arg(long, value_enum, default_value_t = UiDetailArg::Normal)] + detail: UiDetailArg, + }, + /// Render a UI to a deterministic PNG + Screenshot { + #[command(flatten)] + target: UiTargetArgs, + /// Copy the PNG artifact to this path + #[arg(short = 'o', long, value_name = "PATH")] + out: Option, + /// Replace an existing output file + #[arg(long, requires = "out")] + force: bool, + }, + /// Lint, instantiate, inspect, audit, and capture a UI + Check { + #[command(flatten)] + target: UiTargetArgs, + /// Check every state declared by a scenario + #[arg(long, conflicts_with = "state")] + all_states: bool, + /// Restrict snapshots to elements matching a UI Toolkit query + #[arg(long)] + query: Option, + /// Maximum visual-tree depth + #[arg(long, default_value_t = 4)] + depth: usize, + /// Maximum elements per snapshot + #[arg(long, default_value_t = 100)] + max_elements: usize, + /// Snapshot detail level + #[arg(long, value_enum, default_value_t = UiDetailArg::Normal)] + detail: UiDetailArg, + /// Copy capture artifacts into this directory + #[arg(long, value_name = "DIR")] + out_dir: Option, + /// Treat warnings as a failed check result + #[arg(long)] + fail_on_warnings: bool, + /// Replace existing files in --out-dir + #[arg(long, requires = "out_dir")] + force: bool, + }, +} + +#[derive(Debug)] +pub struct UiCommandFailure { + pub message: String, + pub result: Value, +} + +impl fmt::Display for UiCommandFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for UiCommandFailure {} + +struct UiOperationOutcome { + result: Value, + failure: Option, +} + +pub async fn run(action: UiAction, ctx: &Context) -> anyhow::Result<()> { + match action { + UiAction::List { + root, + include_packages, + limit, + } => run_list(root, include_packages, limit, ctx).await, + UiAction::Lint { + paths, + fail_on_warnings, + max_diagnostics, + } => run_lint(paths, fail_on_warnings, max_diagnostics, ctx).await, + UiAction::Inspect { + target, + query, + depth, + max_elements, + detail, + } => { + validate_range("--depth", depth, 0, 64)?; + validate_range("--max-elements", max_elements, 1, 5000)?; + let mut params = target_params(&target)?; + insert_optional(&mut params, "query", query); + params.insert("depth".into(), serde_json::json!(depth)); + params.insert("max".into(), serde_json::json!(max_elements)); + params.insert("detail".into(), serde_json::json!(detail.as_str())); + run_async("ui/inspect", "inspect", params.into(), ctx, |_, _| Ok(())).await + } + UiAction::Screenshot { target, out, force } => { + let params = target_params(&target)?; + run_async( + "ui/screenshot", + "screenshot", + params.into(), + ctx, + move |result, project| { + if let Some(destination) = out { + let source = first_artifact_path(result).ok_or_else(|| { + anyhow::anyhow!("UI screenshot completed without an artifact path") + })?; + let copied = + copy_artifact(project, Path::new(source), &destination, force)?; + insert_result_field( + result, + "outputPath", + Value::String(display_path(&copied)), + ); + } + Ok(()) + }, + ) + .await + } + UiAction::Check { + target, + all_states, + query, + depth, + max_elements, + detail, + out_dir, + fail_on_warnings, + force, + } => { + validate_range("--depth", depth, 0, 64)?; + validate_range("--max-elements", max_elements, 1, 5000)?; + let mut params = target_params(&target)?; + params.insert("allStates".into(), serde_json::json!(all_states)); + insert_optional(&mut params, "query", query); + params.insert("depth".into(), serde_json::json!(depth)); + params.insert("max".into(), serde_json::json!(max_elements)); + params.insert("detail".into(), serde_json::json!(detail.as_str())); + params.insert("failOnWarnings".into(), serde_json::json!(fail_on_warnings)); + run_async( + "ui/check", + "check", + params.into(), + ctx, + move |result, project| { + if let Some(directory) = out_dir { + let copied = copy_result_artifacts(project, result, &directory, force)?; + insert_result_field( + result, + "outputPaths", + serde_json::json!(copied + .iter() + .map(|p| display_path(p)) + .collect::>()), + ); + } + Ok(()) + }, + ) + .await + } + } +} + +async fn run_list( + root: String, + include_packages: bool, + limit: usize, + ctx: &Context, +) -> anyhow::Result<()> { + validate_range("--limit", limit, 1, 5000)?; + let (_, _, mut client) = super::connect_client(ctx).await?; + let result = client + .call( + "ui/list", + serde_json::json!({ + "path": root, + "includePackages": include_packages, + "limit": limit, + }), + ) + .await + .map_err(|err| super::map_bridge_method_error(err, "ui/list", "UI discovery"))?; + client.close().await; + + if ctx.json { + output::print_json(&output::success_json(result)); + } else { + print_list_result(&result); + } + Ok(()) +} + +async fn run_lint( + paths: Vec, + fail_on_warnings: bool, + max_diagnostics: usize, + ctx: &Context, +) -> anyhow::Result<()> { + validate_range("--max-diagnostics", max_diagnostics, 1, 1000)?; + let (project, lock, mut client) = super::connect_client(ctx).await?; + let result = client + .call( + "ui/lint", + serde_json::json!({ + "paths": paths, + "failOnWarnings": fail_on_warnings, + "maxDiagnostics": max_diagnostics, + }), + ) + .await + .map_err(|err| super::map_bridge_method_error(err, "ui/lint", "UI linting"))?; + client.close().await; + + super::await_unity_lifecycle( + &project, + Some(&lock), + UnityLifecyclePolicy::editor_settle( + "Waiting for Unity to finish UI asset work...", + "UI linting", + ), + ctx, + ) + .await?; + + let failure = failed_result_message(&result, "UI lint"); + if ctx.json { + if let Some(message) = failure { + return Err(UiCommandFailure { message, result }.into()); + } + output::print_json(&output::success_json(result)); + } else { + print_lint_result(&result); + if let Some(message) = failure { + return Err(UiCommandFailure { message, result }.into()); + } + } + Ok(()) +} + +async fn run_async( + method: &'static str, + operation: &'static str, + params: Value, + ctx: &Context, + post_process: F, +) -> anyhow::Result<()> +where + F: FnOnce(&mut Value, &Path) -> anyhow::Result<()>, +{ + let (project, _, mut client) = super::connect_client(ctx).await?; + if !ctx.json { + output::print_info(&format!("Starting UI {operation}...")); + } + + let start = client + .call(method, params) + .await + .map_err(|err| super::map_bridge_method_error(err, method, "UI harness support"))?; + let operation_id = start + .get("operationId") + .cloned() + .ok_or_else(|| anyhow::anyhow!("{method} returned no operationId"))?; + + if !ctx.json { + output::print_info(&format!( + "UI {operation} started; waiting for the result..." + )); + } + + let outcome = wait_for_ui_result(&mut client, operation_id, operation, ctx.timeout).await?; + client.close().await; + let (result, operation_failure) = if outcome.failure.is_some() { + (outcome.result, outcome.failure) + } else { + ( + apply_post_process(outcome.result, &project, post_process) + .map_err(anyhow::Error::new)?, + None, + ) + }; + let failure = + operation_failure.or_else(|| failed_result_message(&result, &format!("UI {operation}"))); + + if ctx.json { + if let Some(message) = failure { + return Err(UiCommandFailure { message, result }.into()); + } + output::print_json(&output::success_json(result)); + } else { + if let Some(message) = failure { + if operation == "check" && result.get("passed").and_then(Value::as_bool) == Some(false) + { + print_async_result(operation, &result); + } + return Err(UiCommandFailure { message, result }.into()); + } + print_async_result(operation, &result); + } + Ok(()) +} + +fn apply_post_process( + mut result: Value, + project: &Path, + post_process: F, +) -> Result +where + F: FnOnce(&mut Value, &Path) -> anyhow::Result<()>, +{ + if let Err(error) = post_process(&mut result, project) { + return Err(UiCommandFailure { + message: format!("{error:#}"), + result, + }); + } + Ok(result) +} + +async fn wait_for_ui_result( + client: &mut BridgeClient, + operation_id: Value, + operation: &str, + timeout_secs: u64, +) -> anyhow::Result { + let wait = async { + loop { + let notification = client.next_notification().await.ok_or_else(|| { + anyhow::anyhow!("Connection closed before UI {operation} completed") + })?; + if notification.method != "ui/result" + || notification.params.get("operationId") != Some(&operation_id) + { + continue; + } + + let status = notification + .params + .get("status") + .and_then(Value::as_str) + .unwrap_or("failed"); + if status == "completed" { + let result = notification + .params + .get("result") + .cloned() + .unwrap_or(Value::Null); + return Ok(UiOperationOutcome { + result, + failure: None, + }); + } + + let message = operation_error_message(¬ification.params) + .unwrap_or_else(|| format!("UI {operation} failed")); + return Ok(UiOperationOutcome { + result: notification.params, + failure: Some(message), + }); + } + }; + + if timeout_secs == 0 { + wait.await + } else { + tokio::time::timeout(Duration::from_secs(timeout_secs), wait) + .await + .map_err(|_| { + anyhow::anyhow!( + "Timed out after {timeout_secs}s waiting for UI {operation} result notification" + ) + })? + } +} + +fn target_params(args: &UiTargetArgs) -> anyhow::Result> { + if args.width.is_some() != args.height.is_some() { + anyhow::bail!("--width and --height must be supplied together"); + } + + let mut params = Map::new(); + params.insert("target".into(), Value::String(args.target.clone())); + insert_optional(&mut params, "state", args.state.clone()); + if let Some(data) = load_data(args.data_json.as_deref(), args.data_file.as_deref())? { + params.insert("data".into(), data); + } + if let (Some(width), Some(height)) = (args.width, args.height) { + if width == 0 + || height == 0 + || width > MAX_VIEWPORT_DIMENSION + || height > MAX_VIEWPORT_DIMENSION + { + anyhow::bail!("--width and --height must be between 1 and {MAX_VIEWPORT_DIMENSION}"); + } + let pixel_count = u64::from(width) * u64::from(height); + if pixel_count > MAX_VIEWPORT_PIXELS { + anyhow::bail!( + "--width × --height must not exceed {MAX_VIEWPORT_PIXELS} pixels \ + (requested {pixel_count})" + ); + } + params.insert( + "viewport".into(), + serde_json::json!({ "width": width, "height": height }), + ); + } + Ok(params) +} + +fn load_data(inline: Option<&str>, file: Option<&Path>) -> anyhow::Result> { + if inline.is_some() && file.is_some() { + anyhow::bail!("--data-json and --data-file cannot be used together"); + } + + let (source, label) = match (inline, file) { + (Some(value), None) => (value.to_owned(), "--data-json".to_string()), + (None, Some(path)) => ( + fs::read_to_string(path) + .map_err(|err| anyhow::anyhow!("Failed to read {}: {err}", path.display()))?, + path.display().to_string(), + ), + (None, None) => return Ok(None), + (Some(_), Some(_)) => unreachable!("validated above"), + }; + + let value: Value = serde_json::from_str(&source) + .map_err(|err| anyhow::anyhow!("Invalid JSON in {label}: {err}"))?; + if !value.is_object() { + anyhow::bail!("UI data in {label} must be a JSON object"); + } + Ok(Some(value)) +} + +fn validate_range(flag: &str, value: usize, min: usize, max: usize) -> anyhow::Result<()> { + if value < min || value > max { + anyhow::bail!("{flag} must be between {min} and {max}"); + } + Ok(()) +} + +fn insert_optional(map: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + map.insert(key.to_string(), Value::String(value)); + } +} + +fn failed_result_message(result: &Value, label: &str) -> Option { + let failed = result.get("passed").and_then(Value::as_bool) == Some(false) + || result.get("success").and_then(Value::as_bool) == Some(false); + if !failed { + return None; + } + + if let Some(message) = result.get("message").and_then(Value::as_str) { + return Some(message.to_string()); + } + let (errors, warnings) = diagnostic_counts(result); + Some(format!( + "{label} failed ({errors} error(s), {warnings} warning(s))" + )) +} + +fn operation_error_message(params: &Value) -> Option { + let error = params.get("error")?; + error.as_str().map(str::to_owned).or_else(|| { + error + .get("message") + .and_then(Value::as_str) + .map(str::to_owned) + }) +} + +fn diagnostic_counts(result: &Value) -> (u64, u64) { + if result.get("errorCount").is_some() || result.get("warningCount").is_some() { + return ( + result + .get("errorCount") + .and_then(Value::as_u64) + .unwrap_or(0), + result + .get("warningCount") + .and_then(Value::as_u64) + .unwrap_or(0), + ); + } + + let mut errors = 0; + let mut warnings = 0; + if let Some(lint) = result.get("lint") { + errors += lint.get("errorCount").and_then(Value::as_u64).unwrap_or(0); + warnings += lint + .get("warningCount") + .and_then(Value::as_u64) + .unwrap_or(0); + } + if let Some(states) = result.get("states").and_then(Value::as_array) { + for state in states { + if let Some(audit) = state.get("audit") { + errors += audit.get("errorCount").and_then(Value::as_u64).unwrap_or(0); + warnings += audit + .get("warningCount") + .and_then(Value::as_u64) + .unwrap_or(0); + } + } + } + (errors, warnings) +} + +fn first_artifact_path(result: &Value) -> Option<&str> { + if let Some(path) = result.get("artifactPath").and_then(Value::as_str) { + return Some(path); + } + match result { + Value::Array(values) => values.iter().find_map(first_artifact_path), + Value::Object(values) => values.values().find_map(first_artifact_path), + _ => None, + } +} + +fn collect_artifact_paths(result: &Value, paths: &mut Vec) { + match result { + Value::Array(values) => { + for value in values { + collect_artifact_paths(value, paths); + } + } + Value::Object(values) => { + if let Some(path) = values.get("artifactPath").and_then(Value::as_str) { + paths.push(path.to_string()); + } + for value in values.values() { + collect_artifact_paths(value, paths); + } + } + _ => {} + } +} + +fn copy_result_artifacts( + project: &Path, + result: &Value, + out_dir: &Path, + force: bool, +) -> anyhow::Result> { + let mut artifacts = Vec::new(); + collect_artifact_paths(result, &mut artifacts); + let project = canonical_project(project)?; + let mut seen_sources = HashSet::new(); + let mut seen_destinations = HashSet::new(); + let mut plans = Vec::new(); + + for artifact in artifacts { + let source = resolve_artifact_source(&project, Path::new(&artifact))?; + if !seen_sources.insert(source.clone()) { + continue; + } + let file_name = Path::new(&artifact) + .file_name() + .ok_or_else(|| anyhow::anyhow!("Artifact path has no file name: {artifact}"))?; + let destination = absolute_destination(&out_dir.join(file_name))?; + if !seen_destinations.insert(destination_key(&destination)) { + anyhow::bail!( + "Multiple UI artifacts would be copied to {}", + destination.display() + ); + } + preflight_destination(&source, &destination, force)?; + plans.push((source, destination)); + } + + let mut copied = Vec::with_capacity(plans.len()); + for (source, destination) in plans { + copied.push(copy_resolved_artifact(&source, &destination, force)?); + } + Ok(copied) +} + +fn copy_artifact( + project: &Path, + artifact: &Path, + destination: &Path, + force: bool, +) -> anyhow::Result { + let project = canonical_project(project)?; + let source = resolve_artifact_source(&project, artifact)?; + let destination = absolute_destination(destination)?; + preflight_destination(&source, &destination, force)?; + copy_resolved_artifact(&source, &destination, force) +} + +fn canonical_project(project: &Path) -> anyhow::Result { + fs::canonicalize(project).map_err(|err| { + anyhow::anyhow!( + "Failed to resolve Unity project {}: {err}", + project.display() + ) + }) +} + +fn resolve_artifact_source(project: &Path, artifact: &Path) -> anyhow::Result { + let source_candidate = if artifact.is_absolute() { + artifact.to_path_buf() + } else { + project.join(artifact) + }; + let source = fs::canonicalize(&source_candidate).map_err(|err| { + anyhow::anyhow!( + "Failed to resolve UI artifact {}: {err}", + source_candidate.display() + ) + })?; + if !source.starts_with(&project) { + anyhow::bail!( + "Refusing to copy a UI artifact outside the Unity project: {}", + source.display() + ); + } + if !source.is_file() { + anyhow::bail!("UI artifact is not a file: {}", source.display()); + } + Ok(source) +} + +fn preflight_destination(source: &Path, destination: &Path, force: bool) -> anyhow::Result<()> { + if destination.exists() && !force { + anyhow::bail!( + "Output already exists: {} (use --force to replace it)", + destination.display() + ); + } + if destination.exists() && destination.is_dir() { + anyhow::bail!("Output path is a directory: {}", destination.display()); + } + let parent = destination + .parent() + .ok_or_else(|| anyhow::anyhow!("Output path has no parent: {}", destination.display()))?; + let mut existing_ancestor = parent; + while !existing_ancestor.exists() { + existing_ancestor = existing_ancestor.parent().ok_or_else(|| { + anyhow::anyhow!( + "Output path has no existing ancestor: {}", + destination.display() + ) + })?; + } + if !existing_ancestor.is_dir() { + anyhow::bail!( + "Output parent is not a directory: {}", + existing_ancestor.display() + ); + } + if destination == source { + return Ok(()); + } + Ok(()) +} + +fn copy_resolved_artifact( + source: &Path, + destination: &Path, + force: bool, +) -> anyhow::Result { + if destination == source { + return Ok(destination.to_path_buf()); + } + let parent = destination + .parent() + .ok_or_else(|| anyhow::anyhow!("Output path has no parent: {}", destination.display()))?; + fs::create_dir_all(parent).map_err(|err| { + anyhow::anyhow!( + "Failed to create output directory {}: {err}", + parent.display() + ) + })?; + + let temporary = parent.join(format!(".ucp-ui-{}.tmp", uuid::Uuid::new_v4().as_simple())); + if let Err(err) = fs::copy(&source, &temporary) { + let _ = fs::remove_file(&temporary); + return Err(anyhow::anyhow!( + "Failed to copy UI artifact to {}: {err}", + destination.display() + )); + } + if destination.exists() { + if !force { + let _ = fs::remove_file(&temporary); + anyhow::bail!( + "Output already exists: {} (use --force to replace it)", + destination.display() + ); + } + if let Err(err) = fs::remove_file(&destination) { + let _ = fs::remove_file(&temporary); + return Err(anyhow::anyhow!( + "Failed to replace {}: {err}", + destination.display() + )); + } + } + if let Err(err) = fs::rename(&temporary, &destination) { + let _ = fs::remove_file(&temporary); + return Err(anyhow::anyhow!( + "Failed to finalize UI artifact {}: {err}", + destination.display() + )); + } + Ok(destination.to_path_buf()) +} + +fn destination_key(path: &Path) -> String { + // Keep multi-artifact output portable across case-sensitive and case-insensitive filesystems. + display_path(path).to_lowercase() +} + +fn absolute_destination(path: &Path) -> anyhow::Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir() + .map_err(|err| anyhow::anyhow!("Failed to resolve current directory: {err}"))? + .join(path)) + } +} + +fn insert_result_field(result: &mut Value, key: &str, value: Value) { + if let Some(object) = result.as_object_mut() { + object.insert(key.to_string(), value); + } +} + +fn display_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn print_list_result(result: &Value) { + let entries = result + .get("targets") + .or_else(|| result.get("items")) + .and_then(Value::as_array); + let count = result + .get("count") + .and_then(Value::as_u64) + .or_else(|| entries.map(|items| items.len() as u64)) + .unwrap_or(0); + output::print_success(&format!("Found {count} UI target(s)")); + if let Some(entries) = entries { + for entry in entries.iter().take(20) { + let path = entry + .get("path") + .or_else(|| entry.get("target")) + .and_then(Value::as_str) + .unwrap_or("?"); + let kind = entry + .get("type") + .or_else(|| entry.get("kind")) + .and_then(Value::as_str) + .unwrap_or("ui"); + eprintln!(" {path} ({kind})"); + } + if entries.len() > 20 { + eprintln!(" ... and {} more", entries.len() - 20); + } + } +} + +fn print_lint_result(result: &Value) { + let assets = result + .get("assets") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + let errors = result + .get("errorCount") + .and_then(Value::as_u64) + .unwrap_or(0); + let warnings = result + .get("warningCount") + .and_then(Value::as_u64) + .unwrap_or(0); + if result.get("passed").and_then(Value::as_bool) == Some(false) { + output::print_warn(&format!( + "Linted {assets} UI asset(s): {errors} error(s), {warnings} warning(s)" + )); + } else { + output::print_success(&format!( + "Linted {assets} UI asset(s): {errors} error(s), {warnings} warning(s)" + )); + } + + if let Some(diagnostics) = result.get("diagnostics").and_then(Value::as_array) { + for diagnostic in diagnostics.iter().take(12) { + let severity = diagnostic + .get("severity") + .and_then(Value::as_str) + .unwrap_or("info"); + let path = diagnostic + .get("assetPath") + .or_else(|| diagnostic.get("path")) + .and_then(Value::as_str) + .unwrap_or("?"); + let line = diagnostic.get("line").and_then(Value::as_u64); + let message = diagnostic + .get("message") + .and_then(Value::as_str) + .unwrap_or("?"); + let location = line + .map(|line| format!("{path}:{line}")) + .unwrap_or_else(|| path.to_string()); + eprintln!(" [{severity}] {location}: {message}"); + } + } +} + +fn print_async_result(operation: &str, result: &Value) { + match operation { + "screenshot" => { + let path = result + .get("outputPath") + .and_then(Value::as_str) + .or_else(|| first_artifact_path(result)) + .unwrap_or("artifact unavailable"); + let width = result.get("width").and_then(Value::as_u64).unwrap_or(0); + let height = result.get("height").and_then(Value::as_u64).unwrap_or(0); + output::print_success(&format!( + "UI screenshot captured: {path} ({width}x{height})" + )); + } + "inspect" => { + let count = result + .get("elementCount") + .or_else(|| result.get("count")) + .and_then(Value::as_u64) + .or_else(|| { + result + .get("snapshot") + .and_then(|snapshot| { + snapshot + .get("returnedElementCount") + .or_else(|| snapshot.get("elementCount")) + }) + .and_then(Value::as_u64) + }) + .unwrap_or(0); + output::print_success(&format!("UI inspection completed ({count} element(s))")); + } + "check" => { + let (errors, warnings) = diagnostic_counts(result); + let message = format!("UI check completed: {errors} error(s), {warnings} warning(s)"); + if result.get("passed").and_then(Value::as_bool) == Some(false) { + output::print_warn(&message); + } else { + output::print_success(&message); + } + } + _ => output::print_success(&format!("UI {operation} completed")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loads_inline_and_file_data_objects() { + let inline = load_data(Some(r#"{"title":"Inventory"}"#), None).unwrap(); + assert_eq!(inline.unwrap()["title"], "Inventory"); + + let root = std::env::temp_dir().join(format!("ucp-ui-data-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).unwrap(); + let path = root.join("data.json"); + fs::write(&path, r#"{"count":3}"#).unwrap(); + let from_file = load_data(None, Some(&path)).unwrap(); + assert_eq!(from_file.unwrap()["count"], 3); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_conflicting_or_non_object_data() { + assert!(load_data(Some("{}"), Some(Path::new("data.json"))).is_err()); + assert!(load_data(Some("[]"), None).is_err()); + assert!(load_data(Some("not-json"), None).is_err()); + } + + #[test] + fn target_payload_only_includes_an_explicit_complete_viewport() { + let mut target = UiTargetArgs { + target: "Assets/UI/Panel.uxml".into(), + state: None, + data_json: None, + data_file: None, + width: None, + height: None, + }; + assert!(target_params(&target).unwrap().get("viewport").is_none()); + + target.width = Some(1280); + assert!(target_params(&target).is_err()); + target.height = Some(720); + assert_eq!(target_params(&target).unwrap()["viewport"]["width"], 1280); + } + + #[test] + fn target_payload_rejects_oversized_viewport_area() { + let mut target = UiTargetArgs { + target: "Assets/UI/Panel.uxml".into(), + state: None, + data_json: None, + data_file: None, + width: Some(4096), + height: Some(2048), + }; + assert!(target_params(&target).is_ok()); + + target.height = Some(2160); + let error = target_params(&target).unwrap_err().to_string(); + assert!(error.contains("8388608")); + assert!(error.contains("8847360")); + } + + #[test] + fn artifact_copy_is_project_scoped_and_refuses_overwrite() { + let root = std::env::temp_dir().join(format!("ucp-ui-copy-{}", uuid::Uuid::new_v4())); + let project = root.join("Project"); + let artifact = project.join("Library").join("UcpUi").join("capture.png"); + let destination = root.join("out").join("capture.png"); + fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + fs::write(&artifact, b"png").unwrap(); + + let copied = copy_artifact(&project, &artifact, &destination, false).unwrap(); + assert_eq!(fs::read(copied).unwrap(), b"png"); + assert!(copy_artifact(&project, &artifact, &destination, false).is_err()); + + fs::write(&artifact, b"new").unwrap(); + copy_artifact(&project, &artifact, &destination, true).unwrap(); + assert_eq!(fs::read(destination).unwrap(), b"new"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn artifact_batch_preflights_every_destination_before_copying() { + let root = std::env::temp_dir().join(format!("ucp-ui-preflight-{}", uuid::Uuid::new_v4())); + let project = root.join("Project"); + let artifact_dir = project.join("Library").join("UcpUi"); + let out_dir = root.join("out"); + fs::create_dir_all(&artifact_dir).unwrap(); + fs::create_dir_all(&out_dir).unwrap(); + let first = artifact_dir.join("first.png"); + let second = artifact_dir.join("second.png"); + fs::write(&first, b"first").unwrap(); + fs::write(&second, b"second").unwrap(); + fs::write(out_dir.join("second.png"), b"existing").unwrap(); + let result = serde_json::json!({ + "captures": [ + { "artifactPath": display_path(&first) }, + { "artifactPath": display_path(&second) } + ] + }); + + assert!(copy_result_artifacts(&project, &result, &out_dir, false).is_err()); + assert!(!out_dir.join("first.png").exists()); + assert_eq!(fs::read(out_dir.join("second.png")).unwrap(), b"existing"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn artifact_batch_rejects_nonportable_basename_collisions_before_copying() { + let root = std::env::temp_dir().join(format!("ucp-ui-collision-{}", uuid::Uuid::new_v4())); + let project = root.join("Project"); + let first = project.join("Library").join("one").join("Capture.png"); + let second = project.join("Library").join("two").join("capture.png"); + let out_dir = root.join("out"); + fs::create_dir_all(first.parent().unwrap()).unwrap(); + fs::create_dir_all(second.parent().unwrap()).unwrap(); + fs::write(&first, b"first").unwrap(); + fs::write(&second, b"second").unwrap(); + let result = serde_json::json!({ + "captures": [ + { "artifactPath": display_path(&first) }, + { "artifactPath": display_path(&second) } + ] + }); + + assert!(copy_result_artifacts(&project, &result, &out_dir, false).is_err()); + assert!(!out_dir.exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn post_process_failure_preserves_bridge_result() { + let result = serde_json::json!({ + "artifactPath": "Library/UCP/UiCaptures/panel.png", + "pixelHash": "abc123" + }); + let failure = apply_post_process(result.clone(), Path::new("."), |_, _| { + anyhow::bail!("output already exists") + }) + .unwrap_err(); + + assert_eq!(failure.message, "output already exists"); + assert_eq!(failure.result, result); + } + + #[test] + fn rejects_artifacts_outside_project() { + let root = std::env::temp_dir().join(format!("ucp-ui-scope-{}", uuid::Uuid::new_v4())); + let project = root.join("Project"); + let outside = root.join("outside.png"); + fs::create_dir_all(&project).unwrap(); + fs::write(&outside, b"png").unwrap(); + let destination = root.join("out.png"); + + assert!(copy_artifact(&project, &outside, &destination, false).is_err()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn extracts_nested_check_artifacts_once() { + let value = serde_json::json!({ + "captures": [ + { "artifactPath": "Library/UI/a.png" }, + { "artifactPath": "Library/UI/b.png" } + ], + "primary": { "artifactPath": "Library/UI/a.png" } + }); + let mut paths = Vec::new(); + collect_artifact_paths(&value, &mut paths); + assert_eq!(paths.len(), 3); + assert_eq!(first_artifact_path(&value), Some("Library/UI/a.png")); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 4ace28f..3f9e1bd 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -159,6 +159,12 @@ async fn main() -> anyhow::Result<()> { "error": { "message": compile_failure.message }, "data": compile_failure.result }) + } else if let Some(ui_failure) = e.downcast_ref::() { + serde_json::json!({ + "success": false, + "error": { "message": ui_failure.message }, + "data": ui_failure.result + }) } else { serde_json::json!({ "success": false, @@ -910,4 +916,196 @@ mod tests { _ => panic!("unexpected command variant"), } } + + #[test] + fn parses_ui_inspect_command() { + let cli = Cli::try_parse_from([ + "ucp", + "ui", + "inspect", + "Assets/UI/Inventory.ucp-ui.json", + "--state", + "populated", + "--data-json", + r#"{"title":"Inventory"}"#, + "--width", + "1280", + "--height", + "720", + "--query", + "#cards", + "--depth", + "8", + "--max-elements", + "250", + "--detail", + "verbose", + ]) + .expect("UI inspect command should parse"); + + match cli.command { + commands::Command::Ui { + action: + commands::ui::UiAction::Inspect { + target, + query, + depth, + max_elements, + detail, + }, + } => { + assert_eq!(target.target, "Assets/UI/Inventory.ucp-ui.json"); + assert_eq!(target.state.as_deref(), Some("populated")); + assert!(target.data_json.is_some()); + assert_eq!((target.width, target.height), (Some(1280), Some(720))); + assert_eq!(query.as_deref(), Some("#cards")); + assert_eq!(depth, 8); + assert_eq!(max_elements, 250); + assert_eq!(detail, commands::ui::UiDetailArg::Verbose); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parses_ui_check_all_states_command() { + let cli = Cli::try_parse_from([ + "ucp", + "ui", + "check", + "Assets/UI/Inventory.ucp-ui.json", + "--all-states", + "--out-dir", + "artifacts/ui", + "--fail-on-warnings", + "--force", + ]) + .expect("UI check command should parse"); + + match cli.command { + commands::Command::Ui { + action: + commands::ui::UiAction::Check { + all_states, + out_dir, + fail_on_warnings, + force, + .. + }, + } => { + assert!(all_states); + assert_eq!( + out_dir.as_deref(), + Some(std::path::Path::new("artifacts/ui")) + ); + assert!(fail_on_warnings); + assert!(force); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parses_ui_list_lint_and_screenshot_commands() { + let list = Cli::try_parse_from([ + "ucp", + "ui", + "list", + "--root", + "Assets/UI", + "--include-packages", + "--limit", + "25", + ]) + .expect("UI list command should parse"); + assert!(matches!( + list.command, + commands::Command::Ui { + action: commands::ui::UiAction::List { + root, + include_packages: true, + limit: 25, + } + } if root == "Assets/UI" + )); + + let lint = Cli::try_parse_from([ + "ucp", + "ui", + "lint", + "Assets/UI/Panel.uxml", + "Assets/UI/Panel.uss", + "--fail-on-warnings", + "--max-diagnostics", + "20", + ]) + .expect("UI lint command should parse"); + assert!(matches!( + lint.command, + commands::Command::Ui { + action: commands::ui::UiAction::Lint { + paths, + fail_on_warnings: true, + max_diagnostics: 20, + } + } if paths.len() == 2 + )); + + let screenshot = Cli::try_parse_from([ + "ucp", + "ui", + "screenshot", + "Assets/UI/Panel.uxml", + "--out", + "panel.png", + "--force", + ]) + .expect("UI screenshot command should parse"); + assert!(matches!( + screenshot.command, + commands::Command::Ui { + action: commands::ui::UiAction::Screenshot { + out: Some(_), + force: true, + .. + } + } + )); + } + + #[test] + fn rejects_conflicting_ui_data_and_state_options() { + let conflicting_data = Cli::try_parse_from([ + "ucp", + "ui", + "screenshot", + "Assets/UI/Panel.uxml", + "--data-json", + "{}", + "--data-file", + "data.json", + ]); + assert!(conflicting_data.is_err()); + + let conflicting_states = Cli::try_parse_from([ + "ucp", + "ui", + "check", + "Assets/UI/Panel.ucp-ui.json", + "--state", + "empty", + "--all-states", + ]); + assert!(conflicting_states.is_err()); + + let incomplete_viewport = Cli::try_parse_from([ + "ucp", + "ui", + "inspect", + "Assets/UI/Panel.uxml", + "--width", + "800", + ]); + assert!(incomplete_viewport.is_err()); + } } diff --git a/docs/agents/skills.md b/docs/agents/skills.md index 112f890..125d40a 100644 --- a/docs/agents/skills.md +++ b/docs/agents/skills.md @@ -71,7 +71,7 @@ Install focused, surface-specific skills — one per `ucp` command group — ins /plugin install ucp-surfaces@unity-control-protocol ``` -That install exposes fifteen focused skills, each invoked as `/ucp-surfaces:ucp-`: +That install exposes sixteen focused skills, each invoked as `/ucp-surfaces:ucp-`: - `/ucp-surfaces:ucp-objects` — `ucp object` (create incl. `--primitive`, components, properties, reparent, instantiate) - `/ucp-surfaces:ucp-scene` — `ucp scene` + `ucp editor` lifecycle + `ucp play|stop|pause|compile|screenshot` @@ -79,6 +79,7 @@ That install exposes fifteen focused skills, each invoked as `/ucp-surfaces:ucp- - `/ucp-surfaces:ucp-spatial` — `ucp spatial` (raycast/overlap/bounds/ground/nearest) - `/ucp-surfaces:ucp-view` — `ucp view` (capture/isolate/orbit) + `ucp screenshot` + `ucp record` - `/ucp-surfaces:ucp-assets` — `ucp asset` + `ucp files` + `ucp shader errors` +- `/ucp-surfaces:ucp-ui` — `ucp ui` (list/lint/inspect/screenshot/check) for UI Toolkit - `/ucp-surfaces:ucp-materials` — `ucp material` (create/get/set properties, keywords, shader) - `/ucp-surfaces:ucp-prefabs` — `ucp prefab` (status/apply/revert/unpack/create/overrides) - `/ucp-surfaces:ucp-build` — `ucp build` (targets/scenes/defines/start) diff --git a/docs/authoring/ui-toolkit.md b/docs/authoring/ui-toolkit.md new file mode 100644 index 0000000..e4e03a9 --- /dev/null +++ b/docs/authoring/ui-toolkit.md @@ -0,0 +1,160 @@ +# UI Toolkit + +`ucp ui` gives agents a tight authoring loop for UI Toolkit UXML and USS: discover targets, lint with Unity's importers, inspect a live resolved tree, capture a PNG, or run the full check in one command. + +The harness requires Unity 6 or newer. `ui lint` works in batch mode and with the Null graphics device; `ui inspect`, `ui screenshot`, and `ui check` require an interactive Editor with a graphics device. + +## Commands + +```bash +ucp ui list +ucp ui lint Assets/UI/Inventory.uxml Assets/UI/Inventory.uss +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --json +ucp ui screenshot Assets/UI/Inventory.uxml --width 960 --height 640 -o inventory.png +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui +``` + +Every path must resolve to a location under `Assets/` or `Packages/`; absolute and scenario-relative paths are accepted as long as they land there. A render target can be either a `.uxml` asset or a strict `.ucp-ui.json` scenario. + +### `ucp ui list` + +Discovers UXML documents and scenarios without instantiating them. + +```bash +ucp ui list --root Assets/UI --limit 50 +ucp ui list --include-packages --json +``` + +Invalid scenarios remain in the result with `valid: false` and a structured diagnostic, which makes discovery useful while a fixture is still being authored. + +### `ucp ui lint ` + +Runs Unity's synchronous UXML and USS importers, reads their import logs and flags, resolves dependencies, and clones each UXML tree while capturing scoped Unity diagnostics. The clone step matters because an unknown UXML element can import without an error and fail only when Unity tries to instantiate it. + +```bash +ucp ui lint Assets/UI +ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings --max-diagnostics 200 +``` + +A scenario lint also validates its schema and lints its document, USS dependencies, and collection templates. Errors fail the command. Warnings fail only with `--fail-on-warnings`. + +### `ucp ui inspect ` + +Instantiates the target in an isolated Editor panel, waits for finite stable layout, and returns a bounded semantic snapshot. It includes names, classes, text/value, layout, world bounds, a fixed resolved-style allowlist, binding results, and dynamic-collection counts. + +```bash +ucp ui inspect Assets/UI/Inventory.uxml --query '#toolbar' --depth 4 --max-elements 100 +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state empty --detail verbose --json +``` + +`--query` accepts one simple `#name`, `.class`, or element-type selector. Detail is `summary`, `normal`, or `verbose`. The snapshot reports actual UI Toolkit state; it does not claim matched-USS-rule provenance or an accessibility tree. + +### `ucp ui screenshot ` + +Captures the resolved Editor panel after stable geometry and pixel samples. The defaults are three identical finite geometry samples and two identical pixel samples; a scenario can override them with `settle`. + +```bash +ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png +ucp ui screenshot Assets/UI/Inventory.uxml --width 1280 --height 720 -o artifacts/wide.png --force +``` + +Without `--out`, the bridge retains the PNG under `Library/UCP/UiCaptures` and returns its path and pixel hash. `--out` copies that artifact without overwriting an existing file unless `--force` is explicit. Width and height must be supplied together; if omitted, a scenario's viewport is preserved and direct UXML uses `960x640`. + +The capture backend briefly opens and focuses a transient utility window, then closes it and restores the previously focused window. It does not load or dirty a scene. Use a longer global `--timeout` for scenarios with many states or deliberately slow deferred UI updates. + +### `ucp ui check ` + +Runs fixture validation and lint, then instantiate, settle, inspect, conservative audits, capture, and cleanup as one bridge-owned operation. + +```bash +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --json +ucp ui check Assets/UI/Inventory.ucp-ui.json --state populated --fail-on-warnings +``` + +The v0 audits treat binding/application failures as errors. They warn for focusable zero-size controls, focusable controls outside the viewport, and duplicate element names outside harness-managed collections. They intentionally avoid speculative claims such as unused selectors or contrast failures. `--fail-on-warnings` makes both lint and audit warnings fail the command. + +## Scenario format + +A scenario makes dynamic UI states reproducible without requiring a compiled code-behind type: + +```json +{ + "schemaVersion": 0, + "document": "./Inventory.uxml", + "defaultState": "populated", + "viewport": { "width": 960, "height": 640 }, + "settle": { + "stableFrames": 3, + "pixelStableFrames": 2, + "timeoutSeconds": 15 + }, + "data": { "title": "Inventory" }, + "states": { + "empty": { + "data": { "items": [] }, + "set": [ + { "target": "#empty-message", "property": "display", "value": "flex" } + ] + }, + "populated": { + "data": { + "items": [ + { "name": "Potion", "countLabel": "3" }, + { "name": "Key", "countLabel": "1" } + ] + }, + "collections": [ + { + "target": "#cards", + "mode": "repeat", + "source": "/items", + "template": "./InventoryCard.uxml" + }, + { + "target": "#rows", + "mode": "list-view", + "source": "/items", + "template": "./InventoryRow.uxml", + "itemHeight": 32 + } + ] + } + } +} +``` + +The schema rejects unknown fields and paths outside the project. `states` is required and must contain 1–64 states. `defaultState` is optional only when a state is named `default` or the fixture has exactly one state; otherwise it is required. Viewports allow 1–8192 pixels per axis and at most 8,388,608 total pixels. A render operation has a 300-second enqueue-to-completion ceiling across all selected states. `document` and collection `template` paths are relative to the scenario unless they begin with `Assets/` or `Packages/`. Top-level data is deeply overlaid by state data and then by `--data-json` or `--data-file`. + +`set` supports the deliberately small property allowlist `text`, `value`, `enabled`, `display`, `visibility`, `tooltip`, and `class:`. Targets are `:root`, `#name`, or `.class` and must resolve to one element. + +## Data binding and collections + +Author bindings as ordinary UI Toolkit paths. The harness adapts those paths to its JSON dictionary data source after cloning: + +```xml + + + + + +``` + +Each collection `source` is an RFC 6901-style JSON pointer into the resolved data, such as `/items` or `/inventory/rows`, and must resolve to an array. Each `repeat` or `list-view` item receives one array value as its data source, so the same row UXML works in either mode. + +- `repeat` eagerly clones every row. Use it for small flex-wrap grids and layouts where every item must exist at once. +- `list-view` assigns `itemsSource` and owns `makeItem`, `bindItem`, and `unbindItem`. It uses dynamic-height virtualization by default; setting `itemHeight` selects fixed-height virtualization. It reports logical, realized, bound, and currently visible row counts. + +JSON integers that fit in 32 bits are normalized for controls such as `IntegerField`. Larger integers remain 64-bit and produce a warning because some UI Toolkit controls cannot bind them automatically. + +## Recommended agent loop + +```bash +ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --query '#cards' --json +ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png --force +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --force --json +``` + +Use lint for the fast import/schema pass, inspect to reason about resolved geometry and bindings, and screenshot for visual judgment. Run check before handing off the UI. diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md index 1f0f798..67cbeee 100644 --- a/docs/getting-started/introduction.md +++ b/docs/getting-started/introduction.md @@ -19,6 +19,7 @@ Together, they let you automate virtually any Unity Editor operation from the te - **Project Settings** - Read/write player, quality, physics, and lighting settings - **Prefab Workflow** - Check status, apply/revert overrides, create prefabs, unpack instances - **Build Pipeline** - List targets, manage scenes, control scripting defines, trigger builds +- **UI Toolkit** - Lint UXML/USS with Unity's importers, populate scenarios, inspect resolved layout, capture panel screenshots - **Version Control** - Lightweight Unity VCS fallback commands; prefer native `cm` for full UVCS workflows - **Editor Scripting** - Execute custom C# scripts remotely with parameters - **Cross-Platform** - Works on macOS (x64 + ARM), Linux, and Windows diff --git a/plugins/ucp-surfaces/skills/ucp-ui/SKILL.md b/plugins/ucp-surfaces/skills/ucp-ui/SKILL.md new file mode 100644 index 0000000..d7976c4 --- /dev/null +++ b/plugins/ucp-surfaces/skills/ucp-ui/SKILL.md @@ -0,0 +1,43 @@ +--- +name: ucp-ui +description: >- + Author and verify UI Toolkit with `ucp ui` + (list/lint/inspect/screenshot/check) using UXML, USS, and strict + dynamic-data scenarios. Use when the user wants to lint, inspect, + populate, screenshot, or verify UI Toolkit UXML and USS. For broad + multi-surface Unity automation, use the unity-control-protocol skill + instead. +homepage: https://github.com/mflRevan/unity-control-protocol +compatibility: Requires the `ucp` CLI, the UCP Bridge package, and Unity 6.0+ in the target project. +metadata: + author: mflRevan + version: '0.6.0' +--- + +# UCP UI Toolkit + +Focused micro-skill for the `ucp ui` command surface of the +Unity Control Protocol (`ucp`) CLI. Always confirm the live surface with +`ucp --help` and see the docs at https://unityctl.dev. + +## Examples + +```bash +ucp ui list --root Assets/UI +ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --query '#cards' --json +ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png --force +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --force --json +``` + +## When to use + +Use on Unity 6+ after editing UXML/USS: lint for importer/schema feedback, inspect for resolved geometry and bindings, screenshot for visual judgment, and check for the full multi-state pass. + +## When NOT to use (use the omni skill instead) + +Do not use UI capture commands in batchmode or with the Null graphics device; `ucp ui lint` remains available there. Omit width/height to preserve a scenario viewport. + +For broad, multi-surface Unity automation that spans several of these +command groups at once, use the `unity-control-protocol` omni skill instead +of this focused micro-skill. diff --git a/scripts/generate-micro-skills.mjs b/scripts/generate-micro-skills.mjs index b04aa44..3fe291d 100644 --- a/scripts/generate-micro-skills.mjs +++ b/scripts/generate-micro-skills.mjs @@ -39,6 +39,7 @@ const COMPAT = * @property {string[]} examples Realistic example command lines. * @property {string} whenToUse One-line "When to use". * @property {string} whenNot One-line "When NOT to use". + * @property {string} [compatibility] Optional surface-specific compatibility note. */ /** @type {Surface[]} */ @@ -176,6 +177,28 @@ const surfaces = [ whenNot: 'Prefer direct workspace edits + `ucp compile` when you have filesystem access; use `ucp files` as a fallback. For material property edits use `ucp-materials`; for cross-project reference lookups use `ucp-references`.', }, + { + name: 'ucp-ui', + title: 'UCP UI Toolkit', + capability: + 'Author and verify UI Toolkit with `ucp ui` ' + + '(list/lint/inspect/screenshot/check) using UXML, USS, and strict dynamic-data scenarios', + trigger: + 'the user wants to lint, inspect, populate, screenshot, or verify UI Toolkit UXML and USS', + examples: [ + 'ucp ui list --root Assets/UI', + 'ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings', + "ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --query '#cards' --json", + 'ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png --force', + 'ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --force --json', + ], + whenToUse: + 'Use on Unity 6+ after editing UXML/USS: lint for importer/schema feedback, inspect for resolved geometry and bindings, screenshot for visual judgment, and check for the full multi-state pass.', + whenNot: + 'Do not use UI capture commands in batchmode or with the Null graphics device; `ucp ui lint` remains available there. Omit width/height to preserve a scenario viewport.', + compatibility: + 'Requires the `ucp` CLI, the UCP Bridge package, and Unity 6.0+ in the target project.', + }, { name: 'ucp-materials', title: 'UCP Materials', @@ -380,7 +403,7 @@ function buildSkill(surface) { 'description: >-', ...wrap(description, 2), `homepage: ${HOMEPAGE}`, - `compatibility: ${COMPAT}`, + `compatibility: ${surface.compatibility ?? COMPAT}`, 'metadata:', ' author: mflRevan', ` version: '${VERSION}'`, diff --git a/skills/unity-control-protocol/SKILL.md b/skills/unity-control-protocol/SKILL.md index 283612f..4c30746 100644 --- a/skills/unity-control-protocol/SKILL.md +++ b/skills/unity-control-protocol/SKILL.md @@ -3,7 +3,7 @@ name: unity-control-protocol description: >- Programmatic control of the Unity Editor from the terminal via the `ucp` CLI. Automate scenes, GameObjects, components, assets, materials, prefabs, build - pipelines, settings, tests, packages, selective `.unitypackage` import, debugging and profiling + pipelines, UI Toolkit UXML/USS, settings, tests, packages, selective `.unitypackage` import, debugging and profiling over a WebSocket/JSON-RPC 2.0 bridge. Use when the user asks to inspect, create, modify, or automate anything inside a Unity project without opening the Editor UI. @@ -28,6 +28,7 @@ UCP and Unity expose a broad command surface. If you are unsure what is availabl - The user needs to browse/install Unity packages, manage scoped registries, or selectively import `.unitypackage` content - The user wants to automate entire Unity Editor workflows - The user wants to find all references to an asset, script, material, or prefab across the project +- The user wants to lint, inspect, populate, screenshot, or verify UI Toolkit UXML and USS ## When NOT to use this skill @@ -101,6 +102,20 @@ Object reference writes accept `instanceId`, asset `path`, or asset `guid`, and Use `ucp asset move` / `bulk-move` for Unity-aware renames and folder cleanup instead of raw filesystem moves. That keeps `.meta` files and GUIDs intact so scenes, prefabs, build settings, and serialized object references continue to resolve. +## UI Toolkit authoring and verification + +Use `ucp ui` on Unity 6+ to close the loop after editing UXML, USS, or a strict `.ucp-ui.json` scenario. Lint is the fast importer/schema pass; inspect reports bounded resolved layout and binding state; screenshot provides visual evidence; check runs all of them with cleanup. + +```bash +ucp ui list --root Assets/UI +ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --query '#cards' --json +ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png --force +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --force --json +``` + +Use ordinary UXML `DataBinding` paths. Scenario data is JSON, and the harness adapts those paths to dictionary keys. Use `repeat` for small eager grids and harness-managed `list-view` collections for large virtualized lists. Width and height are an explicit pair; omit both to preserve a scenario viewport. Inspection, screenshot, and check briefly focus a transient Editor window and require a graphics device; lint also works headless. + ## In-scene authoring & spatial workflows For building and arranging scene content, use the dedicated transform/spatial/view commands rather diff --git a/unity-package/com.ucp.bridge/Editor/Bridge/BridgeServer.cs b/unity-package/com.ucp.bridge/Editor/Bridge/BridgeServer.cs index c7ac8d6..527d141 100644 --- a/unity-package/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +++ b/unity-package/com.ucp.bridge/Editor/Bridge/BridgeServer.cs @@ -145,6 +145,9 @@ private static void RegisterHandlers() // Lightweight video recording RecordingController.Register(s_router); + // UI Toolkit authoring, inspection, linting, and capture + UiController.Register(s_router); + // Logs LogsController.Register(s_router); diff --git a/unity-package/com.ucp.bridge/Editor/Controllers/UiController.cs b/unity-package/com.ucp.bridge/Editor/Controllers/UiController.cs new file mode 100644 index 0000000..969e722 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Controllers/UiController.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; + +namespace UCP.Bridge +{ + /// Registers the UI Toolkit discovery, lint, and asynchronous harness methods. + public static class UiController + { + public static void Register(CommandRouter router) + { +#if UNITY_6000_0_OR_NEWER + router.Register("ui/list", HandleList); + router.Register("ui/lint", HandleLint); + router.Register("ui/inspect", parameters => Start("inspect", parameters)); + router.Register("ui/screenshot", parameters => Start("screenshot", parameters)); + router.Register("ui/check", parameters => Start("check", parameters)); + router.Register("ui/status", HandleStatus); +#else + router.Register("ui/list", Unsupported); + router.Register("ui/lint", Unsupported); + router.Register("ui/inspect", Unsupported); + router.Register("ui/screenshot", Unsupported); + router.Register("ui/check", Unsupported); + router.Register("ui/status", Unsupported); +#endif + } + +#if UNITY_6000_0_OR_NEWER + private static object HandleList(string paramsJson) + { + return UiScenarioLoader.List(ParseParameters(paramsJson)); + } + + private static object HandleLint(string paramsJson) + { + return UiLintService.Run(ParseParameters(paramsJson)); + } + + private static object Start(string operation, string paramsJson) + { + return UiOperationManager.Start(operation, ParseParameters(paramsJson)); + } + + private static object HandleStatus(string paramsJson) + { + var parameters = ParseParameters(paramsJson); + var operationId = parameters.TryGetValue("operationId", out var value) + ? value?.ToString() + : null; + return UiOperationManager.Status(operationId); + } + + private static Dictionary ParseParameters(string paramsJson) + { + if (string.IsNullOrWhiteSpace(paramsJson)) + return new Dictionary(StringComparer.Ordinal); + var value = MiniJson.Deserialize(paramsJson); + if (value is not Dictionary parameters) + throw new ArgumentException("UI command parameters must be a JSON object"); + return parameters; + } +#else + private static object Unsupported(string paramsJson) + { + throw new ArgumentException( + "The ucp ui command family requires Unity 6.0 or newer; this project uses an older Unity Editor"); + } +#endif + } +} diff --git a/unity-package/com.ucp.bridge/Editor/Controllers/UiController.cs.meta b/unity-package/com.ucp.bridge/Editor/Controllers/UiController.cs.meta new file mode 100644 index 0000000..a98600b --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Controllers/UiController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 30511693445d48c6a76ea2262ed6ddbf diff --git a/unity-package/com.ucp.bridge/Editor/Ui.meta b/unity-package/com.ucp.bridge/Editor/Ui.meta new file mode 100644 index 0000000..c8b8e13 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 72ee32817b4f45abac53f471c69e899e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs new file mode 100644 index 0000000..cf9f8a3 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs @@ -0,0 +1,468 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using UnityEditor; +using UnityEditorInternal; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.UIElements; + +namespace UCP.Bridge +{ + /// + /// Transient editor-only host for deterministic UI Toolkit inspection and capture. + /// The compositor compensates for CaptureEditorWindow treating logical window points + /// as physical pixels on high-DPI displays. + /// + internal sealed class UiHostWindow : EditorWindow + { + private int _viewportWidth; + private int _viewportHeight; + private float _compositorScale = 1f; + + internal VisualElement ContentRoot { get; private set; } + + internal static UiHostWindow Open(int width, int height) + { + var window = CreateInstance(); + try + { + window.hideFlags = HideFlags.HideAndDontSave; + window._viewportWidth = width; + window._viewportHeight = height; + window._compositorScale = 1f / Mathf.Max(1f, EditorGUIUtility.pixelsPerPoint); + window.titleContent = new GUIContent("UCP UI Harness"); + window.minSize = new Vector2(width, height); + window.maxSize = new Vector2(width, height); + window.position = new Rect(80f, 80f, width, height); + window.ShowUtility(); + window.EnsureContent(); + window.Focus(); + return window; + } + catch + { + try + { + CloseAndDestroy(window); + } + catch + { + // Preserve the exception that prevented the host from opening. + } + throw; + } + } + + internal static void CloseAndDestroy(UiHostWindow window) + { + if (window == null) + return; + + var error = UiCleanup.RunAll( + () => window.Close(), + () => + { + if (window != null) + UnityEngine.Object.DestroyImmediate(window); + }); + if (error != null) + throw error; + } + + public void CreateGUI() + { + EnsureContent(); + } + + internal void EnsureContent() + { + if (ContentRoot != null) + return; + + rootVisualElement.Clear(); + rootVisualElement.style.overflow = Overflow.Hidden; + + ContentRoot = new VisualElement + { + name = "ucp-ui-content-root", + pickingMode = PickingMode.Position + }; + ContentRoot.style.position = Position.Absolute; + ContentRoot.style.left = 0f; + ContentRoot.style.bottom = 0f; + ContentRoot.style.width = _viewportWidth; + ContentRoot.style.height = _viewportHeight; + ContentRoot.style.overflow = Overflow.Hidden; + ContentRoot.style.transformOrigin = new TransformOrigin(Length.Percent(0f), Length.Percent(100f)); + ContentRoot.style.scale = new Scale(new Vector3(_compositorScale, _compositorScale, 1f)); + rootVisualElement.Add(ContentRoot); + } + + internal void Pump() + { + EnsureContent(); + Focus(); + Repaint(); + EditorApplication.QueuePlayerLoopUpdate(); + } + } + + internal readonly struct UiGeometrySample + { + internal UiGeometrySample( + string hash, + int elementCount, + int renderedElementCount, + int invalidGeometryCount, + bool panelAttached) + { + Hash = hash; + ElementCount = elementCount; + RenderedElementCount = renderedElementCount; + InvalidGeometryCount = invalidGeometryCount; + PanelAttached = panelAttached; + } + + internal string Hash { get; } + internal int ElementCount { get; } + internal int RenderedElementCount { get; } + internal int InvalidGeometryCount { get; } + internal bool PanelAttached { get; } + internal bool IsValid => PanelAttached && RenderedElementCount > 0 && InvalidGeometryCount == 0; + + internal Dictionary ToDictionary(int stableFrames) + { + return new Dictionary + { + ["panelAttached"] = PanelAttached, + ["elementCount"] = ElementCount, + ["renderedElementCount"] = RenderedElementCount, + ["invalidGeometryCount"] = InvalidGeometryCount, + ["stableFrames"] = stableFrames, + ["geometryHash"] = Hash + }; + } + } + + internal static class UiGeometrySampler + { + internal static UiGeometrySample Measure(VisualElement root) + { + var hash = 1469598103934665603UL; + var elementCount = 0; + var renderedCount = 0; + var invalidCount = 0; + Visit(root, true, ref hash, ref elementCount, ref renderedCount, ref invalidCount); + return new UiGeometrySample( + hash.ToString("x16", CultureInfo.InvariantCulture), + elementCount, + renderedCount, + invalidCount, + root?.panel != null); + } + + private static void Visit( + VisualElement element, + bool ancestorsDisplayed, + ref ulong hash, + ref int elementCount, + ref int renderedCount, + ref int invalidCount) + { + if (element == null) + return; + + elementCount++; + var displayed = ancestorsDisplayed && element.resolvedStyle.display != DisplayStyle.None; + Add(ref hash, element.GetType().FullName); + Add(ref hash, element.name); + Add(ref hash, displayed ? "displayed" : "not-displayed"); + Add(ref hash, element.hierarchy.childCount.ToString(CultureInfo.InvariantCulture)); + if (element is TextElement textElement) + Add(ref hash, textElement.text); + + if (displayed) + { + renderedCount++; + AddRect(ref hash, element.layout, ref invalidCount); + AddRect(ref hash, element.worldBound, ref invalidCount); + Add(ref hash, element.resolvedStyle.visibility.ToString()); + } + + foreach (var child in element.Children()) + Visit(child, displayed, ref hash, ref elementCount, ref renderedCount, ref invalidCount); + } + + private static void AddRect(ref ulong hash, Rect rect, ref int invalidCount) + { + if (!UiValue.IsFinite(rect.x) || !UiValue.IsFinite(rect.y) || + !UiValue.IsFinite(rect.width) || !UiValue.IsFinite(rect.height)) + { + invalidCount++; + Add(ref hash, ""); + return; + } + + Add(ref hash, Math.Round(rect.x, 3).ToString(CultureInfo.InvariantCulture)); + Add(ref hash, Math.Round(rect.y, 3).ToString(CultureInfo.InvariantCulture)); + Add(ref hash, Math.Round(rect.width, 3).ToString(CultureInfo.InvariantCulture)); + Add(ref hash, Math.Round(rect.height, 3).ToString(CultureInfo.InvariantCulture)); + } + + private static void Add(ref ulong hash, string value) + { + value ??= ""; + for (var i = 0; i < value.Length; i++) + { + hash ^= value[i]; + hash *= 1099511628211UL; + } + + hash ^= 0xff; + hash *= 1099511628211UL; + } + } + + internal sealed class UiCaptureSample + { + internal byte[] Png { get; set; } + internal string PixelHash { get; set; } + internal int Width { get; set; } + internal int Height { get; set; } + internal int SampledDistinctColors { get; set; } + internal int NonTransparentPixels { get; set; } + } + + internal sealed class UiCaptureSurface : IDisposable + { + private RenderTexture _texture; + + internal UiCaptureSurface(int width, int height) + { + _texture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB) + { + name = "UCP UI Capture", + antiAliasing = 1, + hideFlags = HideFlags.HideAndDontSave + }; + _texture.Create(); + } + + internal UiCaptureSample Capture(UiHostWindow window) + { + if (window == null || _texture == null || !_texture.IsCreated()) + throw new InvalidOperationException("The UI capture surface is unavailable"); + + _texture.DiscardContents(); + if (!InternalEditorUtility.CaptureEditorWindow(window, _texture)) + throw new InvalidOperationException("Unity could not capture the UI harness window"); + + var previous = RenderTexture.active; + Texture2D texture = null; + try + { + RenderTexture.active = _texture; + texture = new Texture2D(_texture.width, _texture.height, TextureFormat.RGBA32, false, false) + { + hideFlags = HideFlags.HideAndDontSave + }; + texture.ReadPixels(new Rect(0f, 0f, _texture.width, _texture.height), 0, 0, false); + texture.Apply(false, false); + + var pixels = texture.GetPixels32(); + var distinct = new HashSet(); + var nonTransparent = 0; + var stride = Mathf.Max(1, pixels.Length / 8192); + for (var i = 0; i < pixels.Length; i++) + { + var pixel = pixels[i]; + if (pixel.a > 0) + nonTransparent++; + if (i % stride == 0) + { + distinct.Add((uint)(pixel.r << 24 | pixel.g << 16 | pixel.b << 8 | pixel.a)); + } + } + + var png = texture.EncodeToPNG(); + return new UiCaptureSample + { + Png = png, + PixelHash = Sha256(png), + Width = _texture.width, + Height = _texture.height, + SampledDistinctColors = distinct.Count, + NonTransparentPixels = nonTransparent + }; + } + finally + { + RenderTexture.active = previous; + if (texture != null) + UnityEngine.Object.DestroyImmediate(texture); + } + } + + public void Dispose() + { + var texture = _texture; + _texture = null; + if (texture == null) + return; + + var error = UiCleanup.RunAll( + () => + { + if (RenderTexture.active == texture) + RenderTexture.active = null; + }, + () => texture.Release(), + () => + { + if (texture != null) + UnityEngine.Object.DestroyImmediate(texture); + }); + if (error != null) + throw error; + } + + private static string Sha256(byte[] value) + { + using (var sha = SHA256.Create()) + { + var bytes = sha.ComputeHash(value); + var result = new StringBuilder(bytes.Length * 2); + foreach (var item in bytes) + result.Append(item.ToString("x2", CultureInfo.InvariantCulture)); + return result.ToString(); + } + } + } + + internal static class UiHostCapabilities + { + internal static void EnsureAvailable(string operation) + { + if (Application.isBatchMode) + { + throw new UiOperationException( + "capability_unavailable", + $"ui/{operation} requires an interactive Unity Editor; batch mode supports ui/lint only", + Describe()); + } + + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + { + throw new UiOperationException( + "capability_unavailable", + $"ui/{operation} requires a graphics device; Unity is using the Null graphics device", + Describe()); + } + } + + internal static Dictionary Describe() + { + return new Dictionary + { + ["batchMode"] = Application.isBatchMode, + ["graphicsDeviceType"] = SystemInfo.graphicsDeviceType.ToString(), + ["graphicsDeviceName"] = SystemInfo.graphicsDeviceName, + ["pixelsPerPoint"] = UiValue.FiniteOrNull(EditorGUIUtility.pixelsPerPoint), + ["editorHost"] = !Application.isBatchMode && SystemInfo.graphicsDeviceType != GraphicsDeviceType.Null, + ["lint"] = true, + ["layout"] = !Application.isBatchMode && SystemInfo.graphicsDeviceType != GraphicsDeviceType.Null, + ["screenshot"] = !Application.isBatchMode && SystemInfo.graphicsDeviceType != GraphicsDeviceType.Null + }; + } + } + + internal static class UiCleanup + { + internal static Exception RunAll(params Action[] actions) + { + Exception firstError = null; + if (actions == null) + return null; + + foreach (var action in actions) + { + if (action == null) + continue; + try + { + action(); + } + catch (Exception exception) + { + firstError ??= exception; + } + } + return firstError; + } + } + + internal sealed class UiOperationException : Exception + { + internal UiOperationException(string code, string message, object details = null) + : base(message) + { + Code = code; + Details = details; + } + + internal string Code { get; } + internal object Details { get; } + } + + internal static class UiValue + { + internal static bool IsFinite(float value) + { + return !float.IsNaN(value) && !float.IsInfinity(value); + } + + internal static bool IsFinite(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value); + } + + internal static object FiniteOrNull(float value) + { + return IsFinite(value) ? (object)value : null; + } + + internal static object FiniteOrNull(double value) + { + return IsFinite(value) ? (object)value : null; + } + + internal static string NormalizePath(string path) + { + return path?.Replace('\\', '/'); + } + + internal static string SafeFileName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "ui"; + + var invalid = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(Array.IndexOf(invalid, character) >= 0 || character == '/' || character == '\\' + ? '-' + : character); + } + + var result = builder.ToString().Trim('.', ' ', '-'); + return string.IsNullOrEmpty(result) ? "ui" : result; + } + } +} +#endif diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs.meta b/unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs.meta new file mode 100644 index 0000000..edd0c87 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a6968eabe884a34afea71fd6c099669 diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs new file mode 100644 index 0000000..3e9c21c --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs @@ -0,0 +1,527 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace UCP.Bridge +{ + internal static class UiLintService + { + private const int DefaultMaxDiagnostics = 200; + private const int AbsoluteMaxDiagnostics = 1000; + + internal static Dictionary Run(Dictionary parameters) + { + if (parameters == null || !parameters.TryGetValue("paths", out var pathsValue)) + throw new ArgumentException("Missing 'paths' parameter"); + + var requestedPaths = ReadStringList(pathsValue, "paths"); + if (requestedPaths.Count == 0) + throw new ArgumentException("'paths' must contain at least one path"); + + var failOnWarnings = ReadBool(parameters, "failOnWarnings", false); + var maxDiagnostics = ReadInt( + parameters, + "maxDiagnostics", + DefaultMaxDiagnostics, + 1, + AbsoluteMaxDiagnostics); + + var diagnostics = new DiagnosticCollector(maxDiagnostics); + var fixtureReports = new List(); + var pendingAssets = new SortedSet(StringComparer.Ordinal); + + foreach (var requestedPath in requestedPaths) + CollectRequestedPath(requestedPath, pendingAssets, fixtureReports, diagnostics); + + var inspectedAssets = new HashSet(StringComparer.Ordinal); + var assetReports = new List(); + + while (pendingAssets.Count > 0) + { + var assetPath = pendingAssets.Min; + pendingAssets.Remove(assetPath); + if (!inspectedAssets.Add(assetPath)) + continue; + + var report = InspectAsset(assetPath, diagnostics); + assetReports.Add(report); + + if (!report.TryGetValue("dependencies", out var dependenciesValue) || + dependenciesValue is not IList dependencies) + { + continue; + } + + foreach (var dependencyValue in dependencies) + { + var dependency = dependencyValue as string; + if (IsUiAssetPath(dependency) && !inspectedAssets.Contains(dependency)) + pendingAssets.Add(dependency); + } + } + + var passed = Passes(diagnostics.ErrorCount, diagnostics.WarningCount, failOnWarnings); + + return new Dictionary + { + ["passed"] = passed, + ["failOnWarnings"] = failOnWarnings, + ["requestedPaths"] = requestedPaths.Cast().ToList(), + ["assetCount"] = assetReports.Count, + ["fixtureCount"] = fixtureReports.Count, + ["errorCount"] = diagnostics.ErrorCount, + ["warningCount"] = diagnostics.WarningCount, + ["diagnosticCount"] = diagnostics.ErrorCount + diagnostics.WarningCount, + ["diagnosticsTruncated"] = diagnostics.Truncated, + ["diagnostics"] = diagnostics.Items, + ["assets"] = assetReports, + ["fixtures"] = fixtureReports + }; + } + + private static void CollectRequestedPath( + string requestedPath, + SortedSet assets, + List fixtureReports, + DiagnosticCollector diagnostics) + { + string assetPath; + try + { + assetPath = NormalizeProjectPath(requestedPath); + } + catch (ArgumentException ex) + { + diagnostics.Add("error", "UI_PATH_INVALID", requestedPath, "request", ex.Message); + return; + } + + if (AssetDatabase.IsValidFolder(assetPath)) + { + var childPaths = AssetDatabase.FindAssets(string.Empty, new[] { assetPath }) + .Select(AssetDatabase.GUIDToAssetPath) + .OrderBy(path => path, StringComparer.Ordinal); + foreach (var childPath in childPaths) + { + if (IsUiAssetPath(childPath)) + assets.Add(childPath); + else if (IsFixturePath(childPath)) + CollectFixture(childPath, assets, fixtureReports, diagnostics); + } + + return; + } + + if (IsFixturePath(assetPath)) + { + CollectFixture(assetPath, assets, fixtureReports, diagnostics); + return; + } + + if (!IsUiAssetPath(assetPath)) + { + diagnostics.Add( + "error", + "UI_PATH_UNSUPPORTED", + assetPath, + "request", + "Expected a .uxml, .uss, .ucp-ui.json, or project folder path"); + return; + } + + if (!AssetExists(assetPath)) + { + diagnostics.Add("error", "UI_ASSET_NOT_FOUND", assetPath, "request", "Asset not found"); + return; + } + + assets.Add(assetPath); + } + + private static void CollectFixture( + string fixturePath, + SortedSet assets, + List fixtureReports, + DiagnosticCollector diagnostics) + { + if (!AssetExists(fixturePath)) + { + diagnostics.Add("error", "UI_FIXTURE_NOT_FOUND", fixturePath, "fixture", "Fixture not found"); + return; + } + + try + { + var report = UiScenarioLoader.ValidateFixture(fixturePath); + fixtureReports.Add(report); + + if (report.TryGetValue("dependencies", out var dependenciesValue) && + dependenciesValue is IList dependencies) + { + foreach (var dependencyValue in dependencies) + { + if (dependencyValue is not Dictionary dependency || + !dependency.TryGetValue("path", out var pathValue) || + pathValue == null) + { + continue; + } + + var dependencyPath = pathValue.ToString(); + if (IsUiAssetPath(dependencyPath)) + assets.Add(dependencyPath); + } + } + } + catch (UiScenarioException ex) + { + fixtureReports.Add(new Dictionary + { + ["valid"] = false, + ["path"] = fixturePath, + ["error"] = ex.ToDictionary() + }); + diagnostics.Add("error", ex.Code, fixturePath, "fixture", ex.Message, ex.Location); + } + catch (Exception ex) + { + fixtureReports.Add(new Dictionary + { + ["valid"] = false, + ["path"] = fixturePath + }); + diagnostics.Add("error", "UI_FIXTURE_INVALID", fixturePath, "fixture", ex.Message); + } + } + + private static Dictionary InspectAsset( + string assetPath, + DiagnosticCollector diagnostics) + { + var report = new Dictionary + { + ["path"] = assetPath, + ["guid"] = AssetDatabase.AssetPathToGUID(assetPath), + ["assetType"] = null, + ["importerType"] = null, + ["contentHash"] = string.Empty, + ["importedWithErrors"] = false, + ["importedWithWarnings"] = false, + ["cloneAttempted"] = false, + ["cloneSucceeded"] = false, + ["cloneElementCount"] = 0, + ["dependencies"] = new List() + }; + + if (!AssetExists(assetPath)) + { + diagnostics.Add("error", "UI_ASSET_NOT_FOUND", assetPath, "request", "Asset not found"); + return report; + } + + try + { + AssetDatabase.ImportAsset( + assetPath, + ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + report["contentHash"] = AssetDatabase.GetAssetDependencyHash(assetPath).ToString(); + } + catch (Exception ex) + { + diagnostics.Add("error", "UI_IMPORT_FAILED", assetPath, "import", ex.Message); + return report; + } + + var dependencies = AssetDatabase.GetDependencies(assetPath, true) + .Where(IsUiAssetPath) + .OrderBy(value => value, StringComparer.Ordinal) + .Cast() + .ToList(); + report["dependencies"] = dependencies; + + var importer = AssetImporter.GetAtPath(assetPath); + report["importerType"] = importer == null ? null : importer.GetType().FullName; + + var importHadError = false; + var importHadWarning = false; + var importLog = AssetImporter.GetImportLog(assetPath); + if (importLog?.logEntries != null) + { + foreach (var entry in importLog.logEntries) + { + var severity = ClassifyImportSeverity(entry.flags.ToString()); + if (severity == null) + continue; + + importHadError |= severity == "error"; + importHadWarning |= severity == "warning"; + diagnostics.Add( + severity, + severity == "error" ? "UI_IMPORT_ERROR" : "UI_IMPORT_WARNING", + assetPath, + "import", + entry.message, + entry.line, + entry.file, + entry.context == null ? null : entry.context.name); + } + } + + var asset = AssetDatabase.LoadMainAssetAtPath(assetPath); + report["assetType"] = asset == null ? null : asset.GetType().FullName; + if (asset == null) + { + diagnostics.Add("error", "UI_ASSET_LOAD_FAILED", assetPath, "import", "Asset did not load"); + return report; + } + + var importedWithErrors = false; + var importedWithWarnings = false; + + if (asset is StyleSheet styleSheet) + { + importedWithErrors = styleSheet.importedWithErrors; + importedWithWarnings = styleSheet.importedWithWarnings; + report["contentHash"] = styleSheet.contentHash.ToString(); + } + else if (asset is VisualTreeAsset treeAsset) + { + importedWithErrors = treeAsset.importedWithErrors; + importedWithWarnings = treeAsset.importedWithWarnings; + report["contentHash"] = treeAsset.contentHash.ToString(); + ProbeClone(treeAsset, assetPath, report, diagnostics); + } + + report["importedWithErrors"] = importedWithErrors; + report["importedWithWarnings"] = importedWithWarnings; + + if (importedWithErrors && !importHadError) + { + diagnostics.Add( + "error", + "UI_IMPORT_ERROR", + assetPath, + "import", + "Unity reports that the asset imported with errors"); + } + + if (importedWithWarnings && !importHadWarning) + { + diagnostics.Add( + "warning", + "UI_IMPORT_WARNING", + assetPath, + "import", + "Unity reports that the asset imported with warnings"); + } + + return report; + } + + private static void ProbeClone( + VisualTreeAsset treeAsset, + string assetPath, + Dictionary report, + DiagnosticCollector diagnostics) + { + report["cloneAttempted"] = true; + var cloneRoot = new VisualElement(); + + void CaptureCloneLog(string message, string stackTrace, LogType type) + { + var severity = ClassifyLogSeverity(type); + if (severity == null) + return; + + diagnostics.Add( + severity, + severity == "error" ? "UI_UXML_CLONE_ERROR" : "UI_UXML_CLONE_WARNING", + assetPath, + "clone", + message); + } + + Application.logMessageReceived += CaptureCloneLog; + try + { + treeAsset.CloneTree(cloneRoot); + report["cloneSucceeded"] = true; + report["cloneElementCount"] = CountElements(cloneRoot); + } + catch (Exception ex) + { + report["cloneSucceeded"] = false; + diagnostics.Add( + "error", + "UI_UXML_CLONE_EXCEPTION", + assetPath, + "clone", + ex.GetBaseException().Message); + } + finally + { + Application.logMessageReceived -= CaptureCloneLog; + } + } + + private static int CountElements(VisualElement root) + { + var count = 1; + for (var index = 0; index < root.hierarchy.childCount; index++) + count += CountElements(root.hierarchy[index]); + return count; + } + + private static string NormalizeProjectPath(string value) + { + return UiScenarioLoader.NormalizeAssetPath(value, null, "$request.paths"); + } + + private static bool AssetExists(string assetPath) + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + return AssetDatabase.LoadMainAssetAtPath(assetPath) != null || + File.Exists(Path.Combine(projectRoot, assetPath)); + } + + private static bool IsUiAssetPath(string path) + { + return path != null && + (path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".uss", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsFixturePath(string path) + { + return path != null && path.EndsWith(".ucp-ui.json", StringComparison.OrdinalIgnoreCase); + } + + internal static bool Passes(int errorCount, int warningCount, bool failOnWarnings) + { + return errorCount == 0 && (!failOnWarnings || warningCount == 0); + } + + internal static string ClassifyImportSeverity(string flags) + { + if (string.IsNullOrEmpty(flags)) + return null; + if (flags.IndexOf("error", StringComparison.OrdinalIgnoreCase) >= 0) + return "error"; + if (flags.IndexOf("warning", StringComparison.OrdinalIgnoreCase) >= 0) + return "warning"; + return null; + } + + internal static string ClassifyLogSeverity(LogType type) + { + if (type == LogType.Warning) + return "warning"; + if (type == LogType.Error || type == LogType.Assert || type == LogType.Exception) + return "error"; + return null; + } + + private static List ReadStringList(object value, string field) + { + if (value is not List values) + throw new ArgumentException($"'{field}' must be an array of strings"); + + var result = new List(values.Count); + foreach (var item in values) + { + if (item is not string text || string.IsNullOrWhiteSpace(text)) + throw new ArgumentException($"'{field}' must contain only non-empty strings"); + result.Add(text); + } + + return result; + } + + private static bool ReadBool(Dictionary values, string field, bool fallback) + { + if (!values.TryGetValue(field, out var value) || value == null) + return fallback; + if (value is bool boolean) + return boolean; + throw new ArgumentException($"'{field}' must be a boolean"); + } + + private static int ReadInt( + Dictionary values, + string field, + int fallback, + int minimum, + int maximum) + { + if (!values.TryGetValue(field, out var value) || value == null) + return fallback; + + int parsed; + try + { + parsed = Convert.ToInt32(value); + } + catch (Exception) + { + throw new ArgumentException($"'{field}' must be an integer"); + } + + if (parsed < minimum || parsed > maximum) + throw new ArgumentException($"'{field}' must be between {minimum} and {maximum}"); + return parsed; + } + + private sealed class DiagnosticCollector + { + private readonly int _limit; + + internal DiagnosticCollector(int limit) + { + _limit = limit; + } + + internal int ErrorCount { get; private set; } + internal int WarningCount { get; private set; } + internal bool Truncated => ErrorCount + WarningCount > Items.Count; + internal List Items { get; } = new(); + + internal void Add( + string severity, + string code, + string assetPath, + string source, + string message, + object line = null, + string file = null, + string context = null) + { + if (severity == "error") + ErrorCount++; + else if (severity == "warning") + WarningCount++; + + if (Items.Count >= _limit) + return; + + Items.Add(new Dictionary + { + ["severity"] = severity, + ["code"] = code, + ["assetPath"] = assetPath, + ["source"] = source, + ["message"] = message, + ["line"] = line, + ["file"] = file, + ["context"] = context + }); + } + } + } +} +#endif diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs.meta b/unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs.meta new file mode 100644 index 0000000..b6b16d9 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6f78a4f06bc34cd4b9be3c4a0d59e241 diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs new file mode 100644 index 0000000..3e2971b --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs @@ -0,0 +1,847 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace UCP.Bridge +{ + /// + /// Runs UI Toolkit work over editor update frames so layout and repaint are never + /// blocked by the initiating JSON-RPC handler. Operations are serialized because + /// the capture backend requires a focused editor window. + /// + internal static class UiOperationManager + { + private const int MaxRetainedOperations = 16; + private const double CompletedTtlSeconds = 300.0; + // Hard ceiling from enqueue through every requested state, independent of + // each state's settle timeout. + internal const double MaxOperationDurationSeconds = 300.0; + private static readonly Dictionary Operations = + new Dictionary(StringComparer.Ordinal); + private static readonly Queue Queue = new Queue(); + private static UiRenderOperation _active; + + static UiOperationManager() + { + EditorApplication.update += Tick; + AssemblyReloadEvents.beforeAssemblyReload += Shutdown; + EditorApplication.quitting += Shutdown; + } + + internal static Dictionary Start( + string operation, + Dictionary parameters) + { + if (Operations.Count >= MaxRetainedOperations) + { + PruneCompleted(true); + if (Operations.Count >= MaxRetainedOperations) + throw new ArgumentException("Too many retained UI operations; wait for an active operation to finish"); + } + + var operationId = "ui-" + Guid.NewGuid().ToString("N").Substring(0, 12); + var record = new UiOperationRecord(operationId, operation, parameters); + Operations.Add(operationId, record); + Queue.Enqueue(operationId); + return new Dictionary + { + ["status"] = "started", + ["operationId"] = operationId, + ["operation"] = operation + }; + } + + internal static Dictionary Status(string operationId) + { + if (string.IsNullOrWhiteSpace(operationId)) + throw new ArgumentException("Missing 'operationId' parameter"); + + if (!Operations.TryGetValue(operationId, out var record)) + { + return new Dictionary + { + ["found"] = false, + ["operationId"] = operationId + }; + } + + var envelope = record.Envelope(); + envelope["found"] = true; + return envelope; + } + + internal static void ResetForTests() + { + Shutdown(); + } + + internal static void TickForTests() + { + Tick(); + } + + private static void Tick() + { + PruneCompleted(false); + ExpireQueuedOperations(); + if (_active == null) + ActivateNext(); + if (_active == null) + return; + + try + { + _active.Tick(); + } + catch (Exception exception) + { + _active.Fail(exception); + } + + if (!_active.IsTerminal) + return; + + var completed = _active; + _active = null; + var completionError = completed.Error; + try + { + completed.Dispose(); + } + catch (Exception exception) + { + var cleanupError = UiRenderOperation.BuildError(exception); + if (completionError == null) + { + completionError = cleanupError; + } + else + { + completionError = new Dictionary(completionError, StringComparer.Ordinal) + { + ["cleanupError"] = cleanupError + }; + } + } + var record = Operations[completed.OperationId]; + CompleteAndBroadcast(record, completed.Result, completionError); + } + + private static void ActivateNext() + { + while (Queue.Count > 0) + { + var id = Queue.Dequeue(); + if (!Operations.TryGetValue(id, out var record) || record.IsTerminal) + continue; + + record.MarkRunning(); + try + { + _active = new UiRenderOperation(record); + return; + } + catch (Exception exception) + { + _active = null; + CompleteAndBroadcast(record, null, UiRenderOperation.BuildError(exception)); + } + } + } + + private static void ExpireQueuedOperations() + { + var count = Queue.Count; + var now = EditorApplication.timeSinceStartup; + for (var index = 0; index < count; index++) + { + var id = Queue.Dequeue(); + if (!Operations.TryGetValue(id, out var record) || record.IsTerminal) + continue; + + var elapsed = now - record.StartedAt; + if (HasExceededOperationDuration(record.StartedAt, now)) + { + CompleteAndBroadcast( + record, + null, + UiRenderOperation.BuildError(CreateOperationTimeout( + record.Operation, + 0, + elapsed))); + continue; + } + + Queue.Enqueue(id); + } + } + + internal static bool HasExceededOperationDuration(double startedAt, double now) + { + return now - startedAt > MaxOperationDurationSeconds; + } + + internal static UiOperationException CreateOperationTimeout( + string operation, + int completedStateCount, + double elapsed) + { + return new UiOperationException( + "timeout", + $"UI operation '{operation}' exceeded the {MaxOperationDurationSeconds:F0}s overall duration limit", + new Dictionary + { + ["scope"] = "operation", + ["timeoutSeconds"] = MaxOperationDurationSeconds, + ["durationSeconds"] = UiValue.FiniteOrNull(elapsed), + ["completedStateCount"] = completedStateCount + }); + } + + private static void CompleteAndBroadcast( + UiOperationRecord record, + object result, + Dictionary error) + { + record.Complete(result, error); + BridgeServer.BroadcastNotification("ui/result", record.Envelope()); + + if (record.Error != null && + record.Error.TryGetValue("code", out var code) && + (string.Equals(code?.ToString(), "internal_error", StringComparison.Ordinal) || + string.Equals(code?.ToString(), "cleanup_failed", StringComparison.Ordinal))) + { + Debug.LogError($"[UCP] ui/{record.Operation} operation {record.OperationId} failed: " + + record.Error["message"]); + } + } + + private static void PruneCompleted(bool force) + { + var now = EditorApplication.timeSinceStartup; + var expired = Operations + .Where(pair => pair.Value.IsTerminal && + (force || now - pair.Value.CompletedAt >= CompletedTtlSeconds)) + .Select(pair => pair.Key) + .ToList(); + foreach (var id in expired) + Operations.Remove(id); + } + + private static void Shutdown() + { + var active = _active; + _active = null; + try + { + active?.Dispose(); + } + catch + { + // Reload and quit must continue after best-effort cleanup. + } + finally + { + Queue.Clear(); + Operations.Clear(); + } + } + } + + internal sealed class UiOperationRecord + { + internal UiOperationRecord( + string operationId, + string operation, + Dictionary parameters) + { + OperationId = operationId; + Operation = operation; + Parameters = parameters != null + ? new Dictionary(parameters, StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal); + Status = "queued"; + StartedAtUtc = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); + StartedAt = EditorApplication.timeSinceStartup; + } + + internal string OperationId { get; } + internal string Operation { get; } + internal Dictionary Parameters { get; } + internal string Status { get; private set; } + internal object Result { get; private set; } + internal Dictionary Error { get; private set; } + internal string StartedAtUtc { get; } + internal double StartedAt { get; } + internal double CompletedAt { get; private set; } + internal bool IsTerminal => Status == "completed" || Status == "failed"; + + internal void MarkRunning() + { + Status = "running"; + } + + internal void Complete(object result, Dictionary error) + { + Result = UiJsonSanitizer.Sanitize(result); + Error = error == null + ? null + : (Dictionary)UiJsonSanitizer.Sanitize(error); + Status = Error == null ? "completed" : "failed"; + CompletedAt = EditorApplication.timeSinceStartup; + } + + internal Dictionary Envelope() + { + var envelope = new Dictionary + { + ["operationId"] = OperationId, + ["operation"] = Operation, + ["status"] = Status, + ["startedAtUtc"] = StartedAtUtc + }; + if (Result != null) + envelope["result"] = Result; + if (Error != null) + envelope["error"] = Error; + return envelope; + } + } + + internal sealed class UiRenderOperation : IDisposable + { + private enum Phase + { + Initialize, + OpenScenario, + Settle, + Terminal + } + + private readonly UiOperationRecord _record; + private readonly bool _capture; + private readonly bool _inspect; + private readonly bool _check; + private readonly bool _failOnWarnings; + private UiInspectOptions _inspectOptions; + private readonly List _stateResults = new List(); + private Phase _phase; + private List _scenarios; + private int _scenarioIndex; + private UiResolvedScenario _scenario; + private UiApplyReport _applyReport; + private UiHostWindow _host; + private UiCaptureSurface _captureSurface; + private EditorWindow _previousFocusedWindow; + private UiGeometrySample _geometry; + private string _lastGeometryHash; + private int _geometryStableFrames; + private string _lastPixelHash; + private int _pixelStableFrames; + private UiCaptureSample _latestCapture; + private double _scenarioStartedAt; + private int _warmupFrames; + private Dictionary _lintResult; + + internal UiRenderOperation(UiOperationRecord record) + { + _record = record; + _capture = record.Operation == "screenshot" || record.Operation == "check"; + _inspect = record.Operation == "inspect" || record.Operation == "check"; + _check = record.Operation == "check"; + _failOnWarnings = ReadBool(record.Parameters, "failOnWarnings", false); + } + + internal string OperationId => _record.OperationId; + internal object Result { get; private set; } + internal Dictionary Error { get; private set; } + internal bool IsTerminal => _phase == Phase.Terminal; + + internal void Tick() + { + var now = EditorApplication.timeSinceStartup; + if (UiOperationManager.HasExceededOperationDuration(_record.StartedAt, now)) + { + throw UiOperationManager.CreateOperationTimeout( + _record.Operation, + _stateResults.Count, + now - _record.StartedAt); + } + + switch (_phase) + { + case Phase.Initialize: + Initialize(); + break; + case Phase.OpenScenario: + OpenScenario(); + break; + case Phase.Settle: + Settle(); + break; + } + } + + internal void Fail(Exception exception) + { + if (IsTerminal) + return; + + Error = BuildError(exception); + _phase = Phase.Terminal; + } + + private void Initialize() + { + UiHostCapabilities.EnsureAvailable(_record.Operation); + _inspectOptions = _inspect ? UiInspectOptions.Parse(_record.Parameters) : null; + + if (_check) + { + var lintParameters = new Dictionary + { + ["paths"] = new List { ReadRequiredString(_record.Parameters, "target") }, + ["failOnWarnings"] = _failOnWarnings + }; + if (_record.Parameters.TryGetValue("maxDiagnostics", out var maxDiagnostics)) + lintParameters["maxDiagnostics"] = maxDiagnostics; + _lintResult = UiLintService.Run(lintParameters); + } + + var allStates = ReadBool(_record.Parameters, "allStates", false); + _scenarios = UiScenarioLoader.Resolve(_record.Parameters, allStates); + if (_scenarios == null || _scenarios.Count == 0) + throw new ArgumentException("No UI scenarios matched the request"); + + _previousFocusedWindow = EditorWindow.focusedWindow; + _scenarioIndex = 0; + _phase = Phase.OpenScenario; + } + + private void OpenScenario() + { + DisposeScenario(); + _scenario = _scenarios[_scenarioIndex]; + _host = UiHostWindow.Open(_scenario.Viewport.Width, _scenario.Viewport.Height); + _scenario.Document.CloneTree(_host.ContentRoot); + _applyReport = UiScenarioApplier.Apply(_host.ContentRoot, _scenario); + if (_capture) + { + _captureSurface = new UiCaptureSurface( + _scenario.Viewport.Width, + _scenario.Viewport.Height); + } + + _scenarioStartedAt = EditorApplication.timeSinceStartup; + _lastGeometryHash = null; + _geometryStableFrames = 0; + _lastPixelHash = null; + _pixelStableFrames = 0; + _latestCapture = null; + _warmupFrames = 0; + _phase = Phase.Settle; + } + + private void Settle() + { + if (_host == null) + throw new InvalidOperationException("The transient UI host window was closed before the operation completed"); + + var elapsed = EditorApplication.timeSinceStartup - _scenarioStartedAt; + if (elapsed > _scenario.Settle.TimeoutSeconds) + { + throw new UiOperationException( + "timeout", + $"UI state '{_scenario.StateName}' did not settle within " + + $"{_scenario.Settle.TimeoutSeconds:F1}s", + new Dictionary + { + ["scenario"] = _scenario.ToDictionary(), + ["geometry"] = _geometry.ToDictionary(_geometryStableFrames), + ["pixelStableFrames"] = _pixelStableFrames, + ["completedStateCount"] = _stateResults.Count + }); + } + + _host.Pump(); + _warmupFrames++; + _geometry = UiGeometrySampler.Measure(_host.ContentRoot); + var geometryValid = _warmupFrames >= 2 && _host.hasFocus && _geometry.IsValid; + if (geometryValid && string.Equals(_lastGeometryHash, _geometry.Hash, StringComparison.Ordinal)) + _geometryStableFrames++; + else + _geometryStableFrames = geometryValid ? 1 : 0; + _lastGeometryHash = _geometry.Hash; + + if (_geometryStableFrames < _scenario.Settle.StableFrames) + { + ResetPixelStability(); + return; + } + + if (_capture) + { + _latestCapture = _captureSurface.Capture(_host); + if (string.Equals(_lastPixelHash, _latestCapture.PixelHash, StringComparison.Ordinal)) + _pixelStableFrames++; + else + _pixelStableFrames = 1; + _lastPixelHash = _latestCapture.PixelHash; + + if (_pixelStableFrames < _scenario.Settle.PixelStableFrames) + return; + } + + CompleteScenario(elapsed); + } + + private void CompleteScenario(double elapsed) + { + var metadata = UiScenarioApplier.GetCollectionMetadata(_host.ContentRoot); + var stateResult = new Dictionary + { + ["scenario"] = _scenario.ToDictionary(), + ["apply"] = _applyReport.ToDictionary(), + ["settle"] = BuildSettleResult(elapsed) + }; + + Dictionary audit = null; + if (_inspect) + { + stateResult["snapshot"] = UiVisualTreeInspector.Inspect( + _host.ContentRoot, + _inspectOptions, + metadata); + } + + if (_check) + { + audit = UiAudit.Run(_host.ContentRoot, _applyReport, metadata); + stateResult["audit"] = audit; + } + + if (_capture) + { + var artifactPath = WriteCaptureArtifact(_latestCapture.Png); + var capture = new Dictionary + { + ["artifactPath"] = artifactPath, + ["width"] = _latestCapture.Width, + ["height"] = _latestCapture.Height, + ["pixelHash"] = _latestCapture.PixelHash, + ["pixelStableFrames"] = _pixelStableFrames, + ["sampledDistinctColors"] = _latestCapture.SampledDistinctColors, + ["nonTransparentPixels"] = _latestCapture.NonTransparentPixels, + ["pixelsPerPoint"] = UiValue.FiniteOrNull(EditorGUIUtility.pixelsPerPoint), + ["compositorScale"] = UiValue.FiniteOrNull( + 1f / Mathf.Max(1f, EditorGUIUtility.pixelsPerPoint)), + ["graphicsDeviceType"] = SystemInfo.graphicsDeviceType.ToString() + }; + stateResult["capture"] = capture; + stateResult["artifactPath"] = artifactPath; + stateResult["width"] = _latestCapture.Width; + stateResult["height"] = _latestCapture.Height; + stateResult["pixelHash"] = _latestCapture.PixelHash; + } + + if (_check) + { + var auditPassed = ReadBool(audit, "passed", false); + var warnings = audit != null && audit.TryGetValue("warningCount", out var warningValue) + ? Convert.ToInt32(warningValue) + : 0; + stateResult["passed"] = auditPassed && (!_failOnWarnings || warnings == 0); + } + + _stateResults.Add(stateResult); + _scenarioIndex++; + Result = BuildResult(); + DisposeScenario(); + if (_scenarioIndex < _scenarios.Count) + { + _phase = Phase.OpenScenario; + return; + } + + _phase = Phase.Terminal; + } + + private Dictionary BuildSettleResult(double elapsed) + { + var settle = _geometry.ToDictionary(_geometryStableFrames); + settle["pixelStableFrames"] = _pixelStableFrames; + settle["durationSeconds"] = UiValue.FiniteOrNull(elapsed); + settle["focused"] = _host != null && _host.hasFocus; + return settle; + } + + private Dictionary BuildResult() + { + var result = new Dictionary + { + ["operation"] = _record.Operation, + ["target"] = ReadRequiredString(_record.Parameters, "target"), + ["stateCount"] = _stateResults.Count, + ["states"] = _stateResults, + ["capabilities"] = UiHostCapabilities.Describe() + }; + + if (_lintResult != null) + result["lint"] = _lintResult; + + if (_stateResults.Count == 1 && _stateResults[0] is Dictionary onlyState) + { + foreach (var pair in onlyState) + result[pair.Key] = pair.Value; + } + + if (_check) + { + var lintPassed = _lintResult != null && ReadBool(_lintResult, "passed", false); + var statesPassed = _stateResults.All(state => + state is Dictionary dictionary && ReadBool(dictionary, "passed", false)); + result["passed"] = lintPassed && statesPassed; + var errorCount = ReadCount(_lintResult, "errorCount"); + var warningCount = ReadCount(_lintResult, "warningCount"); + foreach (var state in _stateResults.OfType>()) + { + if (state.TryGetValue("audit", out var auditObject) && + auditObject is Dictionary audit) + { + errorCount += ReadCount(audit, "errorCount"); + warningCount += ReadCount(audit, "warningCount"); + } + } + result["errorCount"] = errorCount; + result["warningCount"] = warningCount; + } + + var elementCount = 0; + foreach (var state in _stateResults.OfType>()) + { + if (state.TryGetValue("snapshot", out var snapshotObject) && + snapshotObject is Dictionary snapshot) + elementCount += ReadCount(snapshot, "returnedElementCount"); + } + if (_inspect) + result["elementCount"] = elementCount; + + return result; + } + + private string WriteCaptureArtifact(byte[] png) + { + if (png == null || png.Length == 0) + throw new InvalidOperationException("No PNG data was available after capture settled"); + + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var outputDirectory = Path.Combine(projectRoot, "Library", "UCP", "UiCaptures"); + Directory.CreateDirectory(outputDirectory); + var path = Path.Combine(outputDirectory, CaptureArtifactFileName( + _scenario.TargetPath, + _scenario.StateName, + _scenarioIndex, + OperationId)); + File.WriteAllBytes(path, png); + return UiValue.NormalizePath(Path.GetFullPath(path)); + } + + internal static string CaptureArtifactFileName( + string targetPath, + string stateName, + int scenarioIndex, + string operationId) + { + var targetToken = UiValue.SafeFileName(Path.GetFileNameWithoutExtension(targetPath)); + var stateToken = UiValue.SafeFileName(stateName); + return $"{targetToken}-{stateToken}-s{scenarioIndex:D4}-{operationId}.png"; + } + + private void ResetPixelStability() + { + _lastPixelHash = null; + _pixelStableFrames = 0; + _latestCapture = null; + } + + private void DisposeScenario() + { + var captureSurface = _captureSurface; + _captureSurface = null; + var host = _host; + _host = null; + + var error = UiCleanup.RunAll( + () => captureSurface?.Dispose(), + () => UiHostWindow.CloseAndDestroy(host)); + if (error != null) + { + throw new UiOperationException( + "cleanup_failed", + "The UI harness could not release all transient resources", + new Dictionary + { + ["exceptionType"] = error.GetType().FullName, + ["message"] = error.Message + }); + } + } + + public void Dispose() + { + var previousFocusedWindow = _previousFocusedWindow; + _previousFocusedWindow = null; + var error = UiCleanup.RunAll( + DisposeScenario, + () => + { + if (previousFocusedWindow == null) + return; + try + { + previousFocusedWindow.Focus(); + } + catch + { + // The previously focused window may have been closed during the operation. + } + }); + if (error != null) + throw error; + } + + internal static Dictionary BuildError(Exception exception) + { + string code; + object details = null; + if (exception is UiOperationException operationException) + { + code = operationException.Code; + details = operationException.Details; + } + else if (exception is UiScenarioException scenarioException) + { + code = scenarioException.Code; + details = new Dictionary + { + ["location"] = scenarioException.Location + }; + } + else if (exception is ArgumentException) + { + code = "invalid_params"; + } + else + { + code = "internal_error"; + details = new Dictionary + { + ["exceptionType"] = exception.GetType().FullName + }; + } + + var error = new Dictionary + { + ["code"] = code, + ["message"] = exception.Message + }; + if (details != null) + error["details"] = details; + return (Dictionary)UiJsonSanitizer.Sanitize(error); + } + + private static bool ReadBool( + Dictionary parameters, + string key, + bool defaultValue) + { + if (parameters == null || !parameters.TryGetValue(key, out var value) || value == null) + return defaultValue; + if (value is bool boolean) + return boolean; + if (bool.TryParse(value.ToString(), out var parsed)) + return parsed; + throw new ArgumentException($"'{key}' must be a boolean"); + } + + private static string ReadRequiredString(Dictionary parameters, string key) + { + if (parameters == null || !parameters.TryGetValue(key, out var value) || + string.IsNullOrWhiteSpace(value?.ToString())) + throw new ArgumentException($"Missing '{key}' parameter"); + return value.ToString(); + } + + private static int ReadCount(Dictionary values, string key) + { + if (values == null || !values.TryGetValue(key, out var value) || value == null) + return 0; + try + { + return Convert.ToInt32(value); + } + catch + { + return 0; + } + } + } + + internal static class UiJsonSanitizer + { + internal static object Sanitize(object value) + { + if (value == null || value is string || value is bool || value is int || value is long) + return value; + if (value is char character) + return character.ToString(); + if (value is byte || value is sbyte || value is short || value is ushort) + return Convert.ToInt32(value, CultureInfo.InvariantCulture); + if (value is uint unsignedInteger) + return (long)unsignedInteger; + if (value is ulong unsignedLong) + return unsignedLong <= long.MaxValue + ? (object)(long)unsignedLong + : unsignedLong.ToString(CultureInfo.InvariantCulture); + if (value is decimal decimalValue) + return decimalValue.ToString(CultureInfo.InvariantCulture); + if (value is IntPtr pointer) + return pointer.ToInt64(); + if (value is UIntPtr unsignedPointer) + { + var numericValue = unsignedPointer.ToUInt64(); + return numericValue <= long.MaxValue + ? (object)(long)numericValue + : numericValue.ToString(CultureInfo.InvariantCulture); + } + if (value is float floatValue) + return UiValue.FiniteOrNull(floatValue); + if (value is double doubleValue) + return UiValue.FiniteOrNull(doubleValue); + if (value is Enum enumValue) + return enumValue.ToString(); + if (value is IDictionary dictionary) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (DictionaryEntry pair in dictionary) + result[pair.Key?.ToString() ?? "null"] = Sanitize(pair.Value); + return result; + } + if (value is IEnumerable enumerable) + { + var result = new List(); + foreach (var item in enumerable) + result.Add(Sanitize(item)); + return result; + } + return value.ToString(); + } + } +} +#endif diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs.meta b/unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs.meta new file mode 100644 index 0000000..9cc6882 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3212acd0da8b4de4acec109d5282a505 diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs new file mode 100644 index 0000000..fdc717f --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs @@ -0,0 +1,496 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using Unity.Properties; +using UnityEngine; +using UnityEngine.UIElements; + +namespace UCP.Bridge +{ + internal static class UiScenarioApplier + { + private static readonly ConditionalWeakTable AppliedStates = + new ConditionalWeakTable(); + + private static bool _propertyBagsRegistered; + + public static UiApplyReport Apply(VisualElement root, UiResolvedScenario scenario) + { + if (root == null) + throw new ArgumentNullException(nameof(root)); + if (scenario == null) + throw new ArgumentNullException(nameof(scenario)); + + EnsurePropertyBags(); + AppliedStates.Remove(root); + + var report = new UiApplyReport(); + report.BindingRewriteCount = RewriteBindingsToDictionaryKeys(root); + root.dataSource = scenario.Data; + AddLargeIntegerDiagnostics(scenario.Data, report); + + foreach (var operation in scenario.SetOperations) + { + var target = ResolveSingle(root, operation.Selector, operation.Location); + ApplySet(target, operation); + report.SetCount++; + } + + var state = new AppliedState(); + foreach (var collection in scenario.Collections) + { + var target = ResolveSingle(root, collection.Selector, collection.Location); + var items = ResolveCollectionSource(scenario.Data, collection.Source, collection.Location); + var metadata = new UiCollectionMetadata( + collection.Selector, + collection.Mode, + collection.Source, + items.Count, + target, + root); + + if (collection.Mode == UiCollectionMode.Repeat) + ApplyRepeat(target, items, collection, metadata, report); + else + ApplyListView(target, items, collection, metadata, report); + + state.Collections.Add(metadata); + report.AddCollection(metadata); + } + + AppliedStates.Add(root, state); + return report; + } + + public static IReadOnlyList GetCollectionMetadata(VisualElement root) + { + if (root != null && AppliedStates.TryGetValue(root, out var state)) + return state.Collections; + return Array.Empty(); + } + + private static void ApplyRepeat( + VisualElement target, + IList items, + UiCollectionDefinition definition, + UiCollectionMetadata metadata, + UiApplyReport report) + { + if (target is ListView) + throw Error("ui.collection-target-type", "repeat collections cannot target a ListView.", definition.Location + ".target"); + + target.Clear(); + for (var i = 0; i < items.Count; i++) + { + var row = definition.Template.CloneTree(); + report.BindingRewriteCount += RewriteBindingsToDictionaryKeys(row); + row.dataSource = items[i]; + target.Add(row); + metadata.Bind(row, i); + report.RepeatItemCount++; + } + } + + private static void ApplyListView( + VisualElement target, + IList items, + UiCollectionDefinition definition, + UiCollectionMetadata metadata, + UiApplyReport report) + { + if (!(target is ListView listView)) + throw Error("ui.collection-target-type", "list-view collections must target a ListView element.", definition.Location + ".target"); + + listView.makeItem = () => + { + var row = definition.Template.CloneTree(); + report.BindingRewriteCount += RewriteBindingsToDictionaryKeys(row); + metadata.Realize(row); + return row; + }; + listView.bindItem = (row, index) => + { + if (index < 0 || index >= items.Count) + throw new ArgumentOutOfRangeException(nameof(index)); + row.dataSource = items[index]; + metadata.Bind(row, index); + }; + listView.unbindItem = (row, _) => + { + row.dataSource = null; + metadata.Unbind(row); + }; + listView.destroyItem = row => + { + row.dataSource = null; + metadata.Destroy(row); + }; + + if (definition.ItemHeight.HasValue) + { + listView.virtualizationMethod = CollectionVirtualizationMethod.FixedHeight; + listView.fixedItemHeight = definition.ItemHeight.Value; + } + else + { + listView.virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight; + } + + listView.itemsSource = items; + listView.Rebuild(); + } + + private static IList ResolveCollectionSource( + Dictionary data, + string pointer, + string location) + { + object current = data; + if (pointer.Length > 0) + { + var segments = pointer.Substring(1).Split('/'); + foreach (var encodedSegment in segments) + { + var segment = encodedSegment.Replace("~1", "/").Replace("~0", "~"); + if (current is Dictionary dictionary) + { + if (!dictionary.TryGetValue(segment, out current)) + throw Error("ui.collection-source-missing", $"Collection source segment '{segment}' was not found.", location + ".source"); + } + else if (current is IList list) + { + if (!int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out var index) || + index < 0 || index >= list.Count) + { + throw Error("ui.collection-source-index", $"Collection source index '{segment}' is invalid.", location + ".source"); + } + current = list[index]; + } + else + { + throw Error("ui.collection-source-traversal", $"Cannot traverse collection source through '{segment}'.", location + ".source"); + } + } + } + + if (!(current is IList result)) + throw Error("ui.collection-source-type", "Collection source must resolve to a JSON array.", location + ".source"); + return result; + } + + private static VisualElement ResolveSingle(VisualElement root, string selector, string location) + { + if (selector == ":root") + return root; + + var matches = new List(); + CollectMatches(root, selector, matches); + if (matches.Count == 0) + throw Error("ui.selector-not-found", $"Selector '{selector}' did not match any element.", location + ".target"); + if (matches.Count > 1) + throw Error("ui.selector-ambiguous", $"Selector '{selector}' matched {matches.Count} elements; targets must be unique.", location + ".target"); + return matches[0]; + } + + private static void CollectMatches(VisualElement element, string selector, List output) + { + var matches = (selector[0] == '#' && string.Equals(element.name, selector.Substring(1), StringComparison.Ordinal)) || + (selector[0] == '.' && element.ClassListContains(selector.Substring(1))); + if (matches) + output.Add(element); + + foreach (var child in element.Children()) + CollectMatches(child, selector, output); + } + + private static void ApplySet(VisualElement target, UiSetOperation operation) + { + switch (operation.Property) + { + case "text": + if (!(target is TextElement textElement)) + throw SetTypeError(operation, target, "TextElement"); + textElement.text = RequireStringOrNull(operation.Value, operation); + return; + case "value": + ApplyValue(target, operation); + return; + case "enabled": + target.SetEnabled(RequireBool(operation.Value, operation)); + return; + case "display": + target.style.display = ParseDisplay(operation); + return; + case "visibility": + target.style.visibility = ParseVisibility(operation); + return; + case "tooltip": + target.tooltip = RequireStringOrNull(operation.Value, operation); + return; + } + + if (operation.Property.StartsWith("class:", StringComparison.Ordinal)) + { + target.EnableInClassList( + operation.Property.Substring("class:".Length), + RequireBool(operation.Value, operation)); + return; + } + + throw Error("ui.set-property", $"Property '{operation.Property}' is not allowed.", operation.Location + ".property"); + } + + private static void ApplyValue(VisualElement target, UiSetOperation operation) + { + switch (target) + { + case TextField field: + field.SetValueWithoutNotify(RequireStringOrNull(operation.Value, operation)); + return; + case IntegerField field: + field.SetValueWithoutNotify(RequireInt(operation.Value, operation)); + return; + case LongField field: + field.SetValueWithoutNotify(RequireLong(operation.Value, operation)); + return; + case FloatField field: + field.SetValueWithoutNotify(RequireFloat(operation.Value, operation)); + return; + case DoubleField field: + field.SetValueWithoutNotify(RequireDouble(operation.Value, operation)); + return; + case Toggle field: + field.SetValueWithoutNotify(RequireBool(operation.Value, operation)); + return; + case RadioButton field: + field.SetValueWithoutNotify(RequireBool(operation.Value, operation)); + return; + case Foldout field: + field.SetValueWithoutNotify(RequireBool(operation.Value, operation)); + return; + case Slider field: + field.SetValueWithoutNotify(RequireFloat(operation.Value, operation)); + return; + case SliderInt field: + field.SetValueWithoutNotify(RequireInt(operation.Value, operation)); + return; + case DropdownField field: + field.SetValueWithoutNotify(RequireStringOrNull(operation.Value, operation)); + return; + case ProgressBar field: + field.SetValueWithoutNotify(RequireFloat(operation.Value, operation)); + return; + default: + throw SetTypeError(operation, target, "a supported UI Toolkit value control"); + } + } + + private static DisplayStyle ParseDisplay(UiSetOperation operation) + { + var value = RequireStringOrNull(operation.Value, operation); + if (value == "flex") + return DisplayStyle.Flex; + if (value == "none") + return DisplayStyle.None; + throw Error("ui.set-value", "display must be 'flex' or 'none'.", operation.Location + ".value"); + } + + private static Visibility ParseVisibility(UiSetOperation operation) + { + var value = RequireStringOrNull(operation.Value, operation); + if (value == "visible") + return Visibility.Visible; + if (value == "hidden") + return Visibility.Hidden; + throw Error("ui.set-value", "visibility must be 'visible' or 'hidden'.", operation.Location + ".value"); + } + + private static string RequireStringOrNull(object value, UiSetOperation operation) + { + if (value == null || value is string) + return (string)value; + throw Error("ui.set-value-type", $"'{operation.Property}' requires a string or null.", operation.Location + ".value"); + } + + private static bool RequireBool(object value, UiSetOperation operation) + { + if (value is bool result) + return result; + throw Error("ui.set-value-type", $"'{operation.Property}' requires a boolean.", operation.Location + ".value"); + } + + private static int RequireInt(object value, UiSetOperation operation) + { + if (value is int integer) + return integer; + if (value is long longInteger && longInteger >= int.MinValue && longInteger <= int.MaxValue) + return (int)longInteger; + throw Error("ui.set-value-type", "value requires a 32-bit integer for this control.", operation.Location + ".value"); + } + + private static long RequireLong(object value, UiSetOperation operation) + { + if (value is int integer) + return integer; + if (value is long longInteger) + return longInteger; + throw Error("ui.set-value-type", "value requires an integer for this control.", operation.Location + ".value"); + } + + private static float RequireFloat(object value, UiSetOperation operation) + { + var number = RequireDouble(value, operation); + if (number < -float.MaxValue || number > float.MaxValue) + throw Error("ui.set-value-range", "value is outside the supported float range.", operation.Location + ".value"); + return (float)number; + } + + private static double RequireDouble(object value, UiSetOperation operation) + { + double number; + if (value is int integer) + number = integer; + else if (value is long longInteger) + number = longInteger; + else if (value is float single) + number = single; + else if (value is double doubleValue) + number = doubleValue; + else + throw Error("ui.set-value-type", "value requires a number for this control.", operation.Location + ".value"); + + if (double.IsNaN(number) || double.IsInfinity(number)) + throw Error("ui.set-value-range", "value must be finite.", operation.Location + ".value"); + return number; + } + + private static UiScenarioException SetTypeError( + UiSetOperation operation, + VisualElement target, + string expected) + { + return Error( + "ui.set-target-type", + $"Cannot set '{operation.Property}' on {target.GetType().Name}; expected {expected}.", + operation.Location + ".target"); + } + + private static int RewriteBindingsToDictionaryKeys(VisualElement root) + { + var changed = 0; + RewriteBindingsRecursive(root, ref changed); + return changed; + } + + private static void RewriteBindingsRecursive(VisualElement element, ref int changed) + { + var bindings = element.GetBindingInfos().ToList(); + foreach (var bindingInfo in bindings) + { + if (!(bindingInfo.binding is DataBinding dataBinding) || dataBinding.dataSourcePath.IsEmpty) + continue; + + dataBinding.dataSourcePath = ConvertNamesToDictionaryKeys(dataBinding.dataSourcePath); + element.ClearBinding(bindingInfo.bindingId); + element.SetBinding(bindingInfo.bindingId, dataBinding); + changed++; + } + + foreach (var child in element.Children()) + RewriteBindingsRecursive(child, ref changed); + } + + private static PropertyPath ConvertNamesToDictionaryKeys(PropertyPath original) + { + var converted = new PropertyPath(); + for (var i = 0; i < original.Length; i++) + { + var part = original[i]; + if (part.IsName) + { + if (part.Name == "Value" && i > 0 && original[i - 1].IsKey) + { + converted = PropertyPath.AppendName(converted, part.Name); + continue; + } + + converted = PropertyPath.AppendKey(converted, part.Name); + converted = PropertyPath.AppendName(converted, "Value"); + } + else if (part.IsIndex) + { + converted = PropertyPath.AppendIndex(converted, part.Index); + } + else if (part.IsKey) + { + converted = PropertyPath.AppendKey(converted, part.Key); + } + } + return converted; + } + + private static void EnsurePropertyBags() + { + if (_propertyBagsRegistered) + return; + + PropertyBag.RegisterDictionary(); + PropertyBag.RegisterList(); + _propertyBagsRegistered = true; + } + + private static void AddLargeIntegerDiagnostics( + Dictionary data, + UiApplyReport report) + { + var locations = new List(); + FindLargeIntegers(data, "$data", locations, 20); + foreach (var location in locations) + { + report.AddDiagnostic( + "warning", + "ui.integer-64-bit", + "Integer is outside the 32-bit range and was preserved as a 64-bit value; some UI controls cannot bind it.", + location); + } + } + + private static void FindLargeIntegers(object value, string location, List output, int limit) + { + if (output.Count >= limit) + return; + if (value is long integer) + { + if (integer < int.MinValue || integer > int.MaxValue) + output.Add(location); + return; + } + if (value is Dictionary dictionary) + { + foreach (var pair in dictionary) + FindLargeIntegers(pair.Value, location + "." + pair.Key, output, limit); + return; + } + if (value is IList list) + { + for (var i = 0; i < list.Count && output.Count < limit; i++) + FindLargeIntegers(list[i], $"{location}[{i}]", output, limit); + } + } + + private static UiScenarioException Error(string code, string message, string location) + { + return new UiScenarioException(code, message, location); + } + + private sealed class AppliedState + { + public readonly List Collections = new List(); + } + } +} +#endif diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs.meta b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs.meta new file mode 100644 index 0000000..4a18206 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7983a726840b46d1972718808fc28a8b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs new file mode 100644 index 0000000..e40a220 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs @@ -0,0 +1,832 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace UCP.Bridge +{ + internal static class UiScenarioLoader + { + private const int MaxFixtureStates = 64; + private const long MaxViewportPixelArea = 8_388_608L; + + private static readonly HashSet FixtureFields = new HashSet(StringComparer.Ordinal) + { + "schemaVersion", "document", "defaultState", "viewport", "settle", "data", "set", "collections", "states" + }; + + private static readonly HashSet StateFields = new HashSet(StringComparer.Ordinal) + { + "viewport", "settle", "data", "set", "collections" + }; + + private static readonly HashSet ViewportFields = new HashSet(StringComparer.Ordinal) + { + "width", "height" + }; + + private static readonly HashSet SettleFields = new HashSet(StringComparer.Ordinal) + { + "stableFrames", "pixelStableFrames", "timeoutSeconds" + }; + + private static readonly HashSet SetFields = new HashSet(StringComparer.Ordinal) + { + "target", "property", "value" + }; + + private static readonly HashSet CollectionFields = new HashSet(StringComparer.Ordinal) + { + "target", "mode", "source", "template", "itemHeight" + }; + + public static List Resolve( + Dictionary parameters, + bool allStates = false) + { + parameters = parameters ?? new Dictionary(); + var target = RequireString(parameters, "target", "$request"); + var targetPath = NormalizeAssetPath(target, null, "$request.target"); + var requestedState = OptionalString(parameters, "state", "$request"); + + if (allStates && requestedState != null) + throw Error("ui.state-conflict", "'state' and 'allStates' cannot be used together.", "$request"); + + var requestData = OptionalObject(parameters, "data", "$request.data"); + var hasRequestViewport = parameters.TryGetValue("viewport", out var requestViewportValue); + var hasRequestSettle = parameters.TryGetValue("settle", out var requestSettleValue); + + if (targetPath.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase)) + { + if (requestedState != null && !string.Equals(requestedState, "default", StringComparison.Ordinal)) + throw Error("ui.state-not-found", $"Direct UXML targets only expose the 'default' state, not '{requestedState}'.", "$request.state"); + + var document = LoadVisualTree(targetPath, "$request.target"); + return new List + { + new UiResolvedScenario( + targetPath, + null, + "default", + targetPath, + document, + MergeData(null, null, requestData), + Array.Empty(), + Array.Empty(), + hasRequestViewport + ? ParseViewport(requestViewportValue, new UiViewport(), "$request.viewport") + : new UiViewport(), + hasRequestSettle + ? ParseSettle(requestSettleValue, new UiSettleOptions(), "$request.settle") + : new UiSettleOptions()) + }; + } + + if (!targetPath.EndsWith(".ucp-ui.json", StringComparison.OrdinalIgnoreCase)) + throw Error("ui.target-type", "UI targets must end in '.uxml' or '.ucp-ui.json'.", "$request.target"); + + var fixture = ParseFixture(targetPath); + var stateNames = SelectStates(fixture, requestedState, allStates); + var scenarios = new List(stateNames.Count); + foreach (var stateName in stateNames) + { + var state = fixture.States[stateName]; + var stateLocation = $"{targetPath}#states.{stateName}"; + var viewport = state.Viewport ?? fixture.Viewport; + var settle = state.Settle ?? fixture.Settle; + var set = state.SetOperations ?? fixture.SetOperations; + var collections = state.Collections ?? fixture.Collections; + + scenarios.Add(new UiResolvedScenario( + targetPath, + targetPath, + stateName, + fixture.DocumentPath, + fixture.Document, + MergeData(fixture.Data, state.Data, requestData), + set ?? Array.Empty(), + collections ?? Array.Empty(), + hasRequestViewport + ? ParseViewport(requestViewportValue, viewport ?? new UiViewport(), "$request.viewport") + : viewport ?? new UiViewport(), + hasRequestSettle + ? ParseSettle(requestSettleValue, settle ?? new UiSettleOptions(), "$request.settle") + : settle ?? new UiSettleOptions())); + } + + return scenarios; + } + + public static object List(Dictionary parameters) + { + parameters = parameters ?? new Dictionary(); + var limit = OptionalInt(parameters, "limit", 200, "$request.limit", 1, 5000); + var includePackages = OptionalBool(parameters, "includePackages", false, "$request.includePackages"); + var roots = new List(); + + var requestedPath = OptionalString(parameters, "path", "$request"); + if (requestedPath != null) + { + var path = NormalizeAssetPath(requestedPath, null, "$request.path"); + if (!AssetDatabase.IsValidFolder(path)) + throw Error("ui.list-path", $"UI list path is not an asset folder: {path}", "$request.path"); + roots.Add(path); + } + else + { + roots.Add("Assets"); + } + + if (includePackages && !roots.Contains("Packages")) + roots.Add("Packages"); + + var paths = new HashSet(StringComparer.Ordinal); + foreach (var guid in AssetDatabase.FindAssets("t:VisualTreeAsset", roots.ToArray())) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + if (path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase)) + paths.Add(path); + } + + foreach (var guid in AssetDatabase.FindAssets("t:TextAsset", roots.ToArray())) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + if (path.EndsWith(".ucp-ui.json", StringComparison.OrdinalIgnoreCase)) + paths.Add(path); + } + + var ordered = paths.OrderBy(path => path, StringComparer.Ordinal).ToList(); + var items = new List(Math.Min(ordered.Count, limit)); + foreach (var path in ordered.Take(limit)) + { + if (path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase)) + { + items.Add(new Dictionary + { + ["target"] = path, + ["type"] = "uxml", + ["valid"] = true, + ["states"] = new List { "default" }, + ["defaultState"] = "default" + }); + continue; + } + + try + { + var fixture = ParseFixture(path); + items.Add(FixtureSummary(fixture)); + } + catch (UiScenarioException ex) + { + items.Add(new Dictionary + { + ["target"] = path, + ["type"] = "fixture", + ["valid"] = false, + ["diagnostics"] = new List { ex.ToDictionary() } + }); + } + } + + return new Dictionary + { + ["items"] = items, + ["count"] = ordered.Count, + ["returned"] = items.Count, + ["truncated"] = ordered.Count > items.Count, + ["searchRoots"] = roots.Select(root => (object)root).ToList() + }; + } + + public static Dictionary ValidateFixture(string fixtureAssetPath) + { + var targetPath = NormalizeAssetPath(fixtureAssetPath, null, "$request.target"); + if (!targetPath.EndsWith(".ucp-ui.json", StringComparison.OrdinalIgnoreCase)) + throw Error("ui.target-type", "Fixture paths must end in '.ucp-ui.json'.", "$request.target"); + + var fixture = ParseFixture(targetPath); + var dependencies = new List + { + new Dictionary + { + ["kind"] = "document", + ["path"] = fixture.DocumentPath + } + }; + + var seen = new HashSet(StringComparer.Ordinal) { "document\0" + fixture.DocumentPath }; + AddCollectionDependencies(fixture.Collections, null, dependencies, seen); + foreach (var pair in fixture.States.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + AddCollectionDependencies(pair.Value.Collections, pair.Key, dependencies, seen); + + var result = FixtureSummary(fixture); + result["dependencies"] = dependencies; + return result; + } + + private static Dictionary FixtureSummary(FixtureDefinition fixture) + { + var states = fixture.States.Keys.OrderBy(name => name, StringComparer.Ordinal) + .Select(name => (object)name).ToList(); + return new Dictionary + { + ["target"] = fixture.Path, + ["type"] = "fixture", + ["valid"] = true, + ["schemaVersion"] = 0, + ["document"] = fixture.DocumentPath, + ["states"] = states, + ["defaultState"] = fixture.DefaultState + }; + } + + private static void AddCollectionDependencies( + IReadOnlyList collections, + string state, + List output, + HashSet seen) + { + if (collections == null) + return; + + foreach (var collection in collections) + { + var key = (state ?? string.Empty) + "\0" + collection.TemplatePath; + if (!seen.Add(key)) + continue; + + output.Add(new Dictionary + { + ["kind"] = "collection-template", + ["path"] = collection.TemplatePath, + ["state"] = state, + ["target"] = collection.Selector + }); + } + } + + private static FixtureDefinition ParseFixture(string path) + { + var textAsset = AssetDatabase.LoadAssetAtPath(path); + if (textAsset == null) + throw Error("ui.fixture-not-found", $"Could not load fixture as a TextAsset: {path}", path); + + Dictionary root; + try + { + root = MiniJson.Deserialize(textAsset.text) as Dictionary; + } + catch (Exception ex) + { + throw Error("ui.fixture-json", $"Fixture JSON is invalid: {ex.Message}", path); + } + + if (root == null) + throw Error("ui.fixture-root", "Fixture root must be a JSON object.", path); + + RejectUnknownFields(root, FixtureFields, path); + var schemaVersion = RequireInt(root, "schemaVersion", path, 0, 0); + if (schemaVersion != 0) + throw Error("ui.schema-version", $"Unsupported fixture schemaVersion {schemaVersion}; expected 0.", path + "#schemaVersion"); + + var documentReference = RequireString(root, "document", path); + var documentPath = NormalizeAssetPath(documentReference, path, path + "#document"); + if (!documentPath.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase)) + throw Error("ui.document-type", "Fixture 'document' must reference a .uxml asset.", path + "#document"); + + var document = LoadVisualTree(documentPath, path + "#document"); + var statesObject = RequireObject(root, "states", path + "#states"); + if (statesObject.Count == 0) + throw Error("ui.states-empty", "Fixture 'states' must contain at least one state.", path + "#states"); + if (statesObject.Count > MaxFixtureStates) + { + throw Error( + "ui.states-limit", + $"Fixture 'states' cannot contain more than {MaxFixtureStates} states.", + path + "#states"); + } + + var fixture = new FixtureDefinition + { + Path = path, + DocumentPath = documentPath, + Document = document, + Viewport = root.TryGetValue("viewport", out var viewportValue) + ? ParseViewport(viewportValue, new UiViewport(), path + "#viewport") + : new UiViewport(), + Settle = root.TryGetValue("settle", out var settleValue) + ? ParseSettle(settleValue, new UiSettleOptions(), path + "#settle") + : new UiSettleOptions(), + Data = OptionalObject(root, "data", path + "#data"), + SetOperations = root.TryGetValue("set", out var setValue) + ? ParseSetOperations(setValue, path + "#set") + : Array.Empty(), + Collections = root.TryGetValue("collections", out var collectionsValue) + ? ParseCollections(collectionsValue, path, path + "#collections") + : Array.Empty() + }; + + foreach (var pair in statesObject) + { + if (string.IsNullOrWhiteSpace(pair.Key)) + throw Error("ui.state-name", "Fixture state names cannot be empty.", path + "#states"); + if (!(pair.Value is Dictionary stateObject)) + throw Error("ui.state-type", $"State '{pair.Key}' must be a JSON object.", path + "#states." + pair.Key); + + var location = path + "#states." + pair.Key; + RejectUnknownFields(stateObject, StateFields, location); + fixture.States.Add(pair.Key, new StateDefinition + { + Viewport = stateObject.TryGetValue("viewport", out var stateViewport) + ? ParseViewport(stateViewport, fixture.Viewport, location + ".viewport") + : null, + Settle = stateObject.TryGetValue("settle", out var stateSettle) + ? ParseSettle(stateSettle, fixture.Settle, location + ".settle") + : null, + Data = OptionalObject(stateObject, "data", location + ".data"), + SetOperations = stateObject.TryGetValue("set", out var stateSet) + ? ParseSetOperations(stateSet, location + ".set") + : null, + Collections = stateObject.TryGetValue("collections", out var stateCollections) + ? ParseCollections(stateCollections, path, location + ".collections") + : null + }); + } + + fixture.DefaultState = OptionalString(root, "defaultState", path); + if (fixture.DefaultState == null) + { + if (fixture.States.ContainsKey("default")) + fixture.DefaultState = "default"; + else if (fixture.States.Count == 1) + fixture.DefaultState = fixture.States.Keys.First(); + else + throw Error("ui.default-state", "Fixtures with multiple states must declare 'defaultState'.", path + "#defaultState"); + } + + if (!fixture.States.ContainsKey(fixture.DefaultState)) + throw Error("ui.default-state", $"defaultState '{fixture.DefaultState}' does not exist in 'states'.", path + "#defaultState"); + + return fixture; + } + + private static List SelectStates(FixtureDefinition fixture, string requestedState, bool allStates) + { + if (allStates) + return fixture.States.Keys.OrderBy(name => name, StringComparer.Ordinal).ToList(); + + var stateName = requestedState ?? fixture.DefaultState; + if (!fixture.States.ContainsKey(stateName)) + throw Error("ui.state-not-found", $"Fixture state '{stateName}' does not exist.", fixture.Path + "#states"); + return new List { stateName }; + } + + private static IReadOnlyList ParseSetOperations(object value, string location) + { + if (!(value is IList values)) + throw Error("ui.set-type", "'set' must be a JSON array.", location); + + var result = new List(values.Count); + for (var i = 0; i < values.Count; i++) + { + var itemLocation = $"{location}[{i}]"; + if (!(values[i] is Dictionary item)) + throw Error("ui.set-item-type", "Each 'set' entry must be an object.", itemLocation); + + RejectUnknownFields(item, SetFields, itemLocation); + var selector = RequireString(item, "target", itemLocation); + ValidateSelector(selector, itemLocation + ".target"); + var property = RequireString(item, "property", itemLocation); + ValidateSetProperty(property, itemLocation + ".property"); + if (!item.TryGetValue("value", out var setValue)) + throw Error("ui.missing-field", "Missing required field 'value'.", itemLocation); + + result.Add(new UiSetOperation(selector, property, NormalizeJsonValue(setValue), itemLocation)); + } + + return result; + } + + private static IReadOnlyList ParseCollections( + object value, + string fixturePath, + string location) + { + if (!(value is IList values)) + throw Error("ui.collections-type", "'collections' must be a JSON array.", location); + + var result = new List(values.Count); + var targets = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < values.Count; i++) + { + var itemLocation = $"{location}[{i}]"; + if (!(values[i] is Dictionary item)) + throw Error("ui.collection-item-type", "Each collection entry must be an object.", itemLocation); + + RejectUnknownFields(item, CollectionFields, itemLocation); + var selector = RequireString(item, "target", itemLocation); + ValidateSelector(selector, itemLocation + ".target"); + if (!targets.Add(selector)) + throw Error("ui.collection-duplicate", $"Collection target '{selector}' is configured more than once in one scope.", itemLocation + ".target"); + + var modeText = RequireString(item, "mode", itemLocation); + UiCollectionMode mode; + if (string.Equals(modeText, "repeat", StringComparison.Ordinal)) + mode = UiCollectionMode.Repeat; + else if (string.Equals(modeText, "list-view", StringComparison.Ordinal)) + mode = UiCollectionMode.ListView; + else + throw Error("ui.collection-mode", "Collection mode must be 'repeat' or 'list-view'.", itemLocation + ".mode"); + + var source = RequireString(item, "source", itemLocation); + ValidateJsonPointer(source, itemLocation + ".source"); + var templateReference = RequireString(item, "template", itemLocation); + var templatePath = NormalizeAssetPath(templateReference, fixturePath, itemLocation + ".template"); + if (!templatePath.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase)) + throw Error("ui.template-type", "Collection templates must reference a .uxml asset.", itemLocation + ".template"); + + float? itemHeight = null; + if (item.TryGetValue("itemHeight", out var heightValue)) + { + var height = NumberAsDouble(heightValue, itemLocation + ".itemHeight"); + if (!IsFinite(height) || height <= 0 || height > 10000) + throw Error("ui.item-height", "itemHeight must be finite and between 0 and 10000.", itemLocation + ".itemHeight"); + itemHeight = (float)height; + } + + result.Add(new UiCollectionDefinition( + selector, + mode, + source, + templatePath, + LoadVisualTree(templatePath, itemLocation + ".template"), + itemHeight, + itemLocation)); + } + + return result; + } + + private static UiViewport ParseViewport(object value, UiViewport fallback, string location) + { + if (!(value is Dictionary viewport)) + throw Error("ui.viewport-type", "viewport must be an object.", location); + RejectUnknownFields(viewport, ViewportFields, location); + + var width = OptionalInt(viewport, "width", fallback.Width, location + ".width", 1, 8192); + var height = OptionalInt(viewport, "height", fallback.Height, location + ".height", 1, 8192); + if ((long)width * height > MaxViewportPixelArea) + { + throw Error( + "ui.viewport-area", + $"Viewport area cannot exceed {MaxViewportPixelArea} pixels.", + location); + } + return new UiViewport(width, height); + } + + private static UiSettleOptions ParseSettle(object value, UiSettleOptions fallback, string location) + { + if (!(value is Dictionary settle)) + throw Error("ui.settle-type", "settle must be an object.", location); + RejectUnknownFields(settle, SettleFields, location); + + var stableFrames = OptionalInt(settle, "stableFrames", fallback.StableFrames, location + ".stableFrames", 1, 60); + var pixelFrames = OptionalInt(settle, "pixelStableFrames", fallback.PixelStableFrames, location + ".pixelStableFrames", 1, 60); + var timeout = settle.TryGetValue("timeoutSeconds", out var timeoutValue) + ? NumberAsDouble(timeoutValue, location + ".timeoutSeconds") + : fallback.TimeoutSeconds; + if (!IsFinite(timeout) || timeout < 0.1 || timeout > 300) + throw Error("ui.settle-timeout", "timeoutSeconds must be finite and between 0.1 and 300.", location + ".timeoutSeconds"); + + return new UiSettleOptions(stableFrames, pixelFrames, timeout); + } + + private static Dictionary MergeData( + Dictionary fixtureData, + Dictionary stateData, + Dictionary requestData) + { + var merged = CloneObject(fixtureData) ?? new Dictionary(); + MergeInto(merged, stateData); + MergeInto(merged, requestData); + return merged; + } + + private static void MergeInto(Dictionary target, Dictionary overlay) + { + if (overlay == null) + return; + + foreach (var pair in overlay) + { + if (pair.Value is Dictionary overlayObject && + target.TryGetValue(pair.Key, out var current) && current is Dictionary currentObject) + { + MergeInto(currentObject, overlayObject); + } + else + { + target[pair.Key] = NormalizeJsonValue(pair.Value); + } + } + } + + private static Dictionary CloneObject(Dictionary value) + { + if (value == null) + return null; + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in value) + result[pair.Key] = NormalizeJsonValue(pair.Value); + return result; + } + + internal static object NormalizeJsonValue(object value) + { + if (value is long integer && integer >= int.MinValue && integer <= int.MaxValue) + return (int)integer; + if (value is Dictionary dictionary) + return CloneObject(dictionary); + if (value is IList list) + { + var normalized = new List(list.Count); + foreach (var item in list) + normalized.Add(NormalizeJsonValue(item)); + return normalized; + } + return value; + } + + private static VisualTreeAsset LoadVisualTree(string path, string location) + { + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset == null) + throw Error("ui.uxml-not-found", $"Could not load UXML asset: {path}", location); + return asset; + } + + internal static string NormalizeAssetPath(string input, string relativeToFixture, string location) + { + if (string.IsNullOrWhiteSpace(input)) + throw Error("ui.path-empty", "Asset path cannot be empty.", location); + + var normalizedInput = input.Trim().Replace('\\', '/'); + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string absolute; + + if (Path.IsPathRooted(normalizedInput)) + { + absolute = Path.GetFullPath(normalizedInput); + } + else + { + string projectRelative; + if (normalizedInput.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) || + normalizedInput.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase) || + string.Equals(normalizedInput, "Assets", StringComparison.OrdinalIgnoreCase) || + string.Equals(normalizedInput, "Packages", StringComparison.OrdinalIgnoreCase)) + { + projectRelative = normalizedInput; + } + else if (relativeToFixture != null) + { + var baseDirectory = Path.GetDirectoryName(relativeToFixture.Replace('/', Path.DirectorySeparatorChar)) + ?? string.Empty; + projectRelative = Path.Combine(baseDirectory, normalizedInput.Replace('/', Path.DirectorySeparatorChar)); + } + else + { + projectRelative = normalizedInput; + } + + absolute = Path.GetFullPath(Path.Combine(projectRoot, projectRelative)); + } + + var rootWithSeparator = projectRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + if (!absolute.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase)) + throw Error("ui.path-outside-project", "UI asset paths must stay inside the Unity project.", location); + + var relative = absolute.Substring(rootWithSeparator.Length).Replace('\\', '/'); + if (!(relative.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) || + relative.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase) || + string.Equals(relative, "Assets", StringComparison.OrdinalIgnoreCase) || + string.Equals(relative, "Packages", StringComparison.OrdinalIgnoreCase))) + { + throw Error("ui.path-root", "UI asset paths must be under Assets or Packages.", location); + } + + return relative; + } + + internal static void ValidateSelector(string selector, string location) + { + if (string.Equals(selector, ":root", StringComparison.Ordinal)) + return; + if (selector.Length < 2 || (selector[0] != '#' && selector[0] != '.')) + throw Error("ui.selector-syntax", "Selectors must be ':root', '#name', or '.class'.", location); + + for (var i = 1; i < selector.Length; i++) + { + var c = selector[i]; + if (!(char.IsLetterOrDigit(c) || c == '_' || c == '-')) + throw Error("ui.selector-syntax", "Selector names may contain only letters, digits, '_' and '-'.", location); + } + } + + private static void ValidateSetProperty(string property, string location) + { + if (property == "text" || property == "value" || property == "enabled" || + property == "display" || property == "visibility" || property == "tooltip") + return; + + if (property.StartsWith("class:", StringComparison.Ordinal) && property.Length > "class:".Length) + { + ValidateSelector("." + property.Substring("class:".Length), location); + return; + } + + throw Error("ui.set-property", $"Property '{property}' is not allowed in UI fixtures.", location); + } + + private static void ValidateJsonPointer(string pointer, string location) + { + if (pointer.Length == 0) + return; + if (pointer[0] != '/') + throw Error("ui.source-path", "Collection source must be an RFC 6901-style JSON pointer such as '/items'.", location); + + for (var i = 0; i < pointer.Length; i++) + { + if (pointer[i] != '~') + continue; + if (i + 1 >= pointer.Length || (pointer[i + 1] != '0' && pointer[i + 1] != '1')) + throw Error("ui.source-path", "JSON pointer '~' escapes must be '~0' or '~1'.", location); + i++; + } + } + + private static void RejectUnknownFields( + Dictionary value, + HashSet allowed, + string location) + { + foreach (var key in value.Keys) + { + if (!allowed.Contains(key)) + throw Error("ui.unknown-field", $"Unknown field '{key}'.", location + "." + key); + } + } + + private static Dictionary RequireObject( + Dictionary value, + string key, + string location) + { + if (!value.TryGetValue(key, out var field)) + throw Error("ui.missing-field", $"Missing required field '{key}'.", location); + if (!(field is Dictionary result)) + throw Error("ui.field-type", $"Field '{key}' must be an object.", location); + return result; + } + + private static Dictionary OptionalObject( + Dictionary value, + string key, + string location) + { + if (!value.TryGetValue(key, out var field)) + return null; + if (!(field is Dictionary result)) + throw Error("ui.field-type", $"Field '{key}' must be an object.", location); + return CloneObject(result); + } + + private static string RequireString(Dictionary value, string key, string location) + { + var result = OptionalString(value, key, location); + if (result == null) + throw Error("ui.missing-field", $"Missing required string field '{key}'.", location); + return result; + } + + private static string OptionalString(Dictionary value, string key, string location) + { + if (!value.TryGetValue(key, out var field)) + return null; + if (!(field is string result) || string.IsNullOrWhiteSpace(result)) + throw Error("ui.field-type", $"Field '{key}' must be a non-empty string.", location + "." + key); + return result; + } + + private static int RequireInt( + Dictionary value, + string key, + string location, + int minimum, + int maximum) + { + if (!value.TryGetValue(key, out var field)) + throw Error("ui.missing-field", $"Missing required integer field '{key}'.", location); + return CheckedInt(field, location + "." + key, minimum, maximum); + } + + private static int OptionalInt( + Dictionary value, + string key, + int fallback, + string location, + int minimum, + int maximum) + { + return value.TryGetValue(key, out var field) + ? CheckedInt(field, location, minimum, maximum) + : fallback; + } + + private static int CheckedInt(object value, string location, int minimum, int maximum) + { + long integer; + if (value is int intValue) + integer = intValue; + else if (value is long longValue) + integer = longValue; + else + throw Error("ui.integer-type", "Expected an integer.", location); + + if (integer < minimum || integer > maximum) + throw Error("ui.integer-range", $"Integer must be between {minimum} and {maximum}.", location); + return (int)integer; + } + + private static bool OptionalBool( + Dictionary value, + string key, + bool fallback, + string location) + { + if (!value.TryGetValue(key, out var field)) + return fallback; + if (!(field is bool result)) + throw Error("ui.boolean-type", "Expected a boolean.", location); + return result; + } + + private static double NumberAsDouble(object value, string location) + { + if (value is int integer) + return integer; + if (value is long longInteger) + return longInteger; + if (value is float single) + return single; + if (value is double number) + return number; + throw Error("ui.number-type", "Expected a number.", location); + } + + private static bool IsFinite(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value); + } + + private static UiScenarioException Error(string code, string message, string location) + { + return new UiScenarioException(code, message, location); + } + + private sealed class FixtureDefinition + { + public string Path; + public string DocumentPath; + public VisualTreeAsset Document; + public string DefaultState; + public UiViewport Viewport; + public UiSettleOptions Settle; + public Dictionary Data; + public IReadOnlyList SetOperations; + public IReadOnlyList Collections; + public readonly Dictionary States = + new Dictionary(StringComparer.Ordinal); + } + + private sealed class StateDefinition + { + public UiViewport Viewport; + public UiSettleOptions Settle; + public Dictionary Data; + public IReadOnlyList SetOperations; + public IReadOnlyList Collections; + } + } +} +#endif diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs.meta b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs.meta new file mode 100644 index 0000000..bfa9b7d --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 39c3fdf0d73e45c2ad21f30786420319 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs new file mode 100644 index 0000000..8c993c4 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs @@ -0,0 +1,374 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UIElements; + +namespace UCP.Bridge +{ + internal sealed class UiScenarioException : ArgumentException + { + public UiScenarioException(string code, string message, string location = null) + : base(location == null ? message : $"{message} ({location})") + { + Code = code; + Location = location; + } + + public string Code { get; } + public string Location { get; } + + public Dictionary ToDictionary() + { + return new Dictionary + { + ["severity"] = "error", + ["code"] = Code, + ["message"] = Message, + ["location"] = Location + }; + } + } + + internal sealed class UiViewport + { + public const int DefaultWidth = 960; + public const int DefaultHeight = 640; + + public UiViewport(int width = DefaultWidth, int height = DefaultHeight) + { + Width = width; + Height = height; + } + + public int Width { get; } + public int Height { get; } + + public Dictionary ToDictionary() + { + return new Dictionary + { + ["width"] = Width, + ["height"] = Height + }; + } + } + + internal sealed class UiSettleOptions + { + public const int DefaultStableFrames = 3; + public const int DefaultPixelStableFrames = 2; + public const double DefaultTimeoutSeconds = 15.0; + + public UiSettleOptions( + int stableFrames = DefaultStableFrames, + int pixelStableFrames = DefaultPixelStableFrames, + double timeoutSeconds = DefaultTimeoutSeconds) + { + StableFrames = stableFrames; + PixelStableFrames = pixelStableFrames; + TimeoutSeconds = timeoutSeconds; + } + + public int StableFrames { get; } + public int PixelStableFrames { get; } + public double TimeoutSeconds { get; } + + public Dictionary ToDictionary() + { + return new Dictionary + { + ["stableFrames"] = StableFrames, + ["pixelStableFrames"] = PixelStableFrames, + ["timeoutSeconds"] = TimeoutSeconds + }; + } + } + + internal sealed class UiSetOperation + { + public UiSetOperation(string selector, string property, object value, string location) + { + Selector = selector; + Property = property; + Value = value; + Location = location; + } + + public string Selector { get; } + public string Property { get; } + public object Value { get; } + public string Location { get; } + } + + internal enum UiCollectionMode + { + Repeat, + ListView + } + + internal sealed class UiCollectionDefinition + { + public UiCollectionDefinition( + string selector, + UiCollectionMode mode, + string source, + string templatePath, + VisualTreeAsset template, + float? itemHeight, + string location) + { + Selector = selector; + Mode = mode; + Source = source; + TemplatePath = templatePath; + Template = template; + ItemHeight = itemHeight; + Location = location; + } + + public string Selector { get; } + public UiCollectionMode Mode { get; } + public string Source { get; } + public string TemplatePath { get; } + public VisualTreeAsset Template { get; } + public float? ItemHeight { get; } + public string Location { get; } + } + + internal sealed class UiResolvedScenario + { + public UiResolvedScenario( + string targetPath, + string fixturePath, + string stateName, + string documentPath, + VisualTreeAsset document, + Dictionary data, + IReadOnlyList setOperations, + IReadOnlyList collections, + UiViewport viewport, + UiSettleOptions settle) + { + TargetPath = targetPath; + FixturePath = fixturePath; + StateName = stateName; + DocumentPath = documentPath; + Document = document; + Data = data; + SetOperations = setOperations; + Collections = collections; + Viewport = viewport; + Settle = settle; + } + + public string TargetPath { get; } + public string FixturePath { get; } + public string StateName { get; } + public string DocumentPath { get; } + public VisualTreeAsset Document { get; } + public Dictionary Data { get; } + public IReadOnlyList SetOperations { get; } + public IReadOnlyList Collections { get; } + public UiViewport Viewport { get; } + public UiSettleOptions Settle { get; } + + public Dictionary ToDictionary() + { + return new Dictionary + { + ["target"] = TargetPath, + ["fixture"] = FixturePath, + ["state"] = StateName, + ["document"] = DocumentPath, + ["viewport"] = Viewport.ToDictionary(), + ["settle"] = Settle.ToDictionary(), + ["setCount"] = SetOperations.Count, + ["collectionCount"] = Collections.Count + }; + } + } + + internal sealed class UiApplyReport + { + private readonly List> _diagnostics = new List>(); + private readonly List _collections = new List(); + + public int BindingRewriteCount { get; internal set; } + public int SetCount { get; internal set; } + public int RepeatItemCount { get; internal set; } + public IReadOnlyList> Diagnostics => _diagnostics; + public IReadOnlyList Collections => _collections; + + internal void AddDiagnostic(string severity, string code, string message, string location = null) + { + _diagnostics.Add(new Dictionary + { + ["severity"] = severity, + ["code"] = code, + ["message"] = message, + ["location"] = location + }); + } + + internal void AddCollection(UiCollectionMetadata metadata) + { + _collections.Add(metadata); + } + + public Dictionary ToDictionary() + { + var collections = new List(_collections.Count); + foreach (var collection in _collections) + collections.Add(collection.ToDictionary()); + + return new Dictionary + { + ["bindingRewriteCount"] = BindingRewriteCount, + ["setCount"] = SetCount, + ["repeatItemCount"] = RepeatItemCount, + ["collections"] = collections, + ["diagnostics"] = new List(_diagnostics) + }; + } + } + + internal sealed class UiCollectionMetadata + { + private readonly HashSet _realizedRows = new HashSet(); + private readonly Dictionary _boundRows = new Dictionary(); + private readonly VisualElement _target; + private readonly VisualElement _inspectionRoot; + + public UiCollectionMetadata( + string selector, + UiCollectionMode mode, + string source, + int logicalItemCount, + VisualElement target, + VisualElement inspectionRoot) + { + Selector = selector; + Mode = mode; + Source = source; + LogicalItemCount = logicalItemCount; + _target = target; + _inspectionRoot = inspectionRoot; + } + + public string Selector { get; } + public UiCollectionMode Mode { get; } + public string Source { get; } + public int LogicalItemCount { get; } + public int RealizedRowCount => _realizedRows.Count; + public int BoundRowCount => _boundRows.Count; + + internal void Realize(VisualElement row) + { + if (row != null) + _realizedRows.Add(row); + } + + internal void Bind(VisualElement row, int index) + { + Realize(row); + _boundRows[row] = index; + } + + internal void Unbind(VisualElement row) + { + if (row != null) + _boundRows.Remove(row); + } + + internal void Destroy(VisualElement row) + { + if (row == null) + return; + + _boundRows.Remove(row); + _realizedRows.Remove(row); + } + + public Dictionary ToDictionary() + { + var rows = new List(_boundRows.Count); + var visibleRowCount = 0; + foreach (var pair in _boundRows) + { + var row = pair.Key; + var visible = row != null && row.panel != null && row.resolvedStyle.display != DisplayStyle.None; + var intersectsViewport = visible && IntersectsViewport(row); + if (intersectsViewport) + visibleRowCount++; + rows.Add(new Dictionary + { + ["index"] = pair.Value, + ["name"] = row != null ? row.name : null, + ["attached"] = row != null && row.panel != null, + ["displayed"] = visible, + ["visible"] = intersectsViewport + }); + } + + rows.Sort((left, right) => + { + var leftIndex = Convert.ToInt32(((Dictionary)left)["index"]); + var rightIndex = Convert.ToInt32(((Dictionary)right)["index"]); + return leftIndex.CompareTo(rightIndex); + }); + + return new Dictionary + { + ["target"] = Selector, + ["mode"] = Mode == UiCollectionMode.Repeat ? "repeat" : "list-view", + ["source"] = Source, + ["logicalItemCount"] = LogicalItemCount, + ["realizedRowCount"] = RealizedRowCount, + ["boundRowCount"] = BoundRowCount, + ["visibleRowCount"] = visibleRowCount, + ["boundRows"] = rows + }; + } + + private bool IntersectsViewport(VisualElement row) + { + if (_target == null || _inspectionRoot == null) + return false; + + for (var current = row; current != null; current = current.parent) + { + if (current.resolvedStyle.display == DisplayStyle.None || + current.resolvedStyle.visibility == Visibility.Hidden) + return false; + if (current == _inspectionRoot) + break; + } + + var rowBounds = row.worldBound; + var viewport = Intersect(_target.worldBound, _inspectionRoot.worldBound); + return IsFinite(rowBounds) && IsFinite(viewport) && rowBounds.width > 0f && rowBounds.height > 0f && + viewport.width > 0f && viewport.height > 0f && rowBounds.Overlaps(viewport); + } + + private static Rect Intersect(Rect left, Rect right) + { + var xMin = Math.Max(left.xMin, right.xMin); + var yMin = Math.Max(left.yMin, right.yMin); + var xMax = Math.Min(left.xMax, right.xMax); + var yMax = Math.Min(left.yMax, right.yMax); + return Rect.MinMaxRect(xMin, yMin, xMax, yMax); + } + + private static bool IsFinite(Rect value) + { + return IsFinite(value.x) && IsFinite(value.y) && IsFinite(value.width) && IsFinite(value.height); + } + + private static bool IsFinite(float value) + { + return !float.IsNaN(value) && !float.IsInfinity(value); + } + } +} +#endif diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs.meta b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs.meta new file mode 100644 index 0000000..7a370c6 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d55428726a134c1bab4f36916763867e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs new file mode 100644 index 0000000..8b4e74e --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs @@ -0,0 +1,679 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; +using UnityEngine.UIElements; + +namespace UCP.Bridge +{ + internal sealed class UiInspectOptions + { + private const int DefaultDepth = 12; + private const int DefaultMaxElements = 500; + + internal string Query { get; private set; } + internal int MaxDepth { get; private set; } + internal int MaxElements { get; private set; } + internal string Detail { get; private set; } + + internal static UiInspectOptions Parse(Dictionary parameters) + { + parameters ??= new Dictionary(); + var options = new UiInspectOptions + { + Query = ReadString(parameters, "query"), + MaxDepth = ReadInt(parameters, "depth", DefaultDepth, 0, 64), + MaxElements = ReadInt(parameters, "max", DefaultMaxElements, 1, 5000), + Detail = ReadString(parameters, "detail") ?? "normal" + }; + + options.Detail = options.Detail.ToLowerInvariant(); + if (options.Detail != "summary" && options.Detail != "normal" && options.Detail != "verbose") + throw new ArgumentException("'detail' must be one of: summary, normal, verbose"); + + UiSimpleQuery.Validate(options.Query); + return options; + } + + private static string ReadString(Dictionary parameters, string key) + { + return parameters.TryGetValue(key, out var value) && value != null + ? value.ToString() + : null; + } + + private static int ReadInt( + Dictionary parameters, + string key, + int defaultValue, + int min, + int max) + { + if (!parameters.TryGetValue(key, out var value) || value == null) + return defaultValue; + + try + { + var result = Convert.ToInt32(value); + if (result < min || result > max) + throw new ArgumentException($"'{key}' must be between {min} and {max}"); + return result; + } + catch (ArgumentException) + { + throw; + } + catch (Exception) + { + throw new ArgumentException($"'{key}' must be an integer between {min} and {max}"); + } + } + } + + internal static class UiVisualTreeInspector + { + internal static Dictionary Inspect( + VisualElement root, + UiInspectOptions options, + IReadOnlyList collectionMetadata) + { + if (root == null) + throw new ArgumentException("The resolved UXML did not create a visual tree"); + + var selectedRoots = Select(root, options.Query); + var context = new SnapshotContext(options); + var roots = new List(); + foreach (var selectedRoot in selectedRoots) + { + if (context.Truncated) + break; + var node = BuildNode(selectedRoot, 0, context); + if (node != null) + roots.Add(node); + } + + var collections = new List(); + if (collectionMetadata != null) + { + foreach (var metadata in collectionMetadata) + collections.Add(metadata.ToDictionary()); + } + + return new Dictionary + { + ["query"] = options.Query, + ["matchCount"] = selectedRoots.Count, + ["returnedElementCount"] = context.ReturnedCount, + ["truncated"] = context.Truncated, + ["maxElements"] = options.MaxElements, + ["maxDepth"] = options.MaxDepth, + ["detail"] = options.Detail, + ["roots"] = roots, + ["collections"] = collections + }; + } + + private static List Select(VisualElement root, string query) + { + if (string.IsNullOrWhiteSpace(query)) + return new List { root }; + + var result = new List(); + Visit(root, element => + { + if (UiSimpleQuery.Matches(element, query)) + result.Add(element); + }); + return result; + } + + private static Dictionary BuildNode( + VisualElement element, + int depth, + SnapshotContext context) + { + if (context.ReturnedCount >= context.Options.MaxElements) + { + context.Truncated = true; + return null; + } + + context.ReturnedCount++; + var node = new Dictionary + { + ["ref"] = $"e{context.ReturnedCount}", + ["type"] = element.GetType().Name, + ["name"] = string.IsNullOrEmpty(element.name) ? null : element.name, + ["classes"] = element.GetClasses().Cast().ToList() + }; + + if (element is TextElement textElement && !string.IsNullOrEmpty(textElement.text)) + node["text"] = textElement.text; + + var value = ReadValue(element); + if (value.Found) + node["value"] = value.Value; + + if (context.Options.Detail != "summary") + { + var displayed = IsDisplayed(element); + node["enabled"] = element.enabledInHierarchy; + node["focusable"] = element.focusable; + node["focused"] = element.panel?.focusController?.focusedElement == element; + node["visible"] = displayed && element.resolvedStyle.visibility == Visibility.Visible; + node["layout"] = RectDictionary(element.layout); + node["worldBound"] = RectDictionary(element.worldBound); + node["resolvedStyle"] = StyleDictionary(element.resolvedStyle); + node["bindings"] = BindingDictionaries(element); + } + + if (context.Options.Detail == "verbose") + { + node["fullType"] = element.GetType().FullName; + node["pickingMode"] = element.pickingMode.ToString(); + node["tooltip"] = string.IsNullOrEmpty(element.tooltip) ? null : element.tooltip; + } + + var children = new List(); + if (depth < context.Options.MaxDepth) + { + foreach (var child in element.Children()) + { + if (context.ReturnedCount >= context.Options.MaxElements) + { + context.Truncated = true; + break; + } + + var childNode = BuildNode(child, depth + 1, context); + if (childNode != null) + children.Add(childNode); + } + } + else if (element.hierarchy.childCount > 0) + { + context.Truncated = true; + } + + if (children.Count > 0) + node["children"] = children; + return node; + } + + internal static List BindingDictionaries(VisualElement element) + { + var bindings = new List(); + foreach (var info in element.GetBindingInfos()) + { + var bindingId = info.bindingId; + var item = new Dictionary + { + ["property"] = bindingId.ToString(), + ["bindingType"] = info.binding?.GetType().Name + }; + + if (element.TryGetLastBindingToUIResult(bindingId, out var toUi)) + { + item["toUi"] = new Dictionary + { + ["status"] = toUi.status.ToString(), + ["message"] = string.IsNullOrEmpty(toUi.message) ? null : toUi.message + }; + } + + if (element.TryGetLastBindingToSourceResult(bindingId, out var toSource)) + { + item["toSource"] = new Dictionary + { + ["status"] = toSource.status.ToString(), + ["message"] = string.IsNullOrEmpty(toSource.message) ? null : toSource.message + }; + } + + bindings.Add(item); + } + return bindings; + } + + private static Dictionary RectDictionary(Rect value) + { + var finite = UiValue.IsFinite(value.x) && UiValue.IsFinite(value.y) && + UiValue.IsFinite(value.width) && UiValue.IsFinite(value.height); + return new Dictionary + { + ["x"] = UiValue.FiniteOrNull(value.x), + ["y"] = UiValue.FiniteOrNull(value.y), + ["width"] = UiValue.FiniteOrNull(value.width), + ["height"] = UiValue.FiniteOrNull(value.height), + ["finite"] = finite + }; + } + + private static Dictionary StyleDictionary(IResolvedStyle style) + { + return new Dictionary + { + ["display"] = style.display.ToString(), + ["visibility"] = style.visibility.ToString(), + ["position"] = style.position.ToString(), + ["flexDirection"] = style.flexDirection.ToString(), + ["justifyContent"] = style.justifyContent.ToString(), + ["alignItems"] = style.alignItems.ToString(), + ["alignSelf"] = style.alignSelf.ToString(), + ["flexGrow"] = UiValue.FiniteOrNull(style.flexGrow), + ["flexShrink"] = UiValue.FiniteOrNull(style.flexShrink), + ["width"] = UiValue.FiniteOrNull(style.width), + ["height"] = UiValue.FiniteOrNull(style.height), + ["opacity"] = UiValue.FiniteOrNull(style.opacity), + ["color"] = ColorDictionary(style.color), + ["backgroundColor"] = ColorDictionary(style.backgroundColor), + ["fontSize"] = UiValue.FiniteOrNull(style.fontSize), + ["whiteSpace"] = style.whiteSpace.ToString() + }; + } + + private static Dictionary ColorDictionary(Color value) + { + return new Dictionary + { + ["r"] = UiValue.FiniteOrNull(value.r), + ["g"] = UiValue.FiniteOrNull(value.g), + ["b"] = UiValue.FiniteOrNull(value.b), + ["a"] = UiValue.FiniteOrNull(value.a) + }; + } + + private static ValueRead ReadValue(VisualElement element) + { + try + { + var property = element.GetType().GetProperty( + "value", + BindingFlags.Instance | BindingFlags.Public); + if (property == null || !property.CanRead || property.GetIndexParameters().Length != 0) + return default; + + return new ValueRead(true, NormalizeValue(property.GetValue(element))); + } + catch + { + return default; + } + } + + private static object NormalizeValue(object value) + { + if (value == null || value is string || value is bool || value is char || + value is byte || value is sbyte || value is short || value is ushort || + value is int || value is uint || value is long || value is ulong || value is decimal) + return value; + if (value is float floatValue) + return UiValue.FiniteOrNull(floatValue); + if (value is double doubleValue) + return UiValue.FiniteOrNull(doubleValue); + if (value is Enum enumValue) + return enumValue.ToString(); + if (value is Color colorValue) + return ColorDictionary(colorValue); + if (value is UnityEngine.Object objectValue) + return objectValue != null ? objectValue.name : null; + return value.ToString(); + } + + private static bool IsDisplayed(VisualElement element) + { + for (var current = element; current != null; current = current.parent) + { + if (current.resolvedStyle.display == DisplayStyle.None) + return false; + } + return true; + } + + internal static void Visit(VisualElement root, Action action) + { + if (root == null) + return; + action(root); + foreach (var child in root.Children()) + Visit(child, action); + } + + private sealed class SnapshotContext + { + internal SnapshotContext(UiInspectOptions options) + { + Options = options; + } + + internal UiInspectOptions Options { get; } + internal int ReturnedCount { get; set; } + internal bool Truncated { get; set; } + } + + private readonly struct ValueRead + { + internal ValueRead(bool found, object value) + { + Found = found; + Value = value; + } + + internal bool Found { get; } + internal object Value { get; } + } + } + + internal static class UiSimpleQuery + { + internal static void Validate(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + query = query.Trim(); + if (query == "#" || query == ".") + throw new ArgumentException("'query' must contain a name, class, or element type"); + if (query.IndexOfAny(new[] { ' ', '>', '+', '~', '[', ']', ':', ',' }) >= 0) + { + throw new ArgumentException( + "'query' supports one simple selector only: #name, .class, or element type"); + } + } + + internal static bool Matches(VisualElement element, string query) + { + if (element == null || string.IsNullOrWhiteSpace(query)) + return false; + query = query.Trim(); + if (query[0] == '#') + return string.Equals(element.name, query.Substring(1), StringComparison.Ordinal); + if (query[0] == '.') + return element.ClassListContains(query.Substring(1)); + return string.Equals(element.GetType().Name, query, StringComparison.OrdinalIgnoreCase) || + string.Equals(element.GetType().FullName, query, StringComparison.OrdinalIgnoreCase); + } + } + + internal static class UiAudit + { + private const int MaxDiagnostics = 200; + + internal static Dictionary Run( + VisualElement root, + UiApplyReport applyReport, + IReadOnlyList collectionMetadata) + { + var errors = new List(); + var warnings = new List(); + var diagnosticCount = 0; + var truncated = false; + var collectionRoots = FindCollectionRoots(root, collectionMetadata); + var namedElements = new Dictionary>(StringComparer.Ordinal); + + if (applyReport != null) + { + foreach (var diagnostic in applyReport.Diagnostics) + { + var severity = diagnostic.TryGetValue("severity", out var value) + ? value?.ToString() + : null; + Add( + severity == "error" ? errors : warnings, + diagnostic, + ref diagnosticCount, + ref truncated); + } + } + + UiVisualTreeInspector.Visit(root, element => + { + if (diagnosticCount >= MaxDiagnostics) + { + truncated = true; + return; + } + + var insideDynamicCollection = HasAncestor(element, collectionRoots); + if (!insideDynamicCollection && IsUserElementName(element.name)) + { + if (!namedElements.TryGetValue(element.name, out var matches)) + { + matches = new List(); + namedElements.Add(element.name, matches); + } + matches.Add(element); + } + + if (element.focusable && IsVisibleThroughAncestors(element)) + { + var rect = element.worldBound; + if (UiValue.IsFinite(rect.width) && UiValue.IsFinite(rect.height) && + (rect.width <= 0f || rect.height <= 0f)) + { + Add(warnings, Diagnostic( + "focusable_zero_size", + "Focusable element has zero rendered width or height", + element), ref diagnosticCount, ref truncated); + } + else if (!insideDynamicCollection && !HasScrollViewAncestor(element) && + IsFinitePositive(rect) && IsFinitePositive(root.worldBound)) + { + var viewport = root.worldBound; + var intersection = Intersect(rect, viewport); + if (intersection.width <= 0f || intersection.height <= 0f) + { + Add(warnings, Diagnostic( + "focusable_outside_viewport", + "Focusable element is fully outside the UI viewport", + element), ref diagnosticCount, ref truncated); + } + else if (intersection.width + 0.5f < rect.width || + intersection.height + 0.5f < rect.height) + { + Add(warnings, Diagnostic( + "focusable_partially_outside_viewport", + "Focusable element is partially outside the UI viewport", + element), ref diagnosticCount, ref truncated); + } + } + } + + foreach (var bindingObject in UiVisualTreeInspector.BindingDictionaries(element)) + { + if (bindingObject is not Dictionary binding) + continue; + CheckBindingResult( + binding, + "toUi", + element, + errors, + warnings, + ref diagnosticCount, + ref truncated); + CheckBindingResult( + binding, + "toSource", + element, + errors, + warnings, + ref diagnosticCount, + ref truncated); + } + }); + + foreach (var pair in namedElements) + { + if (pair.Value.Count < 2) + continue; + Add(warnings, new Dictionary + { + ["code"] = "duplicate_name", + ["message"] = $"Element name '{pair.Key}' is used {pair.Value.Count} times outside dynamic collections", + ["element"] = new Dictionary + { + ["type"] = pair.Value[0].GetType().Name, + ["name"] = pair.Key + } + }, ref diagnosticCount, ref truncated); + } + + return new Dictionary + { + ["passed"] = errors.Count == 0, + ["errorCount"] = errors.Count, + ["warningCount"] = warnings.Count, + ["diagnosticsTruncated"] = truncated, + ["errors"] = errors, + ["warnings"] = warnings + }; + } + + private static void CheckBindingResult( + Dictionary binding, + string direction, + VisualElement element, + List errors, + List warnings, + ref int diagnosticCount, + ref bool truncated) + { + if (!binding.TryGetValue(direction, out var resultObject) || + resultObject is not Dictionary result || + !result.TryGetValue("status", out var statusObject)) + return; + + var status = statusObject?.ToString(); + if (string.Equals(status, "Success", StringComparison.OrdinalIgnoreCase)) + return; + + var message = result.TryGetValue("message", out var messageObject) + ? messageObject?.ToString() + : null; + var property = binding.TryGetValue("property", out var propertyObject) + ? propertyObject?.ToString() + : null; + var diagnostic = Diagnostic( + "binding_" + (status ?? "unknown").ToLowerInvariant(), + $"Binding {direction} for '{property}' reported {status}: {message}", + element); + Add(string.Equals(status, "Failure", StringComparison.OrdinalIgnoreCase) ? errors : warnings, + diagnostic, + ref diagnosticCount, + ref truncated); + } + + private static Dictionary Diagnostic( + string code, + string message, + VisualElement element) + { + return new Dictionary + { + ["code"] = code, + ["message"] = message, + ["element"] = new Dictionary + { + ["type"] = element.GetType().Name, + ["name"] = string.IsNullOrEmpty(element.name) ? null : element.name + } + }; + } + + private static HashSet FindCollectionRoots( + VisualElement root, + IReadOnlyList metadata) + { + var result = new HashSet(); + if (metadata == null) + return result; + foreach (var collection in metadata) + { + if (collection.Selector == ":root") + { + result.Add(root); + continue; + } + UiVisualTreeInspector.Visit(root, element => + { + if (UiSimpleQuery.Matches(element, collection.Selector)) + result.Add(element); + }); + } + return result; + } + + private static bool HasAncestor(VisualElement element, HashSet candidates) + { + for (var current = element; current != null; current = current.parent) + { + if (candidates.Contains(current)) + return true; + } + return false; + } + + private static bool HasScrollViewAncestor(VisualElement element) + { + for (var current = element.parent; current != null; current = current.parent) + { + if (current is ScrollView) + return true; + } + return false; + } + + private static bool IsUserElementName(string name) + { + return !string.IsNullOrEmpty(name) && + !name.StartsWith("unity-", StringComparison.Ordinal); + } + + private static bool IsVisibleThroughAncestors(VisualElement element) + { + for (var current = element; current != null; current = current.parent) + { + if (current.resolvedStyle.display == DisplayStyle.None || + current.resolvedStyle.visibility == Visibility.Hidden) + { + return false; + } + } + return true; + } + + private static bool IsFinitePositive(Rect rect) + { + return UiValue.IsFinite(rect.x) && UiValue.IsFinite(rect.y) && + UiValue.IsFinite(rect.width) && UiValue.IsFinite(rect.height) && + rect.width > 0f && rect.height > 0f; + } + + private static Rect Intersect(Rect left, Rect right) + { + var xMin = Mathf.Max(left.xMin, right.xMin); + var yMin = Mathf.Max(left.yMin, right.yMin); + var xMax = Mathf.Min(left.xMax, right.xMax); + var yMax = Mathf.Min(left.yMax, right.yMax); + return Rect.MinMaxRect(xMin, yMin, Mathf.Max(xMin, xMax), Mathf.Max(yMin, yMax)); + } + + private static void Add( + List target, + object diagnostic, + ref int diagnosticCount, + ref bool truncated) + { + if (diagnosticCount >= MaxDiagnostics) + { + truncated = true; + return; + } + target.Add(diagnostic); + diagnosticCount++; + } + } +} +#endif diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs.meta b/unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs.meta new file mode 100644 index 0000000..ab06bc4 --- /dev/null +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bfab24ff939e46f189b02d0255fc7c1a diff --git a/unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs b/unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs new file mode 100644 index 0000000..3130612 --- /dev/null +++ b/unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs @@ -0,0 +1,341 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine.UIElements; + +namespace UCP.Bridge.Tests +{ + public sealed class UiControllerTests + { + [Test] + public void Register_ExposesUiCommandFamily() + { + var router = new CommandRouter(); + UiController.Register(router); + + Assert.That(router.HasMethod("ui/list"), Is.True); + Assert.That(router.HasMethod("ui/lint"), Is.True); + Assert.That(router.HasMethod("ui/inspect"), Is.True); + Assert.That(router.HasMethod("ui/screenshot"), Is.True); + Assert.That(router.HasMethod("ui/check"), Is.True); + Assert.That(router.HasMethod("ui/status"), Is.True); + } + + [Test] + public void Inspect_ReturnsAsyncStartEnvelopeBeforeResolvingTarget() + { + try + { + var router = new CommandRouter(); + UiController.Register(router); + + var response = router.Dispatch( + "ui/inspect", + 1, + "{\"target\":\"Assets/DoesNotExist.uxml\"}"); + + Assert.That(response.error, Is.Null); + var result = (Dictionary)response.result; + Assert.That(result["status"], Is.EqualTo("started")); + Assert.That(result["operation"], Is.EqualTo("inspect")); + Assert.That(result["operationId"].ToString(), Does.StartWith("ui-")); + } + finally + { + // Prevent the intentionally unresolved target from running on a later + // editor update and polluting the test run's console guard. + UiOperationManager.ResetForTests(); + } + } + + [Test] + public void OperationManager_ConstructorFailureCompletesAndAdvancesQueue() + { + UiOperationManager.ResetForTests(); + try + { + var failedStart = UiOperationManager.Start("inspect", new Dictionary + { + ["target"] = "Assets/DoesNotExist.uxml", + ["failOnWarnings"] = "not-a-boolean" + }); + var followingStart = UiOperationManager.Start("inspect", new Dictionary + { + ["failOnWarnings"] = "also-not-a-boolean" + }); + + UiOperationManager.TickForTests(); + + var failed = UiOperationManager.Status(failedStart["operationId"].ToString()); + Assert.That(failed["status"], Is.EqualTo("failed")); + var error = (Dictionary)failed["error"]; + Assert.That(error["code"], Is.EqualTo("invalid_params")); + + var following = UiOperationManager.Status(followingStart["operationId"].ToString()); + Assert.That(following["status"], Is.EqualTo("failed")); + Assert.That( + ((Dictionary)following["error"])["code"], + Is.EqualTo("invalid_params")); + } + finally + { + UiOperationManager.ResetForTests(); + } + } + + [Test] + public void Cleanup_RunsEveryActionAndRetainsFirstFailure() + { + var visited = new List(); + var expected = new InvalidOperationException("first"); + + var result = UiCleanup.RunAll( + () => + { + visited.Add(1); + throw expected; + }, + () => visited.Add(2), + () => + { + visited.Add(3); + throw new InvalidOperationException("later"); + }); + + Assert.That(visited, Is.EqualTo(new[] { 1, 2, 3 })); + Assert.That(result, Is.SameAs(expected)); + } + + [Test] + public void OperationRecord_PreservesResultWhenCleanupFails() + { + var record = new UiOperationRecord( + "ui-test", + "inspect", + new Dictionary()); + record.MarkRunning(); + record.Complete( + new Dictionary { ["stateCount"] = 1 }, + UiRenderOperation.BuildError(new UiOperationException( + "cleanup_failed", + "cleanup failed"))); + + var envelope = record.Envelope(); + Assert.That(envelope["status"], Is.EqualTo("failed")); + Assert.That(envelope.ContainsKey("result"), Is.True); + Assert.That(envelope.ContainsKey("error"), Is.True); + Assert.That( + ((Dictionary)envelope["error"])["code"], + Is.EqualTo("cleanup_failed")); + } + + [Test] + public void CaptureArtifactFileName_DisambiguatesLossyStateTokens() + { + var first = UiRenderOperation.CaptureArtifactFileName( + "Assets/Panel.uxml", + "a/b", + 0, + "ui-test"); + var second = UiRenderOperation.CaptureArtifactFileName( + "Assets/Panel.uxml", + "a-b", + 1, + "ui-test"); + + Assert.That(first, Is.EqualTo("Panel-a-b-s0000-ui-test.png")); + Assert.That(second, Is.EqualTo("Panel-a-b-s0001-ui-test.png")); + Assert.That(first, Is.Not.EqualTo(second)); + Assert.That( + UiRenderOperation.CaptureArtifactFileName("Assets/Panel.uxml", "a/b", 0, "ui-test"), + Is.EqualTo(first)); + } + + [Test] + public void Inspector_AppliesSimpleQueryAndElementBound() + { + var root = new VisualElement { name = "root" }; + var first = new Label("First") { name = "first" }; + first.AddToClassList("match"); + var second = new Label("Second") { name = "second" }; + second.AddToClassList("match"); + root.Add(first); + root.Add(second); + + var options = UiInspectOptions.Parse(new Dictionary + { + ["query"] = ".match", + ["detail"] = "summary", + ["max"] = 1L + }); + var result = UiVisualTreeInspector.Inspect( + root, + options, + Array.Empty()); + + Assert.That(Convert.ToInt32(result["matchCount"]), Is.EqualTo(2)); + Assert.That(Convert.ToInt32(result["returnedElementCount"]), Is.EqualTo(1)); + Assert.That(Convert.ToBoolean(result["truncated"]), Is.True); + } + + [Test] + public void Audit_ReportsDuplicateStaticNames() + { + var root = new VisualElement(); + root.Add(new Label { name = "duplicate" }); + root.Add(new Label { name = "duplicate" }); + + var result = UiAudit.Run( + root, + new UiApplyReport(), + Array.Empty()); + + Assert.That(Convert.ToInt32(result["errorCount"]), Is.EqualTo(0)); + Assert.That(Convert.ToInt32(result["warningCount"]), Is.EqualTo(1)); + var warnings = (List)result["warnings"]; + var warning = (Dictionary)warnings[0]; + Assert.That(warning["code"], Is.EqualTo("duplicate_name")); + } + + [Test] + public void Audit_UsesOneGlobalDiagnosticBudgetIncludingDuplicateNames() + { + var report = new UiApplyReport(); + for (var index = 0; index < 199; index++) + report.AddDiagnostic("error", "test", "test diagnostic"); + + var root = new VisualElement(); + root.Add(new VisualElement { name = "first-duplicate" }); + root.Add(new VisualElement { name = "first-duplicate" }); + root.Add(new VisualElement { name = "second-duplicate" }); + root.Add(new VisualElement { name = "second-duplicate" }); + + var result = UiAudit.Run( + root, + report, + Array.Empty()); + + Assert.That(Convert.ToInt32(result["errorCount"]), Is.EqualTo(199)); + Assert.That(Convert.ToInt32(result["warningCount"]), Is.EqualTo(1)); + Assert.That(result["diagnosticsTruncated"], Is.True); + } + + [Test] + public void Audit_IgnoresHiddenFocusableElementsAndUnityInternalNames() + { + var root = new VisualElement(); + var displayNone = new VisualElement(); + displayNone.style.display = DisplayStyle.None; + displayNone.Add(new VisualElement { name = "hidden-by-display", focusable = true }); + root.Add(displayNone); + + var visibilityHidden = new VisualElement(); + visibilityHidden.style.visibility = Visibility.Hidden; + visibilityHidden.Add(new VisualElement { name = "hidden-by-visibility", focusable = true }); + root.Add(visibilityHidden); + + root.Add(new VisualElement { name = "unity-generated" }); + root.Add(new VisualElement { name = "unity-generated" }); + root.Add(new VisualElement { name = "authored-name" }); + root.Add(new VisualElement { name = "authored-name" }); + + var result = UiAudit.Run( + root, + new UiApplyReport(), + Array.Empty()); + var warnings = (List)result["warnings"]; + + Assert.That(warnings, Has.Count.EqualTo(1)); + var warning = (Dictionary)warnings[0]; + Assert.That(warning["code"], Is.EqualTo("duplicate_name")); + Assert.That( + ((Dictionary)warning["element"])["name"], + Is.EqualTo("authored-name")); + } + + [Test] + public void OperationDurationLimit_IsOverallAndReportsCompletedStates() + { + Assert.That( + UiOperationManager.HasExceededOperationDuration( + 10.0, + 10.0 + UiOperationManager.MaxOperationDurationSeconds), + Is.False); + Assert.That( + UiOperationManager.HasExceededOperationDuration( + 10.0, + 10.001 + UiOperationManager.MaxOperationDurationSeconds), + Is.True); + + var error = UiRenderOperation.BuildError( + UiOperationManager.CreateOperationTimeout("check", 3, 301.0)); + Assert.That(error["code"], Is.EqualTo("timeout")); + var details = (Dictionary)error["details"]; + Assert.That(details["scope"], Is.EqualTo("operation")); + Assert.That(Convert.ToInt32(details["completedStateCount"]), Is.EqualTo(3)); + } + + [Test] + public void GeometrySampler_IsDeterministicForUnchangedTree() + { + var root = new VisualElement { name = "root" }; + root.Add(new Label("Stable") { name = "label" }); + + var first = UiGeometrySampler.Measure(root); + var second = UiGeometrySampler.Measure(root); + + Assert.That(first.Hash, Is.EqualTo(second.Hash)); + Assert.That(first.ElementCount, Is.EqualTo(2)); + Assert.That(first.PanelAttached, Is.False); + Assert.That(first.IsValid, Is.False); + } + + [Test] + public void JsonSanitizer_ReplacesNonFiniteNumbersWithNull() + { + var result = (Dictionary)UiJsonSanitizer.Sanitize( + new Dictionary + { + ["nan"] = double.NaN, + ["infinity"] = float.PositiveInfinity, + ["finite"] = 4.25 + }); + + Assert.That(result["nan"], Is.Null); + Assert.That(result["infinity"], Is.Null); + Assert.That(result["finite"], Is.EqualTo(4.25)); + } + + [Test] + public void JsonSanitizer_NormalizesScalarsUnsupportedByMiniJson() + { + var result = (Dictionary)UiJsonSanitizer.Sanitize( + new Dictionary + { + ["character"] = 'x', + ["byte"] = (byte)12, + ["signedByte"] = (sbyte)-4, + ["short"] = (short)-123, + ["unsignedShort"] = ushort.MaxValue, + ["unsignedInt"] = uint.MaxValue, + ["unsignedLong"] = ulong.MaxValue, + ["decimal"] = 123.4500m + }); + + Assert.That(result["character"], Is.EqualTo("x")); + Assert.That(result["byte"], Is.TypeOf().And.EqualTo(12)); + Assert.That(result["signedByte"], Is.TypeOf().And.EqualTo(-4)); + Assert.That(result["short"], Is.TypeOf().And.EqualTo(-123)); + Assert.That(result["unsignedShort"], Is.TypeOf().And.EqualTo(ushort.MaxValue)); + Assert.That(result["unsignedInt"], Is.TypeOf().And.EqualTo((long)uint.MaxValue)); + Assert.That(result["unsignedLong"], Is.EqualTo(ulong.MaxValue.ToString())); + Assert.That(result["decimal"], Is.EqualTo("123.4500")); + + var serialized = MiniJson.Serialize(result); + Assert.That(serialized, Does.Not.Contain("{}")); + Assert.That(MiniJson.Deserialize(serialized), Is.TypeOf>()); + } + } +} +#endif diff --git a/unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs.meta b/unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs.meta new file mode 100644 index 0000000..edb8129 --- /dev/null +++ b/unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f28fa266893c4194b8fc5bf7db17333f diff --git a/unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs b/unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs new file mode 100644 index 0000000..e3d93e6 --- /dev/null +++ b/unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs @@ -0,0 +1,102 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace UCP.Bridge.Tests +{ + public sealed class UiLintServiceTests + { + private const string Root = "Assets/UcpUiLintServiceTests"; + + [SetUp] + public void SetUp() + { + AssetDatabase.DeleteAsset(Root); + AssetDatabase.CreateFolder("Assets", "UcpUiLintServiceTests"); + } + + [TearDown] + public void TearDown() + { + AssetDatabase.DeleteAsset(Root); + } + + [Test] + public void ValidUxmlAndUss_AreImportedAndCloned() + { + var ussPath = Root + "/Valid.uss"; + var uxmlPath = Root + "/Valid.uxml"; + WriteAsset(ussPath, ".probe { width: 100px; }\n"); + WriteAsset( + uxmlPath, + "\n" + + " \n" + + "\n"); + + var result = UiLintService.Run(new Dictionary + { + ["paths"] = new List { uxmlPath, ussPath }, + ["failOnWarnings"] = true, + ["maxDiagnostics"] = 50 + }); + + Assert.That(result["passed"], Is.True); + Assert.That(Convert.ToInt32(result["errorCount"]), Is.EqualTo(0)); + Assert.That(Convert.ToInt32(result["warningCount"]), Is.EqualTo(0)); + var assets = ((List)result["assets"]) + .Cast>() + .ToList(); + var uxml = assets.Single(asset => asset["path"].ToString() == uxmlPath); + Assert.That(uxml["cloneAttempted"], Is.True); + Assert.That(uxml["cloneSucceeded"], Is.True); + Assert.That(Convert.ToInt32(uxml["cloneElementCount"]), Is.GreaterThanOrEqualTo(2)); + } + + [TestCase("ImportError", "error")] + [TestCase("Warning", "warning")] + [TestCase("Info", null)] + [TestCase(null, null)] + public void ImportSeverity_IsClassifiedWithoutEmittingConsoleErrors( + string flags, + string expected) + { + Assert.That(UiLintService.ClassifyImportSeverity(flags), Is.EqualTo(expected)); + } + + [TestCase(LogType.Error, "error")] + [TestCase(LogType.Assert, "error")] + [TestCase(LogType.Exception, "error")] + [TestCase(LogType.Warning, "warning")] + [TestCase(LogType.Log, null)] + public void CloneLogSeverity_IsClassifiedWithoutEmittingConsoleErrors( + LogType type, + string expected) + { + Assert.That(UiLintService.ClassifyLogSeverity(type), Is.EqualTo(expected)); + } + + [Test] + public void WarningFailurePolicy_IsExplicit() + { + Assert.That(UiLintService.Passes(0, 1, false), Is.True); + Assert.That(UiLintService.Passes(0, 1, true), Is.False); + Assert.That(UiLintService.Passes(1, 0, false), Is.False); + } + + private static void WriteAsset(string assetPath, string contents) + { + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + var absolutePath = Path.Combine(projectRoot, assetPath); + File.WriteAllText(absolutePath, contents); + AssetDatabase.ImportAsset( + assetPath, + ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + } + } +} +#endif diff --git a/unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs.meta b/unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs.meta new file mode 100644 index 0000000..56e66f5 --- /dev/null +++ b/unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c81e33a1de8b49f9b9820567cb3e4b40 diff --git a/unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs b/unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs new file mode 100644 index 0000000..be479d1 --- /dev/null +++ b/unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs @@ -0,0 +1,304 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace UCP.Bridge.Tests +{ + public sealed class UiScenarioTests + { + private const string TestFolder = "Assets/UcpUiScenarioTests"; + private const string DocumentPath = TestFolder + "/Document.uxml"; + private const string RowPath = TestFolder + "/Row.uxml"; + + [SetUp] + public void SetUp() + { + DeleteTestFolder(); + Directory.CreateDirectory(AbsolutePath(TestFolder)); + File.WriteAllText( + AbsolutePath(DocumentPath), + ""); + File.WriteAllText( + AbsolutePath(RowPath), + ""); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + + [TearDown] + public void TearDown() + { + DeleteTestFolder(); + } + + [Test] + public void Resolve_RejectsUnknownFixtureFields() + { + var fixturePath = WriteFixture( + "Unknown.ucp-ui.json", + "{\"schemaVersion\":0,\"document\":\"./Document.uxml\",\"unexpected\":true,\"states\":{\"default\":{}}}"); + + var exception = Assert.Throws(() => UiScenarioLoader.Resolve( + new Dictionary { ["target"] = fixturePath })); + + Assert.That(exception.Code, Is.EqualTo("ui.unknown-field")); + Assert.That(exception.Location, Does.EndWith(".unexpected")); + } + + [Test] + public void Resolve_RejectsMoreThanSixtyFourStates() + { + var states = new Dictionary(); + for (var index = 0; index < 65; index++) + states["state-" + index] = new Dictionary(); + + var fixturePath = WriteFixture( + "TooManyStates.ucp-ui.json", + MiniJson.Serialize(new Dictionary + { + ["schemaVersion"] = 0, + ["document"] = "./Document.uxml", + ["defaultState"] = "state-0", + ["states"] = states + })); + + var exception = Assert.Throws(() => UiScenarioLoader.Resolve( + new Dictionary { ["target"] = fixturePath }, + allStates: true)); + + Assert.That(exception.Code, Is.EqualTo("ui.states-limit")); + Assert.That(exception.Location, Does.EndWith("#states")); + } + + [Test] + public void Resolve_RejectsViewportAreaForFixtureAndPartialOverride() + { + var oversizedPath = WriteFixture( + "OversizedViewport.ucp-ui.json", + "{\"schemaVersion\":0,\"document\":\"./Document.uxml\",\"viewport\":{\"width\":4096,\"height\":2049},\"states\":{\"default\":{}}}"); + + var fixtureException = Assert.Throws(() => UiScenarioLoader.Resolve( + new Dictionary { ["target"] = oversizedPath })); + Assert.That(fixtureException.Code, Is.EqualTo("ui.viewport-area")); + Assert.That(fixtureException.Location, Does.EndWith("#viewport")); + + var boundaryPath = WriteFixture( + "BoundaryViewport.ucp-ui.json", + "{\"schemaVersion\":0,\"document\":\"./Document.uxml\",\"viewport\":{\"width\":4096,\"height\":2048},\"states\":{\"default\":{}}}"); + Assert.That(UiScenarioLoader.Resolve( + new Dictionary { ["target"] = boundaryPath })[0].Viewport.Height, + Is.EqualTo(2048)); + + var requestException = Assert.Throws(() => UiScenarioLoader.Resolve( + new Dictionary + { + ["target"] = boundaryPath, + ["viewport"] = new Dictionary { ["height"] = 2049L } + })); + Assert.That(requestException.Code, Is.EqualTo("ui.viewport-area")); + Assert.That(requestException.Location, Is.EqualTo("$request.viewport")); + } + + [Test] + public void Resolve_SelectsDefaultAndMergesNestedRequestData() + { + var fixturePath = WriteFixture( + "States.ucp-ui.json", + "{" + + "\"schemaVersion\":0," + + "\"document\":\"./Document.uxml\"," + + "\"defaultState\":\"populated\"," + + "\"data\":{\"nested\":{\"shared\":\"base\",\"count\":1}}," + + "\"states\":{" + + "\"empty\":{\"data\":{\"items\":[]}}," + + "\"populated\":{\"viewport\":{\"width\":800,\"height\":500},\"data\":{\"nested\":{\"count\":2},\"items\":[{\"name\":\"One\"}]}}" + + "}}"); + + var resolved = UiScenarioLoader.Resolve(new Dictionary + { + ["target"] = fixturePath, + ["data"] = new Dictionary + { + ["nested"] = new Dictionary { ["count"] = 9L } + } + }); + + Assert.That(resolved, Has.Count.EqualTo(1)); + Assert.That(resolved[0].StateName, Is.EqualTo("populated")); + Assert.That(resolved[0].DocumentPath, Is.EqualTo(DocumentPath)); + Assert.That(resolved[0].Viewport.Width, Is.EqualTo(800)); + Assert.That(resolved[0].Viewport.Height, Is.EqualTo(500)); + var nested = (Dictionary)resolved[0].Data["nested"]; + Assert.That(nested["shared"], Is.EqualTo("base")); + Assert.That(nested["count"], Is.TypeOf().And.EqualTo(9)); + + var all = UiScenarioLoader.Resolve( + new Dictionary { ["target"] = fixturePath }, + allStates: true); + Assert.That(all.Select(scenario => scenario.StateName), Is.EqualTo(new[] { "empty", "populated" })); + } + + [Test] + public void Resolve_NormalizesOnlySafeIntegersToInt32() + { + var fixturePath = WriteFixture( + "Numbers.ucp-ui.json", + "{\"schemaVersion\":0,\"document\":\"./Document.uxml\",\"data\":{\"small\":42,\"large\":2147483648,\"items\":[-1,2147483647]},\"states\":{\"default\":{}}}"); + + var scenario = UiScenarioLoader.Resolve( + new Dictionary { ["target"] = fixturePath })[0]; + var items = (List)scenario.Data["items"]; + + Assert.That(scenario.Data["small"], Is.TypeOf().And.EqualTo(42)); + Assert.That(scenario.Data["large"], Is.TypeOf().And.EqualTo(2147483648L)); + Assert.That(items[0], Is.TypeOf().And.EqualTo(-1)); + Assert.That(items[1], Is.TypeOf().And.EqualTo(int.MaxValue)); + } + + [Test] + public void Apply_BuildsRepeatAndOwnsListViewBindingLifecycle() + { + var document = AssetDatabase.LoadAssetAtPath(DocumentPath); + var rowTemplate = AssetDatabase.LoadAssetAtPath(RowPath); + Assert.That(document, Is.Not.Null); + Assert.That(rowTemplate, Is.Not.Null); + + var items = new List + { + Item("One"), + Item("Two"), + Item("Three") + }; + var data = new Dictionary { ["items"] = items }; + var collections = new List + { + new UiCollectionDefinition("#repeat", UiCollectionMode.Repeat, "/items", RowPath, rowTemplate, null, "$test.repeat"), + new UiCollectionDefinition("#list", UiCollectionMode.ListView, "/items", RowPath, rowTemplate, 24f, "$test.list") + }; + var scenario = new UiResolvedScenario( + DocumentPath, + null, + "default", + DocumentPath, + document, + data, + Array.Empty(), + collections, + new UiViewport(), + new UiSettleOptions()); + + var root = new VisualElement(); + var repeat = new VisualElement { name = "repeat" }; + var list = new ListView { name = "list" }; + root.Add(repeat); + root.Add(list); + + var report = UiScenarioApplier.Apply(root, scenario); + + Assert.That(repeat.childCount, Is.EqualTo(3)); + Assert.That(report.RepeatItemCount, Is.EqualTo(3)); + Assert.That(list.itemsSource, Is.SameAs(items)); + Assert.That(list.makeItem, Is.Not.Null); + Assert.That(list.bindItem, Is.Not.Null); + Assert.That(list.unbindItem, Is.Not.Null); + Assert.That(list.destroyItem, Is.Not.Null); + Assert.That(list.virtualizationMethod, Is.EqualTo(CollectionVirtualizationMethod.FixedHeight)); + Assert.That(list.fixedItemHeight, Is.EqualTo(24f)); + + var row = list.makeItem(); + list.bindItem(row, 1); + Assert.That(row.dataSource, Is.SameAs(items[1])); + + var metadata = UiScenarioApplier.GetCollectionMetadata(root); + Assert.That(metadata, Has.Count.EqualTo(2)); + var repeatMetadata = metadata.Single(item => item.Mode == UiCollectionMode.Repeat); + var listMetadata = metadata.Single(item => item.Mode == UiCollectionMode.ListView); + Assert.That(repeatMetadata.LogicalItemCount, Is.EqualTo(3)); + Assert.That(repeatMetadata.RealizedRowCount, Is.EqualTo(3)); + Assert.That(repeatMetadata.BoundRowCount, Is.EqualTo(3)); + Assert.That(listMetadata.LogicalItemCount, Is.EqualTo(3)); + Assert.That(listMetadata.RealizedRowCount, Is.EqualTo(1)); + Assert.That(listMetadata.BoundRowCount, Is.EqualTo(1)); + + list.bindItem(row, 2); + Assert.That(listMetadata.RealizedRowCount, Is.EqualTo(1)); + Assert.That(listMetadata.BoundRowCount, Is.EqualTo(1)); + var boundRows = (List)listMetadata.ToDictionary()["boundRows"]; + Assert.That(((Dictionary)boundRows[0])["index"], Is.EqualTo(2)); + + list.unbindItem(row, 2); + Assert.That(row.dataSource, Is.Null); + Assert.That(listMetadata.BoundRowCount, Is.Zero); + + list.bindItem(row, 0); + Assert.That(listMetadata.BoundRowCount, Is.EqualTo(1)); + list.destroyItem(row); + Assert.That(row.dataSource, Is.Null); + Assert.That(listMetadata.RealizedRowCount, Is.Zero); + Assert.That(listMetadata.BoundRowCount, Is.Zero); + Assert.That(report.BindingRewriteCount, Is.GreaterThanOrEqualTo(4)); + } + + [Test] + public void Apply_RejectsAmbiguousSelectors() + { + var document = AssetDatabase.LoadAssetAtPath(DocumentPath); + var root = new VisualElement(); + var first = new VisualElement(); + var second = new VisualElement(); + first.AddToClassList("duplicate"); + second.AddToClassList("duplicate"); + root.Add(first); + root.Add(second); + + var scenario = new UiResolvedScenario( + DocumentPath, + null, + "default", + DocumentPath, + document, + new Dictionary(), + new[] { new UiSetOperation(".duplicate", "enabled", true, "$test.set") }, + Array.Empty(), + new UiViewport(), + new UiSettleOptions()); + + var exception = Assert.Throws(() => UiScenarioApplier.Apply(root, scenario)); + Assert.That(exception.Code, Is.EqualTo("ui.selector-ambiguous")); + } + + private static Dictionary Item(string name) + { + return new Dictionary { ["name"] = name }; + } + + private static string WriteFixture(string filename, string contents) + { + var assetPath = TestFolder + "/" + filename; + File.WriteAllText(AbsolutePath(assetPath), contents); + AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate); + return assetPath; + } + + private static void DeleteTestFolder() + { + if (AssetDatabase.IsValidFolder(TestFolder)) + AssetDatabase.DeleteAsset(TestFolder); + else if (Directory.Exists(AbsolutePath(TestFolder))) + Directory.Delete(AbsolutePath(TestFolder), true); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + + private static string AbsolutePath(string assetPath) + { + var projectRoot = Path.GetDirectoryName(Application.dataPath); + return Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar)); + } + } +} +#endif diff --git a/unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs.meta b/unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs.meta new file mode 100644 index 0000000..d0635d2 --- /dev/null +++ b/unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 24054995f38041c5a45ecd15210b5dc5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/website/.generated/docs/agents/skills.md b/website/.generated/docs/agents/skills.md index 112f890..125d40a 100644 --- a/website/.generated/docs/agents/skills.md +++ b/website/.generated/docs/agents/skills.md @@ -71,7 +71,7 @@ Install focused, surface-specific skills — one per `ucp` command group — ins /plugin install ucp-surfaces@unity-control-protocol ``` -That install exposes fifteen focused skills, each invoked as `/ucp-surfaces:ucp-`: +That install exposes sixteen focused skills, each invoked as `/ucp-surfaces:ucp-`: - `/ucp-surfaces:ucp-objects` — `ucp object` (create incl. `--primitive`, components, properties, reparent, instantiate) - `/ucp-surfaces:ucp-scene` — `ucp scene` + `ucp editor` lifecycle + `ucp play|stop|pause|compile|screenshot` @@ -79,6 +79,7 @@ That install exposes fifteen focused skills, each invoked as `/ucp-surfaces:ucp- - `/ucp-surfaces:ucp-spatial` — `ucp spatial` (raycast/overlap/bounds/ground/nearest) - `/ucp-surfaces:ucp-view` — `ucp view` (capture/isolate/orbit) + `ucp screenshot` + `ucp record` - `/ucp-surfaces:ucp-assets` — `ucp asset` + `ucp files` + `ucp shader errors` +- `/ucp-surfaces:ucp-ui` — `ucp ui` (list/lint/inspect/screenshot/check) for UI Toolkit - `/ucp-surfaces:ucp-materials` — `ucp material` (create/get/set properties, keywords, shader) - `/ucp-surfaces:ucp-prefabs` — `ucp prefab` (status/apply/revert/unpack/create/overrides) - `/ucp-surfaces:ucp-build` — `ucp build` (targets/scenes/defines/start) diff --git a/website/.generated/docs/authoring/ui-toolkit.md b/website/.generated/docs/authoring/ui-toolkit.md new file mode 100644 index 0000000..e4e03a9 --- /dev/null +++ b/website/.generated/docs/authoring/ui-toolkit.md @@ -0,0 +1,160 @@ +# UI Toolkit + +`ucp ui` gives agents a tight authoring loop for UI Toolkit UXML and USS: discover targets, lint with Unity's importers, inspect a live resolved tree, capture a PNG, or run the full check in one command. + +The harness requires Unity 6 or newer. `ui lint` works in batch mode and with the Null graphics device; `ui inspect`, `ui screenshot`, and `ui check` require an interactive Editor with a graphics device. + +## Commands + +```bash +ucp ui list +ucp ui lint Assets/UI/Inventory.uxml Assets/UI/Inventory.uss +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --json +ucp ui screenshot Assets/UI/Inventory.uxml --width 960 --height 640 -o inventory.png +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui +``` + +Every path must resolve to a location under `Assets/` or `Packages/`; absolute and scenario-relative paths are accepted as long as they land there. A render target can be either a `.uxml` asset or a strict `.ucp-ui.json` scenario. + +### `ucp ui list` + +Discovers UXML documents and scenarios without instantiating them. + +```bash +ucp ui list --root Assets/UI --limit 50 +ucp ui list --include-packages --json +``` + +Invalid scenarios remain in the result with `valid: false` and a structured diagnostic, which makes discovery useful while a fixture is still being authored. + +### `ucp ui lint ` + +Runs Unity's synchronous UXML and USS importers, reads their import logs and flags, resolves dependencies, and clones each UXML tree while capturing scoped Unity diagnostics. The clone step matters because an unknown UXML element can import without an error and fail only when Unity tries to instantiate it. + +```bash +ucp ui lint Assets/UI +ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings --max-diagnostics 200 +``` + +A scenario lint also validates its schema and lints its document, USS dependencies, and collection templates. Errors fail the command. Warnings fail only with `--fail-on-warnings`. + +### `ucp ui inspect ` + +Instantiates the target in an isolated Editor panel, waits for finite stable layout, and returns a bounded semantic snapshot. It includes names, classes, text/value, layout, world bounds, a fixed resolved-style allowlist, binding results, and dynamic-collection counts. + +```bash +ucp ui inspect Assets/UI/Inventory.uxml --query '#toolbar' --depth 4 --max-elements 100 +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state empty --detail verbose --json +``` + +`--query` accepts one simple `#name`, `.class`, or element-type selector. Detail is `summary`, `normal`, or `verbose`. The snapshot reports actual UI Toolkit state; it does not claim matched-USS-rule provenance or an accessibility tree. + +### `ucp ui screenshot ` + +Captures the resolved Editor panel after stable geometry and pixel samples. The defaults are three identical finite geometry samples and two identical pixel samples; a scenario can override them with `settle`. + +```bash +ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png +ucp ui screenshot Assets/UI/Inventory.uxml --width 1280 --height 720 -o artifacts/wide.png --force +``` + +Without `--out`, the bridge retains the PNG under `Library/UCP/UiCaptures` and returns its path and pixel hash. `--out` copies that artifact without overwriting an existing file unless `--force` is explicit. Width and height must be supplied together; if omitted, a scenario's viewport is preserved and direct UXML uses `960x640`. + +The capture backend briefly opens and focuses a transient utility window, then closes it and restores the previously focused window. It does not load or dirty a scene. Use a longer global `--timeout` for scenarios with many states or deliberately slow deferred UI updates. + +### `ucp ui check ` + +Runs fixture validation and lint, then instantiate, settle, inspect, conservative audits, capture, and cleanup as one bridge-owned operation. + +```bash +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --json +ucp ui check Assets/UI/Inventory.ucp-ui.json --state populated --fail-on-warnings +``` + +The v0 audits treat binding/application failures as errors. They warn for focusable zero-size controls, focusable controls outside the viewport, and duplicate element names outside harness-managed collections. They intentionally avoid speculative claims such as unused selectors or contrast failures. `--fail-on-warnings` makes both lint and audit warnings fail the command. + +## Scenario format + +A scenario makes dynamic UI states reproducible without requiring a compiled code-behind type: + +```json +{ + "schemaVersion": 0, + "document": "./Inventory.uxml", + "defaultState": "populated", + "viewport": { "width": 960, "height": 640 }, + "settle": { + "stableFrames": 3, + "pixelStableFrames": 2, + "timeoutSeconds": 15 + }, + "data": { "title": "Inventory" }, + "states": { + "empty": { + "data": { "items": [] }, + "set": [ + { "target": "#empty-message", "property": "display", "value": "flex" } + ] + }, + "populated": { + "data": { + "items": [ + { "name": "Potion", "countLabel": "3" }, + { "name": "Key", "countLabel": "1" } + ] + }, + "collections": [ + { + "target": "#cards", + "mode": "repeat", + "source": "/items", + "template": "./InventoryCard.uxml" + }, + { + "target": "#rows", + "mode": "list-view", + "source": "/items", + "template": "./InventoryRow.uxml", + "itemHeight": 32 + } + ] + } + } +} +``` + +The schema rejects unknown fields and paths outside the project. `states` is required and must contain 1–64 states. `defaultState` is optional only when a state is named `default` or the fixture has exactly one state; otherwise it is required. Viewports allow 1–8192 pixels per axis and at most 8,388,608 total pixels. A render operation has a 300-second enqueue-to-completion ceiling across all selected states. `document` and collection `template` paths are relative to the scenario unless they begin with `Assets/` or `Packages/`. Top-level data is deeply overlaid by state data and then by `--data-json` or `--data-file`. + +`set` supports the deliberately small property allowlist `text`, `value`, `enabled`, `display`, `visibility`, `tooltip`, and `class:`. Targets are `:root`, `#name`, or `.class` and must resolve to one element. + +## Data binding and collections + +Author bindings as ordinary UI Toolkit paths. The harness adapts those paths to its JSON dictionary data source after cloning: + +```xml + + + + + +``` + +Each collection `source` is an RFC 6901-style JSON pointer into the resolved data, such as `/items` or `/inventory/rows`, and must resolve to an array. Each `repeat` or `list-view` item receives one array value as its data source, so the same row UXML works in either mode. + +- `repeat` eagerly clones every row. Use it for small flex-wrap grids and layouts where every item must exist at once. +- `list-view` assigns `itemsSource` and owns `makeItem`, `bindItem`, and `unbindItem`. It uses dynamic-height virtualization by default; setting `itemHeight` selects fixed-height virtualization. It reports logical, realized, bound, and currently visible row counts. + +JSON integers that fit in 32 bits are normalized for controls such as `IntegerField`. Larger integers remain 64-bit and produce a warning because some UI Toolkit controls cannot bind them automatically. + +## Recommended agent loop + +```bash +ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --query '#cards' --json +ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png --force +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --force --json +``` + +Use lint for the fast import/schema pass, inspect to reason about resolved geometry and bindings, and screenshot for visual judgment. Run check before handing off the UI. diff --git a/website/.generated/docs/getting-started/introduction.md b/website/.generated/docs/getting-started/introduction.md index 1f0f798..67cbeee 100644 --- a/website/.generated/docs/getting-started/introduction.md +++ b/website/.generated/docs/getting-started/introduction.md @@ -19,6 +19,7 @@ Together, they let you automate virtually any Unity Editor operation from the te - **Project Settings** - Read/write player, quality, physics, and lighting settings - **Prefab Workflow** - Check status, apply/revert overrides, create prefabs, unpack instances - **Build Pipeline** - List targets, manage scenes, control scripting defines, trigger builds +- **UI Toolkit** - Lint UXML/USS with Unity's importers, populate scenarios, inspect resolved layout, capture panel screenshots - **Version Control** - Lightweight Unity VCS fallback commands; prefer native `cm` for full UVCS workflows - **Editor Scripting** - Execute custom C# scripts remotely with parameters - **Cross-Platform** - Works on macOS (x64 + ARM), Linux, and Windows diff --git a/website/.generated/skills-micro/ucp-ui/SKILL.md b/website/.generated/skills-micro/ucp-ui/SKILL.md new file mode 100644 index 0000000..d7976c4 --- /dev/null +++ b/website/.generated/skills-micro/ucp-ui/SKILL.md @@ -0,0 +1,43 @@ +--- +name: ucp-ui +description: >- + Author and verify UI Toolkit with `ucp ui` + (list/lint/inspect/screenshot/check) using UXML, USS, and strict + dynamic-data scenarios. Use when the user wants to lint, inspect, + populate, screenshot, or verify UI Toolkit UXML and USS. For broad + multi-surface Unity automation, use the unity-control-protocol skill + instead. +homepage: https://github.com/mflRevan/unity-control-protocol +compatibility: Requires the `ucp` CLI, the UCP Bridge package, and Unity 6.0+ in the target project. +metadata: + author: mflRevan + version: '0.6.0' +--- + +# UCP UI Toolkit + +Focused micro-skill for the `ucp ui` command surface of the +Unity Control Protocol (`ucp`) CLI. Always confirm the live surface with +`ucp --help` and see the docs at https://unityctl.dev. + +## Examples + +```bash +ucp ui list --root Assets/UI +ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --query '#cards' --json +ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png --force +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --force --json +``` + +## When to use + +Use on Unity 6+ after editing UXML/USS: lint for importer/schema feedback, inspect for resolved geometry and bindings, screenshot for visual judgment, and check for the full multi-state pass. + +## When NOT to use (use the omni skill instead) + +Do not use UI capture commands in batchmode or with the Null graphics device; `ucp ui lint` remains available there. Omit width/height to preserve a scenario viewport. + +For broad, multi-surface Unity automation that spans several of these +command groups at once, use the `unity-control-protocol` omni skill instead +of this focused micro-skill. diff --git a/website/.generated/skills/unity-control-protocol/SKILL.md b/website/.generated/skills/unity-control-protocol/SKILL.md index 283612f..4c30746 100644 --- a/website/.generated/skills/unity-control-protocol/SKILL.md +++ b/website/.generated/skills/unity-control-protocol/SKILL.md @@ -3,7 +3,7 @@ name: unity-control-protocol description: >- Programmatic control of the Unity Editor from the terminal via the `ucp` CLI. Automate scenes, GameObjects, components, assets, materials, prefabs, build - pipelines, settings, tests, packages, selective `.unitypackage` import, debugging and profiling + pipelines, UI Toolkit UXML/USS, settings, tests, packages, selective `.unitypackage` import, debugging and profiling over a WebSocket/JSON-RPC 2.0 bridge. Use when the user asks to inspect, create, modify, or automate anything inside a Unity project without opening the Editor UI. @@ -28,6 +28,7 @@ UCP and Unity expose a broad command surface. If you are unsure what is availabl - The user needs to browse/install Unity packages, manage scoped registries, or selectively import `.unitypackage` content - The user wants to automate entire Unity Editor workflows - The user wants to find all references to an asset, script, material, or prefab across the project +- The user wants to lint, inspect, populate, screenshot, or verify UI Toolkit UXML and USS ## When NOT to use this skill @@ -101,6 +102,20 @@ Object reference writes accept `instanceId`, asset `path`, or asset `guid`, and Use `ucp asset move` / `bulk-move` for Unity-aware renames and folder cleanup instead of raw filesystem moves. That keeps `.meta` files and GUIDs intact so scenes, prefabs, build settings, and serialized object references continue to resolve. +## UI Toolkit authoring and verification + +Use `ucp ui` on Unity 6+ to close the loop after editing UXML, USS, or a strict `.ucp-ui.json` scenario. Lint is the fast importer/schema pass; inspect reports bounded resolved layout and binding state; screenshot provides visual evidence; check runs all of them with cleanup. + +```bash +ucp ui list --root Assets/UI +ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --query '#cards' --json +ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png --force +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --force --json +``` + +Use ordinary UXML `DataBinding` paths. Scenario data is JSON, and the harness adapts those paths to dictionary keys. Use `repeat` for small eager grids and harness-managed `list-view` collections for large virtualized lists. Width and height are an explicit pair; omit both to preserve a scenario viewport. Inspection, screenshot, and check briefly focus a transient Editor window and require a graphics device; lint also works headless. + ## In-scene authoring & spatial workflows For building and arranging scene content, use the dedicated transform/spatial/view commands rather diff --git a/website/public/docs/agents/skills.md b/website/public/docs/agents/skills.md index 112f890..125d40a 100644 --- a/website/public/docs/agents/skills.md +++ b/website/public/docs/agents/skills.md @@ -71,7 +71,7 @@ Install focused, surface-specific skills — one per `ucp` command group — ins /plugin install ucp-surfaces@unity-control-protocol ``` -That install exposes fifteen focused skills, each invoked as `/ucp-surfaces:ucp-`: +That install exposes sixteen focused skills, each invoked as `/ucp-surfaces:ucp-`: - `/ucp-surfaces:ucp-objects` — `ucp object` (create incl. `--primitive`, components, properties, reparent, instantiate) - `/ucp-surfaces:ucp-scene` — `ucp scene` + `ucp editor` lifecycle + `ucp play|stop|pause|compile|screenshot` @@ -79,6 +79,7 @@ That install exposes fifteen focused skills, each invoked as `/ucp-surfaces:ucp- - `/ucp-surfaces:ucp-spatial` — `ucp spatial` (raycast/overlap/bounds/ground/nearest) - `/ucp-surfaces:ucp-view` — `ucp view` (capture/isolate/orbit) + `ucp screenshot` + `ucp record` - `/ucp-surfaces:ucp-assets` — `ucp asset` + `ucp files` + `ucp shader errors` +- `/ucp-surfaces:ucp-ui` — `ucp ui` (list/lint/inspect/screenshot/check) for UI Toolkit - `/ucp-surfaces:ucp-materials` — `ucp material` (create/get/set properties, keywords, shader) - `/ucp-surfaces:ucp-prefabs` — `ucp prefab` (status/apply/revert/unpack/create/overrides) - `/ucp-surfaces:ucp-build` — `ucp build` (targets/scenes/defines/start) diff --git a/website/public/docs/authoring/ui-toolkit.md b/website/public/docs/authoring/ui-toolkit.md new file mode 100644 index 0000000..e4e03a9 --- /dev/null +++ b/website/public/docs/authoring/ui-toolkit.md @@ -0,0 +1,160 @@ +# UI Toolkit + +`ucp ui` gives agents a tight authoring loop for UI Toolkit UXML and USS: discover targets, lint with Unity's importers, inspect a live resolved tree, capture a PNG, or run the full check in one command. + +The harness requires Unity 6 or newer. `ui lint` works in batch mode and with the Null graphics device; `ui inspect`, `ui screenshot`, and `ui check` require an interactive Editor with a graphics device. + +## Commands + +```bash +ucp ui list +ucp ui lint Assets/UI/Inventory.uxml Assets/UI/Inventory.uss +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --json +ucp ui screenshot Assets/UI/Inventory.uxml --width 960 --height 640 -o inventory.png +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui +``` + +Every path must resolve to a location under `Assets/` or `Packages/`; absolute and scenario-relative paths are accepted as long as they land there. A render target can be either a `.uxml` asset or a strict `.ucp-ui.json` scenario. + +### `ucp ui list` + +Discovers UXML documents and scenarios without instantiating them. + +```bash +ucp ui list --root Assets/UI --limit 50 +ucp ui list --include-packages --json +``` + +Invalid scenarios remain in the result with `valid: false` and a structured diagnostic, which makes discovery useful while a fixture is still being authored. + +### `ucp ui lint ` + +Runs Unity's synchronous UXML and USS importers, reads their import logs and flags, resolves dependencies, and clones each UXML tree while capturing scoped Unity diagnostics. The clone step matters because an unknown UXML element can import without an error and fail only when Unity tries to instantiate it. + +```bash +ucp ui lint Assets/UI +ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings --max-diagnostics 200 +``` + +A scenario lint also validates its schema and lints its document, USS dependencies, and collection templates. Errors fail the command. Warnings fail only with `--fail-on-warnings`. + +### `ucp ui inspect ` + +Instantiates the target in an isolated Editor panel, waits for finite stable layout, and returns a bounded semantic snapshot. It includes names, classes, text/value, layout, world bounds, a fixed resolved-style allowlist, binding results, and dynamic-collection counts. + +```bash +ucp ui inspect Assets/UI/Inventory.uxml --query '#toolbar' --depth 4 --max-elements 100 +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state empty --detail verbose --json +``` + +`--query` accepts one simple `#name`, `.class`, or element-type selector. Detail is `summary`, `normal`, or `verbose`. The snapshot reports actual UI Toolkit state; it does not claim matched-USS-rule provenance or an accessibility tree. + +### `ucp ui screenshot ` + +Captures the resolved Editor panel after stable geometry and pixel samples. The defaults are three identical finite geometry samples and two identical pixel samples; a scenario can override them with `settle`. + +```bash +ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png +ucp ui screenshot Assets/UI/Inventory.uxml --width 1280 --height 720 -o artifacts/wide.png --force +``` + +Without `--out`, the bridge retains the PNG under `Library/UCP/UiCaptures` and returns its path and pixel hash. `--out` copies that artifact without overwriting an existing file unless `--force` is explicit. Width and height must be supplied together; if omitted, a scenario's viewport is preserved and direct UXML uses `960x640`. + +The capture backend briefly opens and focuses a transient utility window, then closes it and restores the previously focused window. It does not load or dirty a scene. Use a longer global `--timeout` for scenarios with many states or deliberately slow deferred UI updates. + +### `ucp ui check ` + +Runs fixture validation and lint, then instantiate, settle, inspect, conservative audits, capture, and cleanup as one bridge-owned operation. + +```bash +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --json +ucp ui check Assets/UI/Inventory.ucp-ui.json --state populated --fail-on-warnings +``` + +The v0 audits treat binding/application failures as errors. They warn for focusable zero-size controls, focusable controls outside the viewport, and duplicate element names outside harness-managed collections. They intentionally avoid speculative claims such as unused selectors or contrast failures. `--fail-on-warnings` makes both lint and audit warnings fail the command. + +## Scenario format + +A scenario makes dynamic UI states reproducible without requiring a compiled code-behind type: + +```json +{ + "schemaVersion": 0, + "document": "./Inventory.uxml", + "defaultState": "populated", + "viewport": { "width": 960, "height": 640 }, + "settle": { + "stableFrames": 3, + "pixelStableFrames": 2, + "timeoutSeconds": 15 + }, + "data": { "title": "Inventory" }, + "states": { + "empty": { + "data": { "items": [] }, + "set": [ + { "target": "#empty-message", "property": "display", "value": "flex" } + ] + }, + "populated": { + "data": { + "items": [ + { "name": "Potion", "countLabel": "3" }, + { "name": "Key", "countLabel": "1" } + ] + }, + "collections": [ + { + "target": "#cards", + "mode": "repeat", + "source": "/items", + "template": "./InventoryCard.uxml" + }, + { + "target": "#rows", + "mode": "list-view", + "source": "/items", + "template": "./InventoryRow.uxml", + "itemHeight": 32 + } + ] + } + } +} +``` + +The schema rejects unknown fields and paths outside the project. `states` is required and must contain 1–64 states. `defaultState` is optional only when a state is named `default` or the fixture has exactly one state; otherwise it is required. Viewports allow 1–8192 pixels per axis and at most 8,388,608 total pixels. A render operation has a 300-second enqueue-to-completion ceiling across all selected states. `document` and collection `template` paths are relative to the scenario unless they begin with `Assets/` or `Packages/`. Top-level data is deeply overlaid by state data and then by `--data-json` or `--data-file`. + +`set` supports the deliberately small property allowlist `text`, `value`, `enabled`, `display`, `visibility`, `tooltip`, and `class:`. Targets are `:root`, `#name`, or `.class` and must resolve to one element. + +## Data binding and collections + +Author bindings as ordinary UI Toolkit paths. The harness adapts those paths to its JSON dictionary data source after cloning: + +```xml + + + + + +``` + +Each collection `source` is an RFC 6901-style JSON pointer into the resolved data, such as `/items` or `/inventory/rows`, and must resolve to an array. Each `repeat` or `list-view` item receives one array value as its data source, so the same row UXML works in either mode. + +- `repeat` eagerly clones every row. Use it for small flex-wrap grids and layouts where every item must exist at once. +- `list-view` assigns `itemsSource` and owns `makeItem`, `bindItem`, and `unbindItem`. It uses dynamic-height virtualization by default; setting `itemHeight` selects fixed-height virtualization. It reports logical, realized, bound, and currently visible row counts. + +JSON integers that fit in 32 bits are normalized for controls such as `IntegerField`. Larger integers remain 64-bit and produce a warning because some UI Toolkit controls cannot bind them automatically. + +## Recommended agent loop + +```bash +ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings +ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state populated --query '#cards' --json +ucp ui screenshot Assets/UI/Inventory.ucp-ui.json --state populated -o artifacts/inventory.png --force +ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui --force --json +``` + +Use lint for the fast import/schema pass, inspect to reason about resolved geometry and bindings, and screenshot for visual judgment. Run check before handing off the UI. diff --git a/website/public/docs/index.md b/website/public/docs/index.md index 1f0f798..67cbeee 100644 --- a/website/public/docs/index.md +++ b/website/public/docs/index.md @@ -19,6 +19,7 @@ Together, they let you automate virtually any Unity Editor operation from the te - **Project Settings** - Read/write player, quality, physics, and lighting settings - **Prefab Workflow** - Check status, apply/revert overrides, create prefabs, unpack instances - **Build Pipeline** - List targets, manage scenes, control scripting defines, trigger builds +- **UI Toolkit** - Lint UXML/USS with Unity's importers, populate scenarios, inspect resolved layout, capture panel screenshots - **Version Control** - Lightweight Unity VCS fallback commands; prefer native `cm` for full UVCS workflows - **Editor Scripting** - Execute custom C# scripts remotely with parameters - **Cross-Platform** - Works on macOS (x64 + ARM), Linux, and Windows diff --git a/website/public/llms.txt b/website/public/llms.txt index cf6b82e..f6fc028 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -20,6 +20,7 @@ - [Objects & Components](https://unityctl.dev/docs/authoring/objects.md): Inspect and modify GameObjects, components, and their properties in the active scene. Most commands require an --id flag with the instance ID of the target GameObject (use ucp scene snapshot to discover IDs). The snapshot command is intentionally shallow by default; detailed component decomposition lives here. - [Prefabs](https://unityctl.dev/docs/authoring/prefabs.md): Inspect and manage prefab instances and overrides in the scene. - [Assets](https://unityctl.dev/docs/authoring/assets.md): Search, inspect, and manage project assets. Works with materials, textures, ScriptableObjects, and any asset type Unity recognizes. +- [UI Toolkit](https://unityctl.dev/docs/authoring/ui-toolkit.md): ucp ui gives agents a tight authoring loop for UI Toolkit UXML and USS: discover targets, lint with Unity's importers, inspect a live resolved tree, capture a PNG, or run the full check in one command. - [Materials](https://unityctl.dev/docs/authoring/materials.md): Inspect and modify material properties, shader keywords, and shaders. - [Reference Search](https://unityctl.dev/docs/authoring/references.md): Find all references to any asset, prefab, material, script, or object across the entire Unity project. The Rust-native engine parses Unity's text-serialized YAML directly from disk with parallel scanning — no running Unity editor required. - [Files](https://unityctl.dev/docs/authoring/files.md): Read, write, and patch project files. All file paths are relative to the project root and sandboxed within the project directory for safety. diff --git a/website/public/sitemap.xml b/website/public/sitemap.xml index 43a09d9..e60d533 100644 --- a/website/public/sitemap.xml +++ b/website/public/sitemap.xml @@ -33,6 +33,9 @@ https://unityctl.dev/docs/authoring/assets + + https://unityctl.dev/docs/authoring/ui-toolkit + https://unityctl.dev/docs/authoring/materials diff --git a/website/scripts/sync-content.mjs b/website/scripts/sync-content.mjs index 48110dc..a8787b8 100644 --- a/website/scripts/sync-content.mjs +++ b/website/scripts/sync-content.mjs @@ -106,6 +106,7 @@ const docsNavigation = [ { title: 'Objects & Components', route: 'authoring/objects', source: 'authoring/objects.md' }, { title: 'Prefabs', route: 'authoring/prefabs', source: 'authoring/prefabs.md' }, { title: 'Assets', route: 'authoring/assets', source: 'authoring/assets.md' }, + { title: 'UI Toolkit', route: 'authoring/ui-toolkit', source: 'authoring/ui-toolkit.md' }, { title: 'Materials', route: 'authoring/materials', source: 'authoring/materials.md' }, { title: 'Reference Search', route: 'authoring/references', source: 'authoring/references.md' }, { title: 'Files', route: 'authoring/files', source: 'authoring/files.md' }, diff --git a/website/src/lib/docs-content.ts b/website/src/lib/docs-content.ts index 20be096..15635cd 100644 --- a/website/src/lib/docs-content.ts +++ b/website/src/lib/docs-content.ts @@ -8,6 +8,7 @@ import scenesMd from '@docs/authoring/scenes.md?raw'; import objectsMd from '@docs/authoring/objects.md?raw'; import prefabsMd from '@docs/authoring/prefabs.md?raw'; import assetsMd from '@docs/authoring/assets.md?raw'; +import uiToolkitMd from '@docs/authoring/ui-toolkit.md?raw'; import materialsMd from '@docs/authoring/materials.md?raw'; import referencesMd from '@docs/authoring/references.md?raw'; import filesMd from '@docs/authoring/files.md?raw'; @@ -59,6 +60,7 @@ export const docsNavigation: DocsNavGroup[] = [ { title: 'Objects & Components', href: '/docs/authoring/objects' }, { title: 'Prefabs', href: '/docs/authoring/prefabs' }, { title: 'Assets', href: '/docs/authoring/assets' }, + { title: 'UI Toolkit', href: '/docs/authoring/ui-toolkit' }, { title: 'Materials', href: '/docs/authoring/materials' }, { title: 'Reference Search', href: '/docs/authoring/references' }, { title: 'Files', href: '/docs/authoring/files' }, @@ -100,6 +102,7 @@ const canonicalDocsContent: Record = { 'authoring/objects': objectsMd, 'authoring/prefabs': prefabsMd, 'authoring/assets': assetsMd, + 'authoring/ui-toolkit': uiToolkitMd, 'authoring/materials': materialsMd, 'authoring/references': referencesMd, 'authoring/files': filesMd, @@ -124,6 +127,7 @@ const legacyAliases: Record = { 'commands/objects': objectsMd, 'commands/prefabs': prefabsMd, 'commands/assets': assetsMd, + 'commands/ui-toolkit': uiToolkitMd, 'commands/materials': materialsMd, 'commands/references': referencesMd, 'commands/files': filesMd, From f7b705a6d4c72b5c0879cffa14b576cb0d333e0e Mon Sep 17 00:00:00 2001 From: Quentin Leon Date: Sat, 5 Sep 2026 20:18:09 -0700 Subject: [PATCH 2/3] fix(ui): harden traversal, diagnostics, and operation lifecycle --- CHANGELOG.md | 5 +- cli/src/commands/ui.rs | 293 ++++++++++++++---- cli/src/main.rs | 60 +++- docs/authoring/ui-toolkit.md | 12 +- .../Editor/Bridge/BridgeServer.cs | 3 + .../com.ucp.bridge/Editor/Ui/UiHost.cs | 2 +- .../com.ucp.bridge/Editor/Ui/UiLintService.cs | 41 ++- .../Editor/Ui/UiOperationManager.cs | 16 +- .../Editor/Ui/UiScenarioApplier.cs | 4 +- .../Editor/Ui/UiScenarioLoader.cs | 30 +- .../Editor/Ui/UiVisualTreeInspector.cs | 116 ++++--- .../Tests/Editor/UiControllerTests.cs | 117 ++++++- .../Tests/Editor/UiLintServiceTests.cs | 68 ++++ .../Tests/Editor/UiScenarioTests.cs | 169 ++++++++++ .../.generated/docs/authoring/ui-toolkit.md | 12 +- website/public/docs/authoring/ui-toolkit.md | 12 +- 16 files changed, 795 insertions(+), 165 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc95123..d6bb1d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ ### Added -- Added the Unity 6+ `ucp ui` family for agent-driven UI Toolkit work: target discovery, importer- and clone-backed UXML/USS linting, bounded resolved-tree and binding inspection, DPI-correct Editor-panel PNG capture, and an end-to-end multi-state `ui check` workflow. +- Added the Unity 6+ `ucp ui` family for agent-driven UI Toolkit work: target discovery, importer- and clone-backed UXML/USS/TSS linting, bounded resolved-tree and binding inspection including physical control children and realized ListView rows, DPI-correct Editor-panel PNG capture, and an end-to-end multi-state `ui check` workflow. - Added strict `.ucp-ui.json` scenarios with JSON data overlays, allowlisted fixture mutations, eager `repeat` templates, and harness-owned virtualized `ListView` binding for reproducible dynamic layouts. +- Added conservative UI audits with complete diagnostic totals, error-prioritized bounded details, and exemptions for Unity internal focus targets; lint inspects immutable package artifacts without forced reimport. +- Added UI operation interruption reporting, status recovery on CLI timeouts, and a render timeout default that accommodates the bridge's overall duration limit. - Added focused Rust, bridge, fixture, lint, inspector, and audit coverage plus agent-facing UI Toolkit workflow documentation. ## [0.6.2] - 2026-08-31 @@ -584,4 +586,3 @@ - JSON-RPC 2.0 protocol - Lock file discovery mechanism - Per-session token authentication - diff --git a/cli/src/commands/ui.rs b/cli/src/commands/ui.rs index eed5c11..20bc4f7 100644 --- a/cli/src/commands/ui.rs +++ b/cli/src/commands/ui.rs @@ -67,9 +67,9 @@ pub enum UiAction { #[arg(long, default_value_t = 100)] limit: usize, }, - /// Import and validate UXML and USS assets + /// Import and validate UXML, USS, and TSS assets Lint { - /// UXML, USS, scenario, or folder paths to lint + /// UXML, USS, TSS, scenario, or folder paths to lint #[arg(required = true, num_args = 1..)] paths: Vec, /// Treat warnings as a failed lint result @@ -241,10 +241,9 @@ pub async fn run(action: UiAction, ctx: &Context) -> anyhow::Result<()> { insert_result_field( result, "outputPaths", - serde_json::json!(copied - .iter() - .map(|p| display_path(p)) - .collect::>()), + serde_json::json!( + copied.iter().map(|p| display_path(p)).collect::>() + ), ); } Ok(()) @@ -418,51 +417,97 @@ async fn wait_for_ui_result( ) -> anyhow::Result { let wait = async { loop { - let notification = client.next_notification().await.ok_or_else(|| { - anyhow::anyhow!("Connection closed before UI {operation} completed") - })?; - if notification.method != "ui/result" - || notification.params.get("operationId") != Some(&operation_id) + let notification = client.next_notification().await?; + if notification.method == "ui/result" + && notification.params.get("operationId") == Some(&operation_id) { - continue; + return Some(notification.params); } - - let status = notification - .params - .get("status") - .and_then(Value::as_str) - .unwrap_or("failed"); - if status == "completed" { - let result = notification - .params - .get("result") - .cloned() - .unwrap_or(Value::Null); - return Ok(UiOperationOutcome { - result, - failure: None, - }); - } - - let message = operation_error_message(¬ification.params) - .unwrap_or_else(|| format!("UI {operation} failed")); - return Ok(UiOperationOutcome { - result: notification.params, - failure: Some(message), - }); } }; - if timeout_secs == 0 { - wait.await + let received = if timeout_secs == 0 { + Ok(wait.await) } else { - tokio::time::timeout(Duration::from_secs(timeout_secs), wait) - .await - .map_err(|_| { - anyhow::anyhow!( - "Timed out after {timeout_secs}s waiting for UI {operation} result notification" + tokio::time::timeout(Duration::from_secs(timeout_secs), wait).await + }; + match received { + Ok(Some(envelope)) => Ok(ui_operation_outcome(envelope, operation)), + Ok(None) => Ok(UiOperationOutcome { + result: serde_json::json!({ + "operationId": operation_id, + "operation": operation, + "status": "unknown", + "error": { "code": "connection_closed" } + }), + failure: Some(format!( + "Connection closed before UI {operation} ({operation_id}) completed; \ + the Editor may have reloaded or quit. Completion could not be confirmed." + )), + }), + Err(_) => { + // A notification can be lost while a request is being read. Recover + // terminal results, or report the bridge's last known state. + let status = client + .call_with_timeout( + "ui/status", + serde_json::json!({ "operationId": operation_id }), + Some(Duration::from_secs(5)), ) - })? + .await; + match status { + Ok(envelope) + if matches!( + envelope.get("status").and_then(Value::as_str), + Some("completed" | "failed") + ) => + { + Ok(ui_operation_outcome(envelope, operation)) + } + Ok(envelope) => { + let state = envelope + .get("status") + .and_then(Value::as_str) + .unwrap_or("not found"); + let message = format!( + "Timed out after {timeout_secs}s waiting for UI {operation} ({operation_id}); \ + bridge status: {state}. The operation was not cancelled and may still be running." + ); + Ok(UiOperationOutcome { + result: envelope, + failure: Some(message), + }) + } + Err(error) => Ok(UiOperationOutcome { + result: serde_json::json!({ + "operationId": operation_id, + "operation": operation, + "status": "unknown", + "error": { "code": "status_unavailable", "message": error.to_string() } + }), + failure: Some(format!( + "Timed out after {timeout_secs}s waiting for UI {operation} ({operation_id}); \ + status lookup failed: {error}. The operation was not cancelled." + )), + }), + } + } + } +} + +fn ui_operation_outcome(envelope: Value, operation: &str) -> UiOperationOutcome { + if envelope.get("status").and_then(Value::as_str) == Some("completed") { + UiOperationOutcome { + result: envelope.get("result").cloned().unwrap_or(Value::Null), + failure: None, + } + } else { + let message = + operation_error_message(&envelope).unwrap_or_else(|| format!("UI {operation} failed")); + UiOperationOutcome { + result: envelope, + failure: Some(message), + } } } @@ -658,7 +703,7 @@ fn copy_result_artifacts( destination.display() ); } - preflight_destination(&source, &destination, force)?; + preflight_destination(&destination, force)?; plans.push((source, destination)); } @@ -678,7 +723,7 @@ fn copy_artifact( let project = canonical_project(project)?; let source = resolve_artifact_source(&project, artifact)?; let destination = absolute_destination(destination)?; - preflight_destination(&source, &destination, force)?; + preflight_destination(&destination, force)?; copy_resolved_artifact(&source, &destination, force) } @@ -703,7 +748,7 @@ fn resolve_artifact_source(project: &Path, artifact: &Path) -> anyhow::Result anyhow::Result anyhow::Result<()> { +fn preflight_destination(destination: &Path, force: bool) -> anyhow::Result<()> { if destination.exists() && !force { anyhow::bail!( "Output already exists: {} (use --force to replace it)", @@ -743,9 +788,6 @@ fn preflight_destination(source: &Path, destination: &Path, force: bool) -> anyh existing_ancestor.display() ); } - if destination == source { - return Ok(()); - } Ok(()) } @@ -768,7 +810,7 @@ fn copy_resolved_artifact( })?; let temporary = parent.join(format!(".ucp-ui-{}.tmp", uuid::Uuid::new_v4().as_simple())); - if let Err(err) = fs::copy(&source, &temporary) { + if let Err(err) = fs::copy(source, &temporary) { let _ = fs::remove_file(&temporary); return Err(anyhow::anyhow!( "Failed to copy UI artifact to {}: {err}", @@ -783,7 +825,7 @@ fn copy_resolved_artifact( destination.display() ); } - if let Err(err) = fs::remove_file(&destination) { + if let Err(err) = fs::remove_file(destination) { let _ = fs::remove_file(&temporary); return Err(anyhow::anyhow!( "Failed to replace {}: {err}", @@ -791,7 +833,7 @@ fn copy_resolved_artifact( )); } } - if let Err(err) = fs::rename(&temporary, &destination) { + if let Err(err) = fs::rename(&temporary, destination) { let _ = fs::remove_file(&temporary); return Err(anyhow::anyhow!( "Failed to finalize UI artifact {}: {err}", @@ -827,10 +869,7 @@ fn display_path(path: &Path) -> String { } fn print_list_result(result: &Value) { - let entries = result - .get("targets") - .or_else(|| result.get("items")) - .and_then(Value::as_array); + let entries = result.get("items").and_then(Value::as_array); let count = result .get("count") .and_then(Value::as_u64) @@ -839,16 +878,8 @@ fn print_list_result(result: &Value) { output::print_success(&format!("Found {count} UI target(s)")); if let Some(entries) = entries { for entry in entries.iter().take(20) { - let path = entry - .get("path") - .or_else(|| entry.get("target")) - .and_then(Value::as_str) - .unwrap_or("?"); - let kind = entry - .get("type") - .or_else(|| entry.get("kind")) - .and_then(Value::as_str) - .unwrap_or("ui"); + let path = entry.get("target").and_then(Value::as_str).unwrap_or("?"); + let kind = entry.get("type").and_then(Value::as_str).unwrap_or("ui"); eprintln!(" {path} ({kind})"); } if entries.len() > 20 { @@ -954,6 +985,132 @@ fn print_async_result(operation: &str, result: &Value) { mod tests { use super::*; + #[tokio::test] + async fn ui_wait_recovers_lost_results_and_reports_running_or_interrupted_operations() { + use futures_util::{SinkExt, StreamExt}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_tungstenite::tungstenite::{Message, protocol::Role}; + + for mode in [ + "completed", + "failed", + "running", + "missing", + "shutdown", + "disconnected", + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + request.push(stream.read_u8().await.unwrap()); + } + let request = String::from_utf8(request).unwrap(); + let key = request + .lines() + .find_map(|line| line.strip_prefix("Sec-WebSocket-Key:")) + .unwrap() + .trim(); + let accept = { + use sha1::{Digest, Sha1}; + let mut hasher = Sha1::new(); + hasher.update(key.as_bytes()); + hasher.update("258EAFA5-E914-47DA-95CA-5AB5DC85B11B"); + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + hasher.finalize(), + ) + }; + stream.write_all(format!("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").as_bytes()).await.unwrap(); + let mut ws = + tokio_tungstenite::WebSocketStream::from_raw_socket(stream, Role::Server, None) + .await; + ready_rx.await.unwrap(); + if mode == "disconnected" { + ws.close(None).await.unwrap(); + return; + } + let envelope = if mode == "missing" { + serde_json::json!({ "operationId": "ui-test", "found": false }) + } else { + serde_json::json!({ + "operationId": "ui-test", "found": true, + "status": if mode == "shutdown" { "failed" } else { mode }, + "result": { "passed": true, "artifactPath": "capture.png" }, + "error": { "code": "editor_shutdown", "message": "Editor reloaded" } + }) + }; + if mode == "shutdown" { + // Unrelated results must not complete our operation. + ws.send(Message::Text( + serde_json::json!({ "jsonrpc": "2.0", "method": "ui/result", "params": { + "operationId": "other", "status": "completed" + }}) + .to_string() + .into(), + )) + .await + .unwrap(); + ws.send(Message::Text( + serde_json::json!({ "jsonrpc": "2.0", "method": "ui/result", "params": envelope }) + .to_string() + .into(), + )) + .await + .unwrap(); + } else { + let request: Value = + serde_json::from_str(ws.next().await.unwrap().unwrap().to_text().unwrap()) + .unwrap(); + assert_eq!(request["method"], "ui/status"); + assert_eq!(request["params"]["operationId"], "ui-test"); + ws.send(Message::Text(serde_json::json!({ "jsonrpc": "2.0", "id": request["id"], "result": envelope }).to_string().into())).await.unwrap(); + } + }); + let lock = crate::config::LockFile { + pid: 0, + port, + protocol_version: "test".into(), + unity_version: "test".into(), + project_path: "test".into(), + started_at: "test".into(), + token: "test".into(), + }; + let mut client = BridgeClient::connect(&lock).await.unwrap(); + ready_tx.send(()).unwrap(); + let outcome = wait_for_ui_result(&mut client, serde_json::json!("ui-test"), "check", 1) + .await + .unwrap(); + match mode { + "completed" => { + assert!(outcome.failure.is_none()); + assert_eq!(outcome.result["artifactPath"], "capture.png"); + } + "failed" | "shutdown" => { + assert_eq!(outcome.failure.as_deref(), Some("Editor reloaded")) + } + "running" | "missing" => { + assert!(outcome.failure.unwrap().contains("was not cancelled")); + assert_eq!(outcome.result["operationId"], "ui-test"); + } + "disconnected" => { + assert!( + outcome + .failure + .unwrap() + .contains("may have reloaded or quit") + ); + assert_eq!(outcome.result["error"]["code"], "connection_closed"); + } + _ => unreachable!(), + } + server.await.unwrap(); + } + } + #[test] fn loads_inline_and_file_data_objects() { let inline = load_data(Some(r#"{"title":"Inventory"}"#), None).unwrap(); diff --git a/cli/src/main.rs b/cli/src/main.rs index 3f9e1bd..f340aa5 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -59,9 +59,9 @@ struct Cli { #[arg(long, global = true)] json: bool, - /// Per-request command timeout in seconds; 0 waits indefinitely - #[arg(long, global = true, default_value = "30")] - timeout: u64, + /// Command timeout in seconds (default: 310 for UI renders, 30 otherwise); 0 waits indefinitely + #[arg(long, global = true)] + timeout: Option, /// Enable verbose logging #[arg(long, short, global = true)] @@ -79,6 +79,20 @@ struct Cli { dialog_policy: Option, } +impl Cli { + fn effective_timeout(&self) -> u64 { + self.timeout.unwrap_or(match &self.command { + commands::Command::Ui { + action: + commands::ui::UiAction::Inspect { .. } + | commands::ui::UiAction::Screenshot { .. } + | commands::ui::UiAction::Check { .. }, + } => 310, // Allow the bridge's 300-second ceiling to report its result. + _ => 30, + }) + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { // Ensure UTF-8 output on Windows consoles @@ -121,13 +135,14 @@ async fn main() -> anyhow::Result<()> { .without_time() .init(); + let timeout = cli.effective_timeout(); let ctx = commands::Context { project: cli.project, port: cli.port, unity: cli.unity.or(cli_settings.unity_path), force_unity_version: cli.force_unity_version, json: cli.json, - timeout: cli.timeout, + timeout, verbose: cli.verbose, bridge_update_policy: cli .bridge_update_policy @@ -917,6 +932,43 @@ mod tests { } } + #[test] + fn ui_render_timeout_defaults_allow_the_bridge_ceiling_and_preserve_overrides() { + for action in ["inspect", "screenshot", "check"] { + let args = ["ucp", "ui", action, "Assets/Panel.uxml"]; + assert_eq!(Cli::try_parse_from(args).unwrap().effective_timeout(), 310); + for explicit in ["0", "30", "60"] { + let mut args = args.to_vec(); + args.extend(["--timeout", explicit]); + assert_eq!( + Cli::try_parse_from(args).unwrap().effective_timeout(), + explicit.parse::().unwrap() + ); + } + } + for args in [ + vec!["ucp", "ui", "list"], + vec!["ucp", "ui", "lint", "Assets/UI"], + vec!["ucp", "doctor"], + ] { + assert_eq!(Cli::try_parse_from(args).unwrap().effective_timeout(), 30); + } + assert_eq!( + Cli::try_parse_from([ + "ucp", + "--timeout", + "30", + "ui", + "check", + "Assets/Panel.uxml", + "--all-states" + ]) + .unwrap() + .effective_timeout(), + 30 + ); + } + #[test] fn parses_ui_inspect_command() { let cli = Cli::try_parse_from([ diff --git a/docs/authoring/ui-toolkit.md b/docs/authoring/ui-toolkit.md index e4e03a9..b29e531 100644 --- a/docs/authoring/ui-toolkit.md +++ b/docs/authoring/ui-toolkit.md @@ -29,14 +29,14 @@ Invalid scenarios remain in the result with `valid: false` and a structured diag ### `ucp ui lint ` -Runs Unity's synchronous UXML and USS importers, reads their import logs and flags, resolves dependencies, and clones each UXML tree while capturing scoped Unity diagnostics. The clone step matters because an unknown UXML element can import without an error and fail only when Unity tries to instantiate it. +Runs Unity's synchronous UXML, USS, and TSS importers, reads their import logs and flags, resolves dependencies, and clones each UXML tree while capturing scoped Unity diagnostics. The clone step matters because an unknown UXML element can import without an error and fail only when Unity tries to instantiate it. ```bash ucp ui lint Assets/UI ucp ui lint Assets/UI/Inventory.ucp-ui.json --fail-on-warnings --max-diagnostics 200 ``` -A scenario lint also validates its schema and lints its document, USS dependencies, and collection templates. Errors fail the command. Warnings fail only with `--fail-on-warnings`. +A scenario lint also validates its schema and lints its document, USS/TSS dependencies, and collection templates. Assets in immutable packages use their existing imported artifacts and logs; assets under `Assets/` and in local or embedded packages are synchronously reimported. Each asset report includes `reimported` to distinguish these cases. Errors fail the command. Warnings fail only with `--fail-on-warnings`. ### `ucp ui inspect ` @@ -49,6 +49,8 @@ ucp ui inspect Assets/UI/Inventory.ucp-ui.json --state empty --detail verbose -- `--query` accepts one simple `#name`, `.class`, or element-type selector. Detail is `summary`, `normal`, or `verbose`. The snapshot reports actual UI Toolkit state; it does not claim matched-USS-rule provenance or an accessibility tree. +Traversal includes the physical children of controls, including realized ListView rows. The default snapshot depth is 6 for `inspect` and 4 for `check`, measured from the snapshot root. Internal control containers consume depth, so realized row labels can fall beyond those defaults (for example, Inventory row labels are eight levels deep). Use `--query '#row-label'` or a deeper `--depth`, such as `--depth 12`, when inspecting rows; `truncated: true` signals a depth or element limit. Audits traverse the entire tree independently of snapshot limits. Virtualized items that have not been realized are represented by collection counts rather than element snapshots. + ### `ucp ui screenshot ` Captures the resolved Editor panel after stable geometry and pixel samples. The defaults are three identical finite geometry samples and two identical pixel samples; a scenario can override them with `settle`. @@ -60,7 +62,7 @@ ucp ui screenshot Assets/UI/Inventory.uxml --width 1280 --height 720 -o artifact Without `--out`, the bridge retains the PNG under `Library/UCP/UiCaptures` and returns its path and pixel hash. `--out` copies that artifact without overwriting an existing file unless `--force` is explicit. Width and height must be supplied together; if omitted, a scenario's viewport is preserved and direct UXML uses `960x640`. -The capture backend briefly opens and focuses a transient utility window, then closes it and restores the previously focused window. It does not load or dirty a scene. Use a longer global `--timeout` for scenarios with many states or deliberately slow deferred UI updates. +The capture backend briefly opens and focuses a transient utility window, then closes it and restores the previously focused window. It does not load or dirty a scene. `ui inspect`, `ui screenshot`, and `ui check` default to a 310-second CLI timeout so the bridge can report its 300-second overall limit, including sequential multi-state runs. Other commands retain a 30-second default. An explicit global `--timeout` overrides these defaults; `--timeout 0` disables the CLI deadline but does not disable the bridge ceiling. On a result timeout, the CLI makes one status lookup (up to five additional seconds) to recover a completed result or report the last known state. A CLI timeout does not cancel the operation. Editor reload/quit interrupts active and queued operations with `editor_shutdown`; if the connection closes before that notification arrives, the CLI reports that completion could not be confirmed. ### `ucp ui check ` @@ -71,7 +73,9 @@ ucp ui check Assets/UI/Inventory.ucp-ui.json --all-states --out-dir artifacts/ui ucp ui check Assets/UI/Inventory.ucp-ui.json --state populated --fail-on-warnings ``` -The v0 audits treat binding/application failures as errors. They warn for focusable zero-size controls, focusable controls outside the viewport, and duplicate element names outside harness-managed collections. They intentionally avoid speculative claims such as unused selectors or contrast failures. `--fail-on-warnings` makes both lint and audit warnings fail the command. +The v0 audits treat binding/application failures as errors. They warn for authored focusable zero-size controls, authored focusable controls outside the viewport, and duplicate element names outside harness-managed collections. Focusable audits skip named `unity-` internal elements, including the zero-height content container of a visible empty ListView; unnamed authored controls are still audited. They intentionally avoid speculative claims such as unused selectors or contrast failures. `--fail-on-warnings` makes both lint and audit warnings fail the command. + +Audit responses retain at most 200 diagnostic details across `errors` and `warnings`. `errorCount`, `warningCount`, and `passed` still account for every diagnostic; `diagnosticsTruncated` indicates omitted details. Errors take priority over warning details: when the budget is full, a later error replaces the newest retained warning. If errors alone exceed the budget, the earliest errors are retained. Lint uses the same policy with its `--max-diagnostics` budget. A binding error therefore remains visible when earlier warnings fill the response limit. ## Scenario format diff --git a/unity-package/com.ucp.bridge/Editor/Bridge/BridgeServer.cs b/unity-package/com.ucp.bridge/Editor/Bridge/BridgeServer.cs index 527d141..225fb4d 100644 --- a/unity-package/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +++ b/unity-package/com.ucp.bridge/Editor/Bridge/BridgeServer.cs @@ -577,6 +577,9 @@ private static void CleanLockFile() private static void Shutdown() { +#if UNITY_6000_0_OR_NEWER + UiOperationManager.Shutdown(); +#endif if (!s_running) return; s_running = false; diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs index cf9f8a3..528bdb1 100644 --- a/unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiHost.cs @@ -194,7 +194,7 @@ private static void Visit( Add(ref hash, element.resolvedStyle.visibility.ToString()); } - foreach (var child in element.Children()) + foreach (var child in element.hierarchy.Children()) Visit(child, displayed, ref hash, ref elementCount, ref renderedCount, ref invalidCount); } diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs index 3e9c21c..64767f6 100644 --- a/unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiLintService.cs @@ -131,7 +131,7 @@ private static void CollectRequestedPath( "UI_PATH_UNSUPPORTED", assetPath, "request", - "Expected a .uxml, .uss, .ucp-ui.json, or project folder path"); + "Expected a .uxml, .uss, .tss, .ucp-ui.json, or project folder path"); return; } @@ -213,6 +213,7 @@ private static Dictionary InspectAsset( ["contentHash"] = string.Empty, ["importedWithErrors"] = false, ["importedWithWarnings"] = false, + ["reimported"] = false, ["cloneAttempted"] = false, ["cloneSucceeded"] = false, ["cloneElementCount"] = 0, @@ -227,9 +228,13 @@ private static Dictionary InspectAsset( try { - AssetDatabase.ImportAsset( - assetPath, - ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + if (ShouldReimport(assetPath)) + { + AssetDatabase.ImportAsset( + assetPath, + ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + report["reimported"] = true; + } report["contentHash"] = AssetDatabase.GetAssetDependencyHash(assetPath).ToString(); } catch (Exception ex) @@ -390,11 +395,24 @@ private static bool AssetExists(string assetPath) File.Exists(Path.Combine(projectRoot, assetPath)); } + internal static bool ShouldReimport(string assetPath) + { + if (!assetPath.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) + return true; + var package = UnityEditor.PackageManager.PackageInfo.FindForAssetPath(assetPath); + // Immutable packages already have imported artifacts. Inspect those + // artifacts and logs without forcing work on the package cache. + return package != null && + (package.source == UnityEditor.PackageManager.PackageSource.Embedded || + package.source == UnityEditor.PackageManager.PackageSource.Local); + } + private static bool IsUiAssetPath(string path) { return path != null && (path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase) || - path.EndsWith(".uss", StringComparison.OrdinalIgnoreCase)); + path.EndsWith(".uss", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".tss", StringComparison.OrdinalIgnoreCase)); } private static bool IsFixturePath(string path) @@ -477,7 +495,7 @@ private static int ReadInt( return parsed; } - private sealed class DiagnosticCollector + internal sealed class DiagnosticCollector { private readonly int _limit; @@ -507,7 +525,16 @@ internal void Add( WarningCount++; if (Items.Count >= _limit) - return; + { + if (severity != "error") + return; + var warningIndex = Items.FindLastIndex(item => + item is Dictionary diagnostic && + Equals(diagnostic["severity"], "warning")); + if (warningIndex < 0) + return; + Items.RemoveAt(warningIndex); + } Items.Add(new Dictionary { diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs index 3e2971b..d3ac935 100644 --- a/unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiOperationManager.cs @@ -30,8 +30,6 @@ internal static class UiOperationManager static UiOperationManager() { EditorApplication.update += Tick; - AssemblyReloadEvents.beforeAssemblyReload += Shutdown; - EditorApplication.quitting += Shutdown; } internal static Dictionary Start( @@ -79,6 +77,7 @@ internal static Dictionary Status(string operationId) internal static void ResetForTests() { Shutdown(); + Operations.Clear(); } internal static void TickForTests() @@ -234,8 +233,18 @@ private static void PruneCompleted(bool force) Operations.Remove(id); } - private static void Shutdown() + internal static void Shutdown() { + // The bridge calls this before closing client sockets on reload/quit. + // Retain terminal records for status recovery until the domain unloads. + foreach (var record in Operations.Values.Where(record => !record.IsTerminal).ToList()) + { + CompleteAndBroadcast(record, null, new Dictionary + { + ["code"] = "editor_shutdown", + ["message"] = "UI operation interrupted by an Editor domain reload or shutdown" + }); + } var active = _active; _active = null; try @@ -249,7 +258,6 @@ private static void Shutdown() finally { Queue.Clear(); - Operations.Clear(); } } } diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs index fdc717f..ccaee43 100644 --- a/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs @@ -203,7 +203,7 @@ private static void CollectMatches(VisualElement element, string selector, List< if (matches) output.Add(element); - foreach (var child in element.Children()) + foreach (var child in element.hierarchy.Children()) CollectMatches(child, selector, output); } @@ -400,7 +400,7 @@ private static void RewriteBindingsRecursive(VisualElement element, ref int chan changed++; } - foreach (var child in element.Children()) + foreach (var child in element.hierarchy.Children()) RewriteBindingsRecursive(child, ref changed); } diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs index e40a220..7b908d4 100644 --- a/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs @@ -585,7 +585,7 @@ internal static string NormalizeAssetPath(string input, string relativeToFixture if (Path.IsPathRooted(normalizedInput)) { - absolute = Path.GetFullPath(normalizedInput); + absolute = NormalizePathSegments(normalizedInput); } else { @@ -608,11 +608,10 @@ internal static string NormalizeAssetPath(string input, string relativeToFixture projectRelative = normalizedInput; } - absolute = Path.GetFullPath(Path.Combine(projectRoot, projectRelative)); + absolute = NormalizePathSegments(Path.Combine(projectRoot, projectRelative)); } - var rootWithSeparator = projectRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar; + var rootWithSeparator = projectRoot.Replace('\\', '/').TrimEnd('/') + "/"; if (!absolute.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase)) throw Error("ui.path-outside-project", "UI asset paths must stay inside the Unity project.", location); @@ -628,6 +627,29 @@ internal static string NormalizeAssetPath(string input, string relativeToFixture return relative; } + private static string NormalizePathSegments(string absolutePath) + { + // Unity's Path.GetFullPath remaps virtual Packages paths into + // Library/PackageCache. Collapse dot segments without resolving that + // alias so validation and AssetDatabase receive the virtual path. + var path = absolutePath.Replace('\\', '/'); + var root = Path.GetPathRoot(path).Replace('\\', '/'); + var segments = new List(); + foreach (var segment in path.Substring(root.Length).Split('/')) + { + if (segment.Length == 0 || segment == ".") + continue; + if (segment == "..") + { + if (segments.Count > 0) + segments.RemoveAt(segments.Count - 1); + continue; + } + segments.Add(segment); + } + return root + string.Join("/", segments); + } + internal static void ValidateSelector(string selector, string location) { if (string.Equals(selector, ":root", StringComparison.Ordinal)) diff --git a/unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs b/unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs index 8b4e74e..fa89096 100644 --- a/unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs +++ b/unity-package/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs @@ -180,7 +180,7 @@ private static Dictionary BuildNode( var children = new List(); if (depth < context.Options.MaxDepth) { - foreach (var child in element.Children()) + foreach (var child in element.hierarchy.Children()) { if (context.ReturnedCount >= context.Options.MaxElements) { @@ -338,7 +338,9 @@ internal static void Visit(VisualElement root, Action action) if (root == null) return; action(root); - foreach (var child in root.Children()) + // Collection controls can have a null contentContainer while their + // realized rows still exist in the physical visual hierarchy. + foreach (var child in root.hierarchy.Children()) Visit(child, action); } @@ -406,10 +408,7 @@ internal static Dictionary Run( UiApplyReport applyReport, IReadOnlyList collectionMetadata) { - var errors = new List(); - var warnings = new List(); - var diagnosticCount = 0; - var truncated = false; + var diagnostics = new DiagnosticCollector(); var collectionRoots = FindCollectionRoots(root, collectionMetadata); var namedElements = new Dictionary>(StringComparer.Ordinal); @@ -420,22 +419,12 @@ internal static Dictionary Run( var severity = diagnostic.TryGetValue("severity", out var value) ? value?.ToString() : null; - Add( - severity == "error" ? errors : warnings, - diagnostic, - ref diagnosticCount, - ref truncated); + diagnostics.Add(severity == "error", diagnostic); } } UiVisualTreeInspector.Visit(root, element => { - if (diagnosticCount >= MaxDiagnostics) - { - truncated = true; - return; - } - var insideDynamicCollection = HasAncestor(element, collectionRoots); if (!insideDynamicCollection && IsUserElementName(element.name)) { @@ -447,16 +436,16 @@ internal static Dictionary Run( matches.Add(element); } - if (element.focusable && IsVisibleThroughAncestors(element)) + if (element.focusable && !IsUnityInternalName(element.name) && IsVisibleThroughAncestors(element)) { var rect = element.worldBound; if (UiValue.IsFinite(rect.width) && UiValue.IsFinite(rect.height) && (rect.width <= 0f || rect.height <= 0f)) { - Add(warnings, Diagnostic( + diagnostics.Add(false, Diagnostic( "focusable_zero_size", "Focusable element has zero rendered width or height", - element), ref diagnosticCount, ref truncated); + element)); } else if (!insideDynamicCollection && !HasScrollViewAncestor(element) && IsFinitePositive(rect) && IsFinitePositive(root.worldBound)) @@ -465,18 +454,18 @@ internal static Dictionary Run( var intersection = Intersect(rect, viewport); if (intersection.width <= 0f || intersection.height <= 0f) { - Add(warnings, Diagnostic( + diagnostics.Add(false, Diagnostic( "focusable_outside_viewport", "Focusable element is fully outside the UI viewport", - element), ref diagnosticCount, ref truncated); + element)); } else if (intersection.width + 0.5f < rect.width || intersection.height + 0.5f < rect.height) { - Add(warnings, Diagnostic( + diagnostics.Add(false, Diagnostic( "focusable_partially_outside_viewport", "Focusable element is partially outside the UI viewport", - element), ref diagnosticCount, ref truncated); + element)); } } } @@ -489,18 +478,12 @@ internal static Dictionary Run( binding, "toUi", element, - errors, - warnings, - ref diagnosticCount, - ref truncated); + diagnostics); CheckBindingResult( binding, "toSource", element, - errors, - warnings, - ref diagnosticCount, - ref truncated); + diagnostics); } }); @@ -508,7 +491,7 @@ internal static Dictionary Run( { if (pair.Value.Count < 2) continue; - Add(warnings, new Dictionary + diagnostics.Add(false, new Dictionary { ["code"] = "duplicate_name", ["message"] = $"Element name '{pair.Key}' is used {pair.Value.Count} times outside dynamic collections", @@ -517,17 +500,17 @@ internal static Dictionary Run( ["type"] = pair.Value[0].GetType().Name, ["name"] = pair.Key } - }, ref diagnosticCount, ref truncated); + }); } return new Dictionary { - ["passed"] = errors.Count == 0, - ["errorCount"] = errors.Count, - ["warningCount"] = warnings.Count, - ["diagnosticsTruncated"] = truncated, - ["errors"] = errors, - ["warnings"] = warnings + ["passed"] = diagnostics.ErrorCount == 0, + ["errorCount"] = diagnostics.ErrorCount, + ["warningCount"] = diagnostics.WarningCount, + ["diagnosticsTruncated"] = diagnostics.Truncated, + ["errors"] = diagnostics.Errors, + ["warnings"] = diagnostics.Warnings }; } @@ -535,10 +518,7 @@ private static void CheckBindingResult( Dictionary binding, string direction, VisualElement element, - List errors, - List warnings, - ref int diagnosticCount, - ref bool truncated) + DiagnosticCollector diagnostics) { if (!binding.TryGetValue(direction, out var resultObject) || resultObject is not Dictionary result || @@ -559,10 +539,7 @@ resultObject is not Dictionary result || "binding_" + (status ?? "unknown").ToLowerInvariant(), $"Binding {direction} for '{property}' reported {status}: {message}", element); - Add(string.Equals(status, "Failure", StringComparison.OrdinalIgnoreCase) ? errors : warnings, - diagnostic, - ref diagnosticCount, - ref truncated); + diagnostics.Add(string.Equals(status, "Failure", StringComparison.OrdinalIgnoreCase), diagnostic); } private static Dictionary Diagnostic( @@ -628,7 +605,12 @@ private static bool HasScrollViewAncestor(VisualElement element) private static bool IsUserElementName(string name) { return !string.IsNullOrEmpty(name) && - !name.StartsWith("unity-", StringComparison.Ordinal); + !IsUnityInternalName(name); + } + + private static bool IsUnityInternalName(string name) + { + return name != null && name.StartsWith("unity-", StringComparison.Ordinal); } private static bool IsVisibleThroughAncestors(VisualElement element) @@ -660,19 +642,33 @@ private static Rect Intersect(Rect left, Rect right) return Rect.MinMaxRect(xMin, yMin, Mathf.Max(xMin, xMax), Mathf.Max(yMin, yMax)); } - private static void Add( - List target, - object diagnostic, - ref int diagnosticCount, - ref bool truncated) + private sealed class DiagnosticCollector { - if (diagnosticCount >= MaxDiagnostics) - { - truncated = true; - return; + internal List Errors { get; } = new List(); + internal List Warnings { get; } = new List(); + internal int ErrorCount { get; private set; } + internal int WarningCount { get; private set; } + internal bool Truncated { get; private set; } + + internal void Add(bool isError, object diagnostic) + { + // Bound the response, but keep counting every diagnostic so + // truncation cannot hide a failing audit or warning policy. + if (isError) + ErrorCount++; + else + WarningCount++; + + if (Errors.Count + Warnings.Count >= MaxDiagnostics) + { + Truncated = true; + if (!isError || Warnings.Count == 0) + return; + // Preserve the cause of failure even when warnings arrived first. + Warnings.RemoveAt(Warnings.Count - 1); + } + (isError ? Errors : Warnings).Add(diagnostic); } - target.Add(diagnostic); - diagnosticCount++; } } } diff --git a/unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs b/unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs index 3130612..ded8c7c 100644 --- a/unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs +++ b/unity-package/com.ucp.bridge/Tests/Editor/UiControllerTests.cs @@ -84,6 +84,52 @@ public void OperationManager_ConstructorFailureCompletesAndAdvancesQueue() } } + [Test] + public void OperationManager_ShutdownFailsActiveAndQueuedOperations() + { + UiOperationManager.ResetForTests(); + try + { + var active = UiOperationManager.Start("inspect", new Dictionary()); + var queued = UiOperationManager.Start("inspect", new Dictionary()); + // Activate without advancing initialization (which would resolve a target). + typeof(UiOperationManager).GetMethod("ActivateNext", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static) + .Invoke(null, null); + Assert.That(UiOperationManager.Status(active["operationId"].ToString())["status"], Is.EqualTo("running")); + UiOperationManager.Shutdown(); + foreach (var start in new[] { active, queued }) + { + var status = UiOperationManager.Status(start["operationId"].ToString()); + Assert.That(status["status"], Is.EqualTo("failed")); + Assert.That(((Dictionary)status["error"])["code"], Is.EqualTo("editor_shutdown")); + } + UiOperationManager.TickForTests(); + Assert.That(UiOperationManager.Status(queued["operationId"].ToString())["status"], Is.EqualTo("failed")); + } + finally + { + UiOperationManager.ResetForTests(); + } + } + + [Test] + public void Audit_RetainsFirstErrorsWhenErrorsExhaustBudget() + { + var report = new UiApplyReport(); + report.AddDiagnostic("warning", "test", "displaced warning"); + for (var index = 0; index < 201; index++) + report.AddDiagnostic("error", "test", "error " + index); + var audit = UiAudit.Run(new VisualElement(), report, Array.Empty()); + Assert.That(audit["errorCount"], Is.EqualTo(201)); + Assert.That(audit["warningCount"], Is.EqualTo(1)); + Assert.That(audit["diagnosticsTruncated"], Is.True); + Assert.That((List)audit["warnings"], Is.Empty); + var errors = (List)audit["errors"]; + Assert.That(errors, Has.Count.EqualTo(200)); + Assert.That(((Dictionary)errors[199])["message"], Is.EqualTo("error 199")); + } + [Test] public void Cleanup_RunsEveryActionAndRetainsFirstFailure() { @@ -179,6 +225,30 @@ public void Inspector_AppliesSimpleQueryAndElementBound() Assert.That(Convert.ToBoolean(result["truncated"]), Is.True); } + [Test] + public void InspectorAndGeometrySampler_IncludePhysicalChildrenOfListView() + { + var list = new ListView(); + var row = new Label("Before") { name = "row-label" }; + // A ListView owns its physical hierarchy and exposes no contentContainer. + // Model a realized row without needing a graphics device for this regression. + list.hierarchy.Add(row); + Assert.That(list.contentContainer, Is.Null); + + var snapshot = UiVisualTreeInspector.Inspect(list, + UiInspectOptions.Parse(new Dictionary { ["query"] = "#row-label" }), + Array.Empty()); + Assert.That(snapshot["matchCount"], Is.EqualTo(1)); + var fullSnapshot = UiVisualTreeInspector.Inspect(list, + UiInspectOptions.Parse(new Dictionary { ["depth"] = 64 }), + Array.Empty()); + Assert.That(MiniJson.Serialize(fullSnapshot), Does.Contain("row-label")); + + var before = UiGeometrySampler.Measure(list); + row.text = "After"; + Assert.That(UiGeometrySampler.Measure(list).Hash, Is.Not.EqualTo(before.Hash)); + } + [Test] public void Audit_ReportsDuplicateStaticNames() { @@ -217,7 +287,52 @@ public void Audit_UsesOneGlobalDiagnosticBudgetIncludingDuplicateNames() Array.Empty()); Assert.That(Convert.ToInt32(result["errorCount"]), Is.EqualTo(199)); - Assert.That(Convert.ToInt32(result["warningCount"]), Is.EqualTo(1)); + Assert.That(Convert.ToInt32(result["warningCount"]), Is.EqualTo(2)); + Assert.That((List)result["errors"], Has.Count.EqualTo(199)); + Assert.That((List)result["warnings"], Has.Count.EqualTo(1)); + Assert.That(result["diagnosticsTruncated"], Is.True); + } + + [TestCase(199)] + [TestCase(200)] + [TestCase(201)] + public void Audit_CountsErrorsBeyondReturnedDiagnosticLimit(int warningCount) + { + var report = new UiApplyReport(); + for (var index = 0; index < warningCount; index++) + report.AddDiagnostic("warning", "test", "warning"); + report.AddDiagnostic("error", "test", "error after warnings"); + + var result = UiAudit.Run(new VisualElement(), report, Array.Empty()); + var errors = (List)result["errors"]; + var warnings = (List)result["warnings"]; + + Assert.That(result["passed"], Is.False); + Assert.That(result["errorCount"], Is.EqualTo(1)); + Assert.That(result["warningCount"], Is.EqualTo(warningCount)); + Assert.That(errors.Count + warnings.Count, Is.EqualTo(200)); + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(((Dictionary)errors[0])["message"], Is.EqualTo("error after warnings")); + Assert.That(warnings, Has.Count.EqualTo(199)); + Assert.That(result["diagnosticsTruncated"], Is.EqualTo(warningCount >= 200)); + } + + [Test] + public void Audit_ContinuesCheckingTreeAfterApplicationDiagnosticsFillLimit() + { + var report = new UiApplyReport(); + for (var index = 0; index < 200; index++) + report.AddDiagnostic("warning", "test", "warning"); + var root = new VisualElement(); + root.Add(new Label { name = "duplicate" }); + root.Add(new Label { name = "duplicate" }); + + var result = UiAudit.Run(root, report, Array.Empty()); + + Assert.That(result["passed"], Is.True); + Assert.That(result["errorCount"], Is.EqualTo(0)); + Assert.That(result["warningCount"], Is.EqualTo(201)); + Assert.That((List)result["warnings"], Has.Count.EqualTo(200)); Assert.That(result["diagnosticsTruncated"], Is.True); } diff --git a/unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs b/unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs index e3d93e6..bcb7eb4 100644 --- a/unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs +++ b/unity-package/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs @@ -88,6 +88,74 @@ public void WarningFailurePolicy_IsExplicit() Assert.That(UiLintService.Passes(1, 0, false), Is.False); } + [TestCase(1)] + [TestCase(3)] + public void DiagnosticLimit_PreservesErrorsAndCountsEverySeverity(int limit) + { + var collector = new UiLintService.DiagnosticCollector(limit); + for (var index = 0; index < limit + 1; index++) + collector.Add("warning", "warning-" + index, Root, "test", "warning"); + collector.Add("error", "late-error", Root, "test", "cause of failure"); + Assert.That(collector.ErrorCount, Is.EqualTo(1)); + Assert.That(collector.WarningCount, Is.EqualTo(limit + 1)); + Assert.That(collector.Truncated, Is.True); + Assert.That(collector.Items, Has.Count.EqualTo(limit)); + var details = collector.Items.Cast>().ToList(); + Assert.That(details.Last()["code"], Is.EqualTo("late-error")); + if (limit > 1) + Assert.That(details[limit - 2]["code"], Is.EqualTo("warning-" + (limit - 2))); + for (var index = 0; index < limit; index++) + collector.Add("error", "error-" + index, Root, "test", "error"); + collector.Add("warning", "dropped", Root, "test", "warning"); + Assert.That(collector.ErrorCount, Is.EqualTo(limit + 1)); + Assert.That(collector.WarningCount, Is.EqualTo(limit + 2)); + Assert.That(collector.Items, Has.Count.EqualTo(limit)); + Assert.That(collector.Items.Cast>() + .All(item => Equals(item["severity"], "error")), Is.True); + Assert.That(((Dictionary)collector.Items[0])["code"], Is.EqualTo("late-error")); + } + + [TestCase(false)] + [TestCase(true)] + public void ThemeStyleSheets_AreLintedWithTheirDependencies(bool folder) + { + WriteAsset(Root + "/Theme.uss", ".probe { width: 100px; }\n"); + WriteAsset(Root + "/Theme.tss", "@import url(\"Theme.uss\");\n"); + var result = UiLintService.Run(new Dictionary + { + ["paths"] = new List { folder ? Root : Root + "/Theme.tss" }, + ["failOnWarnings"] = true + }); + Assert.That(result["passed"], Is.True, MiniJson.Serialize(result)); + var assets = ((List)result["assets"]).Cast>().ToList(); + Assert.That(assets.Select(asset => asset["path"]), + Does.Contain(Root + "/Theme.tss").And.Contain(Root + "/Theme.uss")); + Assert.That(assets.All(asset => Equals(asset["reimported"], true)), Is.True); + } + + [Test] + public void ImmutablePackageStyleSheet_UsesExistingImport() + { + var path = AssetDatabase.GetAllAssetPaths().FirstOrDefault(candidate => + candidate.StartsWith("Packages/", StringComparison.Ordinal) && + (candidate.EndsWith(".uss", StringComparison.OrdinalIgnoreCase) || + candidate.EndsWith(".tss", StringComparison.OrdinalIgnoreCase)) && + !UiLintService.ShouldReimport(candidate)); + if (path == null) + Assert.Ignore("No immutable package stylesheet is installed"); + var result = UiLintService.Run(new Dictionary + { + ["paths"] = new List { path } + }); + var asset = ((List)result["assets"]).Cast>() + .SingleOrDefault(item => Equals(item["path"], path)); + Assert.That(asset, Is.Not.Null, MiniJson.Serialize(result)); + Assert.That(asset["reimported"], Is.False); + Assert.That(asset["assetType"], Is.Not.Null); + Assert.That(((List)result["diagnostics"]).Cast>() + .Any(item => Equals(item["code"], "UI_IMPORT_FAILED")), Is.False); + } + private static void WriteAsset(string assetPath, string contents) { var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); diff --git a/unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs b/unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs index be479d1..791d106 100644 --- a/unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs +++ b/unity-package/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs @@ -1,11 +1,13 @@ #if UNITY_6000_0_OR_NEWER using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using NUnit.Framework; using UnityEditor; using UnityEngine; +using UnityEngine.TestTools; using UnityEngine.UIElements; namespace UCP.Bridge.Tests @@ -50,6 +52,25 @@ public void Resolve_RejectsUnknownFixtureFields() Assert.That(exception.Location, Does.EndWith(".unexpected")); } + [TestCase("Packages/com.example.ui/Styles/../Theme.tss", null, "Packages/com.example.ui/Theme.tss")] + [TestCase("./Theme.tss", "Packages/com.example.ui/Panel.ucp-ui.json", "Packages/com.example.ui/Theme.tss")] + [TestCase("Assets/UI/../Panel.uxml", null, "Assets/Panel.uxml")] + public void NormalizeAssetPath_PreservesVirtualRoots(string path, string fixture, string expected) + { + Assert.That(UiScenarioLoader.NormalizeAssetPath(path, fixture, "$test"), Is.EqualTo(expected)); + var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + Assert.That(UiScenarioLoader.NormalizeAssetPath(Path.Combine(projectRoot, expected), null, "$test"), + Is.EqualTo(expected)); + } + + [TestCase("Packages/../../outside.uss")] + [TestCase("Assets/../Library/Theme.tss")] + [TestCase("Packages/com.example/../../Library/Theme.tss")] + public void NormalizeAssetPath_RejectsEscapesAfterCollapsingSegments(string path) + { + Assert.Throws(() => UiScenarioLoader.NormalizeAssetPath(path, null, "$test")); + } + [Test] public void Resolve_RejectsMoreThanSixtyFourStates() { @@ -272,6 +293,154 @@ public void Apply_RejectsAmbiguousSelectors() Assert.That(exception.Code, Is.EqualTo("ui.selector-ambiguous")); } + [UnityTest] + public IEnumerator InspectAndAudit_IncludeRealizedListViewRowsAndBindingFailures() + { + if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null) + Assert.Ignore("Realized ListView binding coverage requires an Editor graphics device"); + + var previousFocus = EditorWindow.focusedWindow; + var window = UiHostWindow.Open(320, 240); + try + { + var root = window.ContentRoot; + Binding.SetPanelLogLevel(root.panel, BindingLogLevel.None); + var list = new ListView { name = "rows" }; + list.style.height = 160; + root.Add(list); + var scenario = new UiResolvedScenario( + DocumentPath, null, "default", DocumentPath, + AssetDatabase.LoadAssetAtPath(DocumentPath), + new Dictionary + { + ["items"] = new List { Item("One"), new Dictionary() } + }, + Array.Empty(), + new[] + { + new UiCollectionDefinition("#rows", UiCollectionMode.ListView, "/items", RowPath, + AssetDatabase.LoadAssetAtPath(RowPath), 24f, "$test.list") + }, + new UiViewport(320, 240), new UiSettleOptions()); + var report = UiScenarioApplier.Apply(root, scenario); + var metadata = UiScenarioApplier.GetCollectionMetadata(root); + List