blob: 104cb99f931de8383070b613ef2cf315bdf419d4 [file]
/*
* Copyright 2026 Rive
*/
// Integration tests for Artboard-level semantic tree construction using
// data_binding_lists.riv — a dropdown button that toggles a list of four
// fandom items via data binding.
#include <rive/animation/state_machine_instance.hpp>
#include <rive/artboard.hpp>
#include <rive/semantic/semantic_manager.hpp>
#include <rive/semantic/semantic_node.hpp>
#include <rive/semantic/semantic_role.hpp>
#include <rive/semantic/semantic_snapshot.hpp>
#include <rive/semantic/semantic_state.hpp>
#include <rive/semantic/semantic_trait.hpp>
#include "rive_file_reader.hpp"
#include "semantic_test_helpers.hpp"
#include <catch.hpp>
#include <set>
#include <string>
using namespace rive;
using namespace rive::semantic_test;
namespace
{
static const std::set<std::string> kFandomLabels = {
"War of the Stars",
"Scufflestar Galactica",
"Galaxy Hike",
"Dino Planet",
};
// Diff-replay aggregator: holds the current semantic tree as seen through
// drainDiff(), mirroring SemanticTreeModel.applyDiff on the Dart side but
// retaining only the fields these tests assert on.
struct Setup
{
Fixture fixture;
struct Entry
{
uint32_t id;
uint32_t role;
std::string label;
uint32_t stateFlags;
uint32_t traitFlags;
AABB bounds;
};
std::unordered_map<uint32_t, Entry> entries;
StateMachineInstance* sm() const { return fixture.sm.get(); }
void applyDiff(const SemanticsDiff& diff)
{
for (auto id : diff.removed)
{
entries.erase(id);
}
for (const auto& n : diff.added)
{
entries[n.id] = {n.id,
n.role,
n.label,
n.stateFlags,
n.traitFlags,
AABB(n.minX, n.minY, n.maxX, n.maxY)};
}
for (const auto& n : diff.updatedSemantic)
{
auto it = entries.find(n.id);
if (it != entries.end())
{
it->second.role = n.role;
it->second.label = n.label;
it->second.stateFlags = n.stateFlags;
it->second.traitFlags = n.traitFlags;
}
}
for (const auto& g : diff.updatedGeometry)
{
auto it = entries.find(g.id);
if (it != entries.end())
{
it->second.bounds = AABB(g.minX, g.minY, g.maxX, g.maxY);
}
}
}
void settle()
{
auto* mgr = sm()->semanticManager();
REQUIRE(mgr != nullptr);
advance(sm());
applyDiff(mgr->drainDiff());
}
const Entry* findByLabel(const std::string& label) const
{
for (const auto& kv : entries)
{
if (kv.second.label == label)
{
return &kv.second;
}
}
return nullptr;
}
std::set<std::string> fandomLabelsPresent() const
{
std::set<std::string> out;
for (const auto& kv : entries)
{
if (kv.second.role == static_cast<uint32_t>(SemanticRole::text) &&
kFandomLabels.count(kv.second.label) > 0)
{
out.insert(kv.second.label);
}
}
return out;
}
void tapDropdown()
{
const auto* button = findByLabel(kDropdownLabel);
REQUIRE(button != nullptr);
const Vec2D center((button->bounds.minX + button->bounds.maxX) * 0.5f,
(button->bounds.minY + button->bounds.maxY) * 0.5f);
sm()->pointerDown(center);
sm()->pointerUp(center);
settle();
}
};
// Loads the fixture via the shared helper, then seeds the diff-replay
// aggregator from the initial settle diff so Setup::entries is ready.
static Setup loadDataBindingLists()
{
Setup s;
s.fixture = loadFixture("assets/semantic/data_binding_lists.riv");
REQUIRE(s.fixture.sm != nullptr);
auto* mgr = s.fixture.sm->semanticManager();
REQUIRE(mgr != nullptr);
s.applyDiff(mgr->drainDiff());
return s;
}
} // namespace
// ---------------------------------------------------------------------------
// Unified tree: ArtboardComponentList items register into the top-level
// SemanticManager.
// ---------------------------------------------------------------------------
TEST_CASE("ArtboardComponentList items populate the parent artboard's semantic "
"manager",
"[semantics][artboard]")
{
auto s = loadDataBindingLists();
// All four fandom labels should be present as text SDs in the unified
// tree, even though they live inside list-item sub-artboards.
CHECK(s.fandomLabelsPresent() == kFandomLabels);
// Each fandom text SD's id must resolve through the top-level manager
// (not just show up in the diff).
auto* mgr = s.sm()->semanticManager();
for (const auto& label : kFandomLabels)
{
const auto* entry = s.findByLabel(label);
REQUIRE(entry != nullptr);
CAPTURE(label);
CHECK(mgr->nodeById(entry->id) != nullptr);
}
}
TEST_CASE("Dropdown button starts expanded with the Expandable trait authored",
"[semantics][artboard]")
{
auto s = loadDataBindingLists();
const auto* button = s.findByLabel(kDropdownLabel);
REQUIRE(button != nullptr);
CHECK(button->role == static_cast<uint32_t>(SemanticRole::button));
CHECK(hasSemanticTrait(button->traitFlags, SemanticTrait::Expandable));
CHECK(hasSemanticState(button->stateFlags, SemanticState::Expanded));
}
TEST_CASE("List-item SDs have non-empty bounds in the unified coordinate space",
"[semantics][artboard]")
{
auto s = loadDataBindingLists();
for (const auto& label : kFandomLabels)
{
const auto* entry = s.findByLabel(label);
REQUIRE(entry != nullptr);
CAPTURE(label);
CAPTURE(entry->bounds.minX);
CAPTURE(entry->bounds.minY);
CAPTURE(entry->bounds.maxX);
CAPTURE(entry->bounds.maxY);
// List items go through rootTransform during bounds computation —
// the result must be a non-degenerate rect.
CHECK_FALSE(entry->bounds.isEmptyOrNaN());
CHECK(entry->bounds.width() > 0.0f);
CHECK(entry->bounds.height() > 0.0f);
}
}
// ---------------------------------------------------------------------------
// Spawn / despawn cycle.
// ---------------------------------------------------------------------------
TEST_CASE("Collapsing the dropdown removes list-item SDs from the unified tree",
"[semantics][artboard]")
{
auto s = loadDataBindingLists();
CHECK(s.fandomLabelsPresent() == kFandomLabels);
// Record ids before collapse so we can verify the manager index
// drops them.
std::vector<uint32_t> preCollapseIds;
for (const auto& label : kFandomLabels)
{
const auto* entry = s.findByLabel(label);
REQUIRE(entry != nullptr);
preCollapseIds.push_back(entry->id);
}
s.tapDropdown();
CHECK(s.fandomLabelsPresent().empty());
// Dropdown button's Expanded state bit must have flipped.
const auto* button = s.findByLabel(kDropdownLabel);
REQUIRE(button != nullptr);
CHECK_FALSE(hasSemanticState(button->stateFlags, SemanticState::Expanded));
// The manager's authoritative index must agree: every pre-collapse id
// is gone.
auto* mgr = s.sm()->semanticManager();
for (auto id : preCollapseIds)
{
CHECK(mgr->nodeById(id) == nullptr);
}
}
TEST_CASE("Re-expanding the dropdown re-registers list-item SDs",
"[semantics][artboard]")
{
auto s = loadDataBindingLists();
s.tapDropdown(); // collapse
CHECK(s.fandomLabelsPresent().empty());
s.tapDropdown(); // re-expand
CHECK(s.fandomLabelsPresent() == kFandomLabels);
// All four labels must resolve through the top-level manager post-reexpand.
auto* mgr = s.sm()->semanticManager();
for (const auto& label : kFandomLabels)
{
const auto* entry = s.findByLabel(label);
REQUIRE(entry != nullptr);
CAPTURE(label);
CHECK(mgr->nodeById(entry->id) != nullptr);
}
}
TEST_CASE("Multiple collapse / uncollapse cycles maintain exactly 4 fandoms",
"[semantics][artboard]")
{
auto s = loadDataBindingLists();
// Initial.
CHECK(s.fandomLabelsPresent() == kFandomLabels);
// Three full cycles — each cycle must clean up fully and rebuild
// without duplicating entries or leaking state.
for (int cycle = 0; cycle < 3; ++cycle)
{
CAPTURE(cycle);
s.tapDropdown(); // collapse
CHECK(s.fandomLabelsPresent().empty());
s.tapDropdown(); // re-expand
CHECK(s.fandomLabelsPresent() == kFandomLabels);
// There must be exactly four fandom-labelled text SDs — not eight
// or twelve — across all cycles.
int count = 0;
for (const auto& kv : s.entries)
{
if (kv.second.role == static_cast<uint32_t>(SemanticRole::text) &&
kFandomLabels.count(kv.second.label) > 0)
{
count++;
}
}
CHECK(count == 4);
}
}
// ---------------------------------------------------------------------------
// fireSemanticAction routed to a SD reached via the unified manager.
// ---------------------------------------------------------------------------
TEST_CASE("fireSemanticAction(tap) on the dropdown button collapses the list",
"[semantics][artboard]")
{
auto s = loadDataBindingLists();
const auto* button = s.findByLabel(kDropdownLabel);
REQUIRE(button != nullptr);
CHECK(hasSemanticState(button->stateFlags, SemanticState::Expanded));
CHECK(s.fandomLabelsPresent() == kFandomLabels);
s.sm()->fireSemanticAction(button->id, SemanticActionType::tap);
s.settle();
const auto* afterButton = s.findByLabel(kDropdownLabel);
REQUIRE(afterButton != nullptr);
CHECK_FALSE(
hasSemanticState(afterButton->stateFlags, SemanticState::Expanded));
CHECK(s.fandomLabelsPresent().empty());
}
TEST_CASE(
"fireSemanticAction(tap) via pointerDown/Up converge on the same state",
"[semantics][artboard]")
{
auto pointerSetup = loadDataBindingLists();
auto semanticSetup = loadDataBindingLists();
// Pointer path.
pointerSetup.tapDropdown();
// Semantic path.
const auto* button = semanticSetup.findByLabel(kDropdownLabel);
REQUIRE(button != nullptr);
semanticSetup.sm()->fireSemanticAction(button->id, SemanticActionType::tap);
semanticSetup.settle();
// Both must have collapsed.
CHECK(pointerSetup.fandomLabelsPresent().empty());
CHECK(semanticSetup.fandomLabelsPresent().empty());
// Both should agree on the dropdown button's Expanded bit.
const auto* pBtn = pointerSetup.findByLabel(kDropdownLabel);
const auto* sBtn = semanticSetup.findByLabel(kDropdownLabel);
REQUIRE(pBtn != nullptr);
REQUIRE(sBtn != nullptr);
CHECK(hasSemanticState(pBtn->stateFlags, SemanticState::Expanded) ==
hasSemanticState(sBtn->stateFlags, SemanticState::Expanded));
}
// ---------------------------------------------------------------------------
// enableSemantics idempotence under data-bound content.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// data_binding_lists_items.riv — parent artboard with an
// ArtboardComponentList authored under a SemanticData of role=list. The list
// items are hosted artboards whose state machines are created dynamically
// (after the parent's initial buildSemanticTree). These items carry their
// own listItem-role SemanticData and must land under the enclosing list-role
// node, otherwise their boundary becomes a root and the flattened parentId
// for listItems drops to -1 — violating the platform-side invariant that
// listItem roles live under a list role.
// ---------------------------------------------------------------------------
namespace
{
static bool hasAncestorWithRole(const SemanticNode* node, SemanticRole role)
{
const auto asU = static_cast<uint32_t>(role);
auto* current = node != nullptr ? node->parent() : nullptr;
while (current != nullptr)
{
if (current->role() == asU)
{
return true;
}
current = current->parent();
}
return false;
}
} // namespace
TEST_CASE("Dynamic list item artboards parent listItem nodes under the "
"enclosing list role",
"[semantics][artboard]")
{
auto f = loadFixture("assets/semantic/data_binding_lists_items.riv");
REQUIRE(f.sm != nullptr);
auto* mgr = f.sm->semanticManager();
REQUIRE(mgr != nullptr);
const auto diff = mgr->drainDiff();
// Collect every list and listItem node that the manager emitted.
std::vector<uint32_t> listIds;
std::vector<uint32_t> listItemIds;
for (const auto& n : diff.added)
{
if (n.role == static_cast<uint32_t>(SemanticRole::list))
{
listIds.push_back(n.id);
}
else if (n.role == static_cast<uint32_t>(SemanticRole::listItem))
{
listItemIds.push_back(n.id);
}
}
REQUIRE(listIds.size() == 1);
REQUIRE_FALSE(listItemIds.empty());
// (1) In the flattened diff, every listItem's parentId must resolve to
// the list-role node — not -1 (root) and not a non-list sibling.
// Boundary nodes between the listItem and the list get skipped
// during flatten, so the parentId lands directly on the list id.
std::unordered_map<uint32_t, int32_t> parentById;
for (const auto& n : diff.added)
{
parentById[n.id] = n.parentId;
}
const uint32_t listId = listIds.front();
for (auto id : listItemIds)
{
CAPTURE(id);
REQUIRE(parentById.count(id) == 1);
CHECK(parentById[id] == static_cast<int32_t>(listId));
}
// (2) Cross-check against the in-memory SemanticNode tree: each
// listItem must have a list-role ancestor reachable via parent()
// pointers. This catches regressions where the diff happens to
// look right but the underlying tree is misparented.
for (auto id : listItemIds)
{
CAPTURE(id);
auto* node = mgr->nodeById(id);
REQUIRE(node != nullptr);
CHECK(hasAncestorWithRole(node, SemanticRole::list));
}
}
TEST_CASE("enableSemantics called twice doesn't duplicate list-item SD entries",
"[semantics][artboard]")
{
auto file = ReadRiveFile("assets/semantic/data_binding_lists.riv");
auto artboard = file->artboardDefault();
auto sm = artboard->stateMachineAt(0);
sm->enableSemantics();
sm->enableSemantics(); // second call should be a no-op
auto vmi = file->createDefaultViewModelInstance(artboard.get());
if (vmi != nullptr)
{
artboard->bindViewModelInstance(vmi);
sm->bindViewModelInstance(vmi);
}
for (int i = 0; i < 10; ++i)
{
sm->advanceAndApply(0.1f);
}
auto* mgr = sm->semanticManager();
REQUIRE(mgr != nullptr);
const auto diff = mgr->drainDiff();
// Count unique ids for each fandom label in the initial added diff.
// Duplicate enableSemantics would show up as duplicate added entries.
std::set<uint32_t> fandomIds;
for (const auto& n : diff.added)
{
if (n.role == static_cast<uint32_t>(SemanticRole::text) &&
kFandomLabels.count(n.label) > 0)
{
fandomIds.insert(n.id);
}
}
CHECK(fandomIds.size() == kFandomLabels.size());
}