diff --git a/crates/originweave-core/src/semantic_action_target.rs b/crates/originweave-core/src/semantic_action_target.rs index 6d4a1a418..1fe02a990 100644 --- a/crates/originweave-core/src/semantic_action_target.rs +++ b/crates/originweave-core/src/semantic_action_target.rs @@ -16,7 +16,7 @@ pub struct SemanticNodeActionTarget { } impl SemanticNodeActionTarget { - /// Construct a target only when the observation advertised the requested node-local action. + /// Construct a target only when the observation advertises a currently coherent node action. pub fn from_observation( observation: &SemanticNodeObservation, action: NodeActionKind, @@ -24,6 +24,9 @@ impl SemanticNodeActionTarget { if !observation.supported_actions().contains(&action) { return Err(SemanticNodeActionTargetError::UnsupportedAction); } + if action != NodeActionKind::ScrollIntoView && !observation.is_enabled() { + return Err(SemanticNodeActionTargetError::NodeNotEnabled); + } Ok(Self { handle: observation.handle().clone(), action, @@ -59,6 +62,8 @@ impl SemanticNodeActionTarget { pub enum SemanticNodeActionTargetError { /// The requested action was not advertised by the semantic observation. UnsupportedAction, + /// The observation reported the target disabled for an interactive action. + NodeNotEnabled, } impl fmt::Display for SemanticNodeActionTargetError { @@ -67,6 +72,9 @@ impl fmt::Display for SemanticNodeActionTargetError { Self::UnsupportedAction => { formatter.write_str("semantic node action is not advertised by the observation") } + Self::NodeNotEnabled => { + formatter.write_str("semantic node is not enabled for the requested action") + } } } } diff --git a/crates/originweave-core/tests/semantic_node_action_target.rs b/crates/originweave-core/tests/semantic_node_action_target.rs index 916b77b1a..5ff305782 100644 --- a/crates/originweave-core/tests/semantic_node_action_target.rs +++ b/crates/originweave-core/tests/semantic_node_action_target.rs @@ -14,6 +14,10 @@ struct ObservationFixture { } fn observation_fixture() -> Result { + observation_fixture_with_enabled(true) +} + +fn observation_fixture_with_enabled(enabled: bool) -> Result { let mut registry = BrowserAuthorityRegistry::new(); let session = registry .register_session("semantic-action-session") @@ -33,10 +37,13 @@ fn observation_fixture() -> Result { role: "button".to_owned(), accessible_name: "Save draft".to_owned(), visible_text: Some("Save draft".to_owned()), - enabled: true, + enabled, visible: true, selected: None, - supported_actions: BTreeSet::from([NodeActionKind::Click]), + supported_actions: BTreeSet::from([ + NodeActionKind::Click, + NodeActionKind::ScrollIntoView, + ]), evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]), }, ®istry, @@ -74,6 +81,29 @@ fn unsupported_node_action_fails_closed_without_minting_authority() -> Result<() Ok(()) } +#[test] +fn disabled_interactive_node_action_fails_closed() -> Result<(), String> { + let fixture = observation_fixture_with_enabled(false)?; + assert_eq!( + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .err(), + Some(SemanticNodeActionTargetError::NodeNotEnabled) + ); + Ok(()) +} + +#[test] +fn disabled_node_can_still_be_targeted_for_scroll_only() -> Result<(), String> { + let fixture = observation_fixture_with_enabled(false)?; + let target = SemanticNodeActionTarget::from_observation( + &fixture.observation, + NodeActionKind::ScrollIntoView, + ) + .map_err(|error| error.to_string())?; + assert_eq!(target.action(), NodeActionKind::ScrollIntoView); + Ok(()) +} + #[test] fn node_action_target_revalidates_live_registry_authority() -> Result<(), String> { let fixture = observation_fixture()?; @@ -147,4 +177,8 @@ fn node_action_target_error_is_stable_and_credential_free() { SemanticNodeActionTargetError::UnsupportedAction.to_string(), "semantic node action is not advertised by the observation" ); + assert_eq!( + SemanticNodeActionTargetError::NodeNotEnabled.to_string(), + "semantic node is not enabled for the requested action" + ); }