| /* |
| * Copyright 2026 Rive |
| */ |
| |
| // Unit tests for the dispatch path that |
| // StateMachineInstance::fireSemanticAction depends on: |
| // 1. SemanticData's listener subscription / dispatch. |
| // 2. SemanticManager::nodeById + SemanticNode::semanticData() back-pointer |
| // routing, which is how an action id resolves to the target SemanticData |
| // without walking any scene graph. |
| // |
| // The full StateMachineInstance flow (advance→processSemanticEvents→ |
| // listener->performChanges) is not reached here because it requires loading a |
| // .riv with an authored semantic listener; those paths are covered by the |
| // Dart integration tests under packages/rive_native/test/semantic/. |
| |
| #include <rive/animation/semantic_listener_group.hpp> |
| #include <rive/refcnt.hpp> |
| #include <rive/semantic/semantic_data.hpp> |
| #include <rive/semantic/semantic_listener.hpp> |
| #include <rive/semantic/semantic_manager.hpp> |
| #include <rive/semantic/semantic_node.hpp> |
| #include <rive/semantic/semantic_role.hpp> |
| #include <rive/semantic/semantic_state.hpp> |
| #include <catch.hpp> |
| |
| using namespace rive; |
| |
| namespace |
| { |
| class MockSemanticListener : public SemanticListener |
| { |
| public: |
| int tapCount = 0; |
| int increaseCount = 0; |
| int decreaseCount = 0; |
| |
| void onSemanticTap() override { tapCount++; } |
| void onSemanticIncrease() override { increaseCount++; } |
| void onSemanticDecrease() override { decreaseCount++; } |
| }; |
| } // namespace |
| |
| // --------------------------------------------------------------------------- |
| // SemanticData listener subscription & dispatch. |
| // --------------------------------------------------------------------------- |
| |
| TEST_CASE("fireSemanticTap invokes onSemanticTap on all registered listeners", |
| "[semantics][dispatch]") |
| { |
| SemanticData sd; |
| MockSemanticListener a; |
| MockSemanticListener b; |
| sd.addSemanticListener(&a); |
| sd.addSemanticListener(&b); |
| |
| sd.fireSemanticTap(); |
| |
| CHECK(a.tapCount == 1); |
| CHECK(b.tapCount == 1); |
| CHECK(a.increaseCount == 0); |
| CHECK(a.decreaseCount == 0); |
| } |
| |
| TEST_CASE("fireSemanticIncrease only invokes onSemanticIncrease", |
| "[semantics][dispatch]") |
| { |
| SemanticData sd; |
| MockSemanticListener listener; |
| sd.addSemanticListener(&listener); |
| |
| sd.fireSemanticIncrease(); |
| |
| CHECK(listener.increaseCount == 1); |
| CHECK(listener.tapCount == 0); |
| CHECK(listener.decreaseCount == 0); |
| } |
| |
| TEST_CASE("fireSemanticDecrease only invokes onSemanticDecrease", |
| "[semantics][dispatch]") |
| { |
| SemanticData sd; |
| MockSemanticListener listener; |
| sd.addSemanticListener(&listener); |
| |
| sd.fireSemanticDecrease(); |
| |
| CHECK(listener.decreaseCount == 1); |
| CHECK(listener.tapCount == 0); |
| CHECK(listener.increaseCount == 0); |
| } |
| |
| TEST_CASE( |
| "removeSemanticListener stops future dispatches from reaching the listener", |
| "[semantics][dispatch]") |
| { |
| SemanticData sd; |
| MockSemanticListener listener; |
| sd.addSemanticListener(&listener); |
| |
| sd.fireSemanticTap(); |
| CHECK(listener.tapCount == 1); |
| |
| sd.removeSemanticListener(&listener); |
| sd.fireSemanticTap(); |
| CHECK(listener.tapCount == 1); // no change |
| } |
| |
| TEST_CASE("removeSemanticListener on an unregistered pointer is a no-op", |
| "[semantics][dispatch]") |
| { |
| SemanticData sd; |
| MockSemanticListener registered; |
| MockSemanticListener ghost; |
| sd.addSemanticListener(®istered); |
| |
| // Never added; removing it must not affect the real subscription. |
| sd.removeSemanticListener(&ghost); |
| |
| sd.fireSemanticTap(); |
| CHECK(registered.tapCount == 1); |
| } |
| |
| TEST_CASE("fireSemantic* with no listeners is a silent no-op", |
| "[semantics][dispatch]") |
| { |
| SemanticData sd; |
| sd.fireSemanticTap(); |
| sd.fireSemanticIncrease(); |
| sd.fireSemanticDecrease(); |
| SUCCEED("no crash, no observable effect"); |
| } |
| |
| // --------------------------------------------------------------------------- |
| // Routing from SemanticManager through the SemanticNode::semanticData() |
| // back-pointer to the owning SemanticData. This is exactly what |
| // StateMachineInstance::fireSemanticAction does — isolating the routing |
| // lets us cover it without a full state-machine harness. |
| // --------------------------------------------------------------------------- |
| |
| TEST_CASE("SemanticNode::semanticData is the back-pointer set at node creation", |
| "[semantics][dispatch]") |
| { |
| SemanticData sd; |
| auto node = sd.semanticNode(); |
| REQUIRE(node != nullptr); |
| CHECK(node->semanticData() == &sd); |
| } |
| |
| TEST_CASE("manager.nodeById + node->semanticData routes dispatch to the SD", |
| "[semantics][dispatch]") |
| { |
| SemanticManager mgr; |
| SemanticData sd; |
| sd.role(static_cast<uint32_t>(SemanticRole::button)); |
| sd.label("Go"); |
| |
| auto node = sd.semanticNode(); |
| mgr.addChild(nullptr, node); |
| const uint32_t id = node->id(); |
| REQUIRE(id != 0); |
| |
| MockSemanticListener listener; |
| sd.addSemanticListener(&listener); |
| |
| auto* found = mgr.nodeById(id); |
| REQUIRE(found != nullptr); |
| auto* ownerSd = found->semanticData(); |
| REQUIRE(ownerSd == &sd); |
| |
| ownerSd->fireSemanticTap(); |
| CHECK(listener.tapCount == 1); |
| |
| // Remove the listener before the SD destructs, mirroring what |
| // SemanticListenerGroup does in its own destructor. |
| sd.removeSemanticListener(&listener); |
| } |
| |
| TEST_CASE("manager.nodeById returns null for an unknown id", |
| "[semantics][dispatch]") |
| { |
| SemanticManager mgr; |
| SemanticData sd; |
| sd.role(static_cast<uint32_t>(SemanticRole::button)); |
| auto node = sd.semanticNode(); |
| mgr.addChild(nullptr, node); |
| |
| CHECK(mgr.nodeById(9999) == nullptr); |
| } |
| |
| TEST_CASE("boundary node has no owning SemanticData", "[semantics][dispatch]") |
| { |
| SemanticManager mgr; |
| auto boundary = rcp<SemanticNode>(new SemanticNode()); |
| boundary->isBoundaryNode(true); |
| mgr.addChild(nullptr, boundary); |
| |
| auto* found = mgr.nodeById(boundary->id()); |
| REQUIRE(found != nullptr); |
| CHECK(found->isBoundaryNode()); |
| CHECK(found->semanticData() == nullptr); |
| } |
| |
| TEST_CASE("removing an SD from the manager drops its id from the index", |
| "[semantics][dispatch]") |
| { |
| SemanticManager mgr; |
| SemanticData sd; |
| sd.role(static_cast<uint32_t>(SemanticRole::button)); |
| auto node = sd.semanticNode(); |
| mgr.addChild(nullptr, node); |
| const uint32_t id = node->id(); |
| |
| REQUIRE(mgr.nodeById(id) == node.get()); |
| |
| mgr.removeChild(node); |
| CHECK(mgr.nodeById(id) == nullptr); |
| } |
| |
| // --------------------------------------------------------------------------- |
| // Runtime-driven state modification. |
| // --------------------------------------------------------------------------- |
| |
| TEST_CASE("setFocusedState toggles the Focused bit and preserves siblings", |
| "[semantics][dispatch]") |
| { |
| SemanticData sd; |
| auto node = sd.semanticNode(); |
| |
| const uint32_t initial = static_cast<uint32_t>(SemanticState::Selected) | |
| static_cast<uint32_t>(SemanticState::Expanded); |
| node->stateFlags(initial); |
| |
| sd.setFocusedState(true); |
| CHECK(hasSemanticState(node->stateFlags(), SemanticState::Focused)); |
| CHECK(hasSemanticState(node->stateFlags(), SemanticState::Selected)); |
| CHECK(hasSemanticState(node->stateFlags(), SemanticState::Expanded)); |
| |
| sd.setFocusedState(false); |
| CHECK_FALSE(hasSemanticState(node->stateFlags(), SemanticState::Focused)); |
| CHECK(hasSemanticState(node->stateFlags(), SemanticState::Selected)); |
| CHECK(hasSemanticState(node->stateFlags(), SemanticState::Expanded)); |
| } |
| |
| TEST_CASE("setFocusedState is a no-op before semanticNode() has been created", |
| "[semantics][dispatch]") |
| { |
| SemanticData sd; |
| CHECK_FALSE(sd.hasSemanticNode()); |
| |
| // Must not crash or lazily create a node — the Focused bit is |
| // runtime-driven and meaningless without a node. |
| sd.setFocusedState(true); |
| sd.setFocusedState(false); |
| |
| CHECK_FALSE(sd.hasSemanticNode()); |
| } |
| |
| // --------------------------------------------------------------------------- |
| // SemanticManager::requestFocus — negative paths. The positive path (a SD |
| // with a sibling FocusData and a real FocusManager) requires a fixture |
| // authored with focus trait; none of the current fixtures exercise it |
| // directly, so the happy case is deferred to future fixture work. |
| // --------------------------------------------------------------------------- |
| |
| TEST_CASE("requestFocus with an unknown id returns false", |
| "[semantics][dispatch]") |
| { |
| SemanticManager mgr; |
| CHECK_FALSE(mgr.requestFocus(42)); |
| } |
| |
| TEST_CASE("requestFocus on a registered node with no coreOwner returns false", |
| "[semantics][dispatch]") |
| { |
| SemanticManager mgr; |
| auto node = rcp<SemanticNode>(new SemanticNode()); |
| mgr.addChild(nullptr, node); |
| REQUIRE(node->coreOwner() == nullptr); |
| |
| CHECK_FALSE(mgr.requestFocus(node->id())); |
| } |
| |
| TEST_CASE("requestFocus on a boundary node returns false", |
| "[semantics][dispatch]") |
| { |
| SemanticManager mgr; |
| auto boundary = rcp<SemanticNode>(new SemanticNode()); |
| boundary->isBoundaryNode(true); |
| mgr.addChild(nullptr, boundary); |
| |
| // Boundary nodes may or may not have a Node coreOwner, but they have no |
| // SD child either way → no focusable association. |
| CHECK_FALSE(mgr.requestFocus(boundary->id())); |
| } |