Skip to content
Draft
10 changes: 9 additions & 1 deletion crates/originweave-core/src/semantic_action_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@ 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,
) -> Result<Self, SemanticNodeActionTargetError> {
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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
}
}
}
}
Expand Down
38 changes: 36 additions & 2 deletions crates/originweave-core/tests/semantic_node_action_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ struct ObservationFixture {
}

fn observation_fixture() -> Result<ObservationFixture, String> {
observation_fixture_with_enabled(true)
}

fn observation_fixture_with_enabled(enabled: bool) -> Result<ObservationFixture, String> {
let mut registry = BrowserAuthorityRegistry::new();
let session = registry
.register_session("semantic-action-session")
Expand All @@ -33,10 +37,13 @@ fn observation_fixture() -> Result<ObservationFixture, String> {
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]),
},
&registry,
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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"
);
}
Loading