blob: 502c035050ed6639b3ebe9fea587867e034e1e8c [file]
#include "rive/animation/state_machine_input_instance.hpp"
#include "rive/animation/state_machine_instance.hpp"
#include "rive/artboard_component_list.hpp"
#include "rive/constraints/scrolling/scroll_constraint.hpp"
#include "rive/layout/layout_component_style.hpp"
#include "rive/math/transform_components.hpp"
#include "rive/shapes/rectangle.hpp"
#include "rive/text/text.hpp"
#include "rive/viewmodel/viewmodel_instance_artboard.hpp"
#include "rive/viewmodel/viewmodel_instance_boolean.hpp"
#include "rive/viewmodel/viewmodel_instance_color.hpp"
#include "rive/viewmodel/viewmodel_instance_enum.hpp"
#include "rive/viewmodel/viewmodel_instance_list.hpp"
#include "rive/viewmodel/viewmodel_instance_list_item.hpp"
#include "rive/viewmodel/viewmodel_instance_number.hpp"
#include "rive/viewmodel/viewmodel_instance_string.hpp"
#include "rive/viewmodel/viewmodel_instance_symbol_list_index.hpp"
#include "rive/viewmodel/viewmodel_instance_trigger.hpp"
#include "rive/assets/file_asset.hpp"
#include "rive/nested_artboard.hpp"
#include "utils/no_op_factory.hpp"
#include "utils/serializing_factory.hpp"
#include "rive_file_reader.hpp"
#include "rive_testing.hpp"
#include <catch.hpp>
#include <cstdio>
TEST_CASE("Stateful Component ViewModelInstance", "[silver]")
{
rive::SerializingFactory silver;
auto file =
ReadRiveFile("assets/component_stateful_vm_instance.riv", &silver);
auto artboard = file->artboardNamed("ParentArtboard");
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
int viewModelId = artboard.get()->viewModelId();
auto vmi = viewModelId == -1
? file->createViewModelInstance(artboard.get())
: file->createViewModelInstance(viewModelId, 0);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.1f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
auto heightProperty = vmi->propertyValue("h");
REQUIRE(heightProperty != nullptr);
REQUIRE(heightProperty->is<rive::ViewModelInstanceNumber>());
heightProperty->as<rive::ViewModelInstanceNumber>()->propertyValue(200);
int frames = 30;
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
heightProperty->as<rive::ViewModelInstanceNumber>()->propertyValue(50);
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
CHECK(silver.matches("component_stateful_vm_instance"));
}
TEST_CASE("Stateful Component ViewModelInstance multi", "[silver]")
{
rive::SerializingFactory silver;
auto file =
ReadRiveFile("assets/component_stateful_vm_instance_2.riv", &silver);
auto artboard = file->artboardNamed("Artboard");
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
int viewModelId = artboard.get()->viewModelId();
auto vmi = viewModelId == -1
? file->createViewModelInstance(artboard.get())
: file->createViewModelInstance(viewModelId, 0);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.1f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
auto labelProperty = vmi->propertyValue("label");
REQUIRE(labelProperty != nullptr);
REQUIRE(labelProperty->is<rive::ViewModelInstanceString>());
int frames = 30;
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
labelProperty->as<rive::ViewModelInstanceString>()->propertyValue(
"Override");
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
CHECK(silver.matches("component_stateful_vm_instance_2"));
}
TEST_CASE("Stateful Component multi-property independence", "[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/stateful_multi_property.riv", &silver);
auto artboard = file->artboardNamed("Main");
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
int viewModelId = artboard.get()->viewModelId();
auto vmi = viewModelId == -1
? file->createViewModelInstance(artboard.get())
: file->createViewModelInstance(viewModelId, 0);
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.1f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
// Sanity: all parent props exist with expected types.
auto btn1Count = vmi->propertyValue("btn1Count");
auto btn1Tint = vmi->propertyValue("btn1Tint");
auto btn1Label = vmi->propertyValue("btn1Label");
auto btn1Clip = vmi->propertyValue("btn1Clip");
auto btn1Display = vmi->propertyValue("btn1Display");
auto btn2Count = vmi->propertyValue("btn2Count");
auto btn2Tint = vmi->propertyValue("btn2Tint");
auto btn2Label = vmi->propertyValue("btn2Label");
auto btn2Clip = vmi->propertyValue("btn2Clip");
auto btn2Display = vmi->propertyValue("btn2Display");
REQUIRE(btn1Count != nullptr);
REQUIRE(btn1Count->is<rive::ViewModelInstanceNumber>());
REQUIRE(btn1Tint != nullptr);
REQUIRE(btn1Tint->is<rive::ViewModelInstanceColor>());
REQUIRE(btn1Label != nullptr);
REQUIRE(btn1Label->is<rive::ViewModelInstanceString>());
REQUIRE(btn1Clip != nullptr);
REQUIRE(btn1Clip->is<rive::ViewModelInstanceBoolean>());
REQUIRE(btn1Display != nullptr);
REQUIRE(btn1Display->is<rive::ViewModelInstanceEnum>());
REQUIRE(btn2Count != nullptr);
REQUIRE(btn2Count->is<rive::ViewModelInstanceNumber>());
REQUIRE(btn2Tint != nullptr);
REQUIRE(btn2Tint->is<rive::ViewModelInstanceColor>());
REQUIRE(btn2Label != nullptr);
REQUIRE(btn2Label->is<rive::ViewModelInstanceString>());
REQUIRE(btn2Clip != nullptr);
REQUIRE(btn2Clip->is<rive::ViewModelInstanceBoolean>());
REQUIRE(btn2Display != nullptr);
REQUIRE(btn2Display->is<rive::ViewModelInstanceEnum>());
auto runFrames = [&](int frames) {
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
};
// Drive btn1 properties one by one — btn2 should remain unchanged
// throughout. Each segment isolates a single property type so the
// snapshot makes the binding visible per-type.
btn1Count->as<rive::ViewModelInstanceNumber>()->propertyValue(180);
runFrames(5);
btn1Tint->as<rive::ViewModelInstanceColor>()->propertyValue(0xFFFF3344);
runFrames(5);
btn1Label->as<rive::ViewModelInstanceString>()->propertyValue("One");
runFrames(5);
btn1Clip->as<rive::ViewModelInstanceBoolean>()->propertyValue(true);
runFrames(5);
btn1Display->as<rive::ViewModelInstanceEnum>()->value(1);
runFrames(5);
// Now drive btn2 — btn1 should keep its modified values from above.
btn2Count->as<rive::ViewModelInstanceNumber>()->propertyValue(60);
runFrames(5);
btn2Tint->as<rive::ViewModelInstanceColor>()->propertyValue(0xFF33AAFF);
runFrames(5);
btn2Label->as<rive::ViewModelInstanceString>()->propertyValue("Two");
runFrames(5);
btn2Clip->as<rive::ViewModelInstanceBoolean>()->propertyValue(true);
runFrames(5);
btn2Display->as<rive::ViewModelInstanceEnum>()->value(1);
runFrames(5);
CHECK(silver.matches("stateful_multi_property"));
}
TEST_CASE("Stateful Component nested in stateful", "[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/stateful_nested.riv", &silver);
auto artboard = file->artboardNamed("Main");
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
int viewModelId = artboard.get()->viewModelId();
auto vmi = viewModelId == -1
? file->createViewModelInstance(artboard.get())
: file->createViewModelInstance(viewModelId, 0);
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.1f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
auto btn1Label = vmi->propertyValue("btn1Label");
auto btn2Label = vmi->propertyValue("btn2Label");
auto btn1Tint = vmi->propertyValue("btn1Tint");
auto btn2Tint = vmi->propertyValue("btn2Tint");
REQUIRE(btn1Label != nullptr);
REQUIRE(btn1Label->is<rive::ViewModelInstanceString>());
REQUIRE(btn2Label != nullptr);
REQUIRE(btn2Label->is<rive::ViewModelInstanceString>());
REQUIRE(btn1Tint != nullptr);
REQUIRE(btn1Tint->is<rive::ViewModelInstanceColor>());
REQUIRE(btn2Tint != nullptr);
REQUIRE(btn2Tint->is<rive::ViewModelInstanceColor>());
auto runFrames = [&](int frames) {
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
};
// Drive each property in isolation. Each segment exercises a single
// input on the outer Main VM that is wired down through MultiButton's
// stateful boundary into one of its two inner Button instances.
btn1Label->as<rive::ViewModelInstanceString>()->propertyValue("One");
runFrames(5);
btn1Tint->as<rive::ViewModelInstanceColor>()->propertyValue(0xFFFF3344);
runFrames(5);
btn2Label->as<rive::ViewModelInstanceString>()->propertyValue("Two");
runFrames(5);
btn2Tint->as<rive::ViewModelInstanceColor>()->propertyValue(0xFF33AAFF);
runFrames(5);
CHECK(silver.matches("stateful_nested"));
}
/*
// Disable this test for now. Initially we decided not to create stateful
// VM instances for each list item to reduce overhead. This may change in
// the future and we can revisit this test at that point.
TEST_CASE("Stateful Component list with input/output bridge binds", "[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/stateful_list_props.riv", &silver);
auto artboard = file->artboardNamed("Main");
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
int viewModelId = artboard.get()->viewModelId();
auto vmi = viewModelId == -1
? file->createViewModelInstance(artboard.get())
: file->createViewModelInstance(viewModelId, 0);
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.0f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
auto buttonsProp = vmi->propertyValue("buttons");
REQUIRE(buttonsProp != nullptr);
REQUIRE(buttonsProp->is<rive::ViewModelInstanceList>());
auto buttonsList = buttonsProp->as<rive::ViewModelInstanceList>();
REQUIRE(buttonsList != nullptr);
// Helper: build a fresh ButtonVM instance with the given inputs.
auto makeButton =
[&](float count, uint32_t tint, const std::string& label, bool clip) {
auto inst = file->createViewModelInstance("ButtonVM");
REQUIRE(inst != nullptr);
auto pCount = inst->propertyValue("count");
auto pTint = inst->propertyValue("tint");
auto pLabel = inst->propertyValue("label");
auto pClip = inst->propertyValue("clipped");
REQUIRE(pCount != nullptr);
REQUIRE(pTint != nullptr);
REQUIRE(pLabel != nullptr);
REQUIRE(pClip != nullptr);
REQUIRE(pCount->is<rive::ViewModelInstanceNumber>());
REQUIRE(pTint->is<rive::ViewModelInstanceColor>());
REQUIRE(pLabel->is<rive::ViewModelInstanceString>());
REQUIRE(pClip->is<rive::ViewModelInstanceBoolean>());
pCount->as<rive::ViewModelInstanceNumber>()->propertyValue(count);
pTint->as<rive::ViewModelInstanceColor>()->propertyValue(tint);
pLabel->as<rive::ViewModelInstanceString>()->propertyValue(label);
pClip->as<rive::ViewModelInstanceBoolean>()->propertyValue(clip);
return inst;
};
auto addItem = [&](rive::rcp<rive::ViewModelInstance> inst) {
auto item = rive::make_rcp<rive::ViewModelInstanceListItem>();
item->viewModelInstance(inst);
buttonsList->addItem(item);
};
// Three buttons with distinct inputs. We keep handles to the
// *original* (user-supplied) ButtonVM instances — these are the ones
// whose `click` trigger is written back via the output bridge bind
// (clone -> original) when the corresponding rendered button is
// pressed.
auto button0 = makeButton(20, 0xFFFF3344, "Alpha", false);
auto button1 = makeButton(40, 0xFF33AAFF, "Beta", false);
auto button2 = makeButton(60, 0xFF44CC55, "Gamma", false);
addItem(button0);
addItem(button1);
addItem(button2);
auto runFrames = [&](int frames) {
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
};
// Settle the list with all three items visible.
runFrames(5);
auto getClickedBool = [&](rive::rcp<rive::ViewModelInstance> btn) {
auto p = btn->propertyValue("clicked");
REQUIRE(p != nullptr);
REQUIRE(p->is<rive::ViewModelInstanceBoolean>());
return p->as<rive::ViewModelInstanceBoolean>();
};
auto clicked0 = getClickedBool(button0);
auto clicked1 = getClickedBool(button1);
auto clicked2 = getClickedBool(button2);
// To observe trigger fires on the original VMs we attach a delegate.
// Reading `propertyValue()` post-frame doesn't work for triggers
// because `ViewModelInstanceTrigger::advanced()` resets the value
// back to 0 every frame — that's their normal "fired this frame"
// semantic. The delegate's `valueChanged` is invoked in
// `propertyValueChanged()`, before the reset, so it captures real
// fires.
struct FireCounter : public rive::ViewModelInstanceValueDelegate
{
int count = 0;
void valueChanged() override { count++; }
};
FireCounter fire0, fire1, fire2;
auto getClickTrigger = [&](rive::rcp<rive::ViewModelInstance> btn) {
auto p = btn->propertyValue("click");
REQUIRE(p != nullptr);
REQUIRE(p->is<rive::ViewModelInstanceTrigger>());
return p->as<rive::ViewModelInstanceTrigger>();
};
auto trigger0 = getClickTrigger(button0);
auto trigger1 = getClickTrigger(button1);
auto trigger2 = getClickTrigger(button2);
trigger0->addDelegate(&fire0);
trigger1->addDelegate(&fire1);
trigger2->addDelegate(&fire2);
// --- Input bridge: modify inputs on the originals; the cloned VMIs
// inside the list should pick up the new values. ---
button1->propertyValue("label")
->as<rive::ViewModelInstanceString>()
->propertyValue("Beta!");
runFrames(5);
button2->propertyValue("tint")
->as<rive::ViewModelInstanceColor>()
->propertyValue(0xFFFFCC00);
runFrames(5);
button0->propertyValue("count")
->as<rive::ViewModelInstanceNumber>()
->propertyValue(80);
runFrames(5);
// --- Output bridge: clicks on the rendered list items must
// propagate the `clicked` boolean back to the originals. ---
// Layout: 10px padding, ~35px button height, 10px gap, vertical.
// Button 0 center y = 10 + 35/2 = 27.5
// Button 1 center y = 10 + 35 + 10 + 35/2 = 72.5
// Button 2 center y = 10 + 2*(35+10) + 35/2 = 117.5
// Sanity: nothing has been clicked yet.
CHECK(clicked0->propertyValue() == false);
CHECK(clicked1->propertyValue() == false);
CHECK(clicked2->propertyValue() == false);
CHECK(fire0.count == 0);
CHECK(fire1.count == 0);
CHECK(fire2.count == 0);
// Click button 0 — pointerDown + pointerUp completes the click, then
// we advance once and read `clicked` and the trigger fire count.
stateMachine->pointerDown(rive::Vec2D(50.0f, 27.0f));
stateMachine->pointerUp(rive::Vec2D(50.0f, 27.0f));
stateMachine->advanceAndApply(0.016f);
CHECK(clicked0->propertyValue() == true);
CHECK(clicked1->propertyValue() == false);
CHECK(clicked2->propertyValue() == false);
int fire0After1 = fire0.count;
int fire1After1 = fire1.count;
int fire2After1 = fire2.count;
CHECK(fire0After1 > 0);
CHECK(fire1After1 == 0);
CHECK(fire2After1 == 0);
runFrames(3);
// Click button 1.
stateMachine->pointerDown(rive::Vec2D(50.0f, 73.0f));
stateMachine->pointerUp(rive::Vec2D(50.0f, 73.0f));
stateMachine->advanceAndApply(0.016f);
CHECK(clicked1->propertyValue() == true);
CHECK(fire0.count == fire0After1);
CHECK(fire1.count > fire1After1);
CHECK(fire2.count == fire2After1);
int fire0After2 = fire0.count;
int fire1After2 = fire1.count;
int fire2After2 = fire2.count;
runFrames(3);
// Click button 2.
stateMachine->pointerDown(rive::Vec2D(50.0f, 118.0f));
stateMachine->pointerUp(rive::Vec2D(50.0f, 118.0f));
stateMachine->advanceAndApply(0.016f);
CHECK(clicked2->propertyValue() == true);
CHECK(fire0.count == fire0After2);
CHECK(fire1.count == fire1After2);
CHECK(fire2.count > fire2After2);
runFrames(3);
CHECK(silver.matches("stateful_list_props"));
}
*/
TEST_CASE("Stateful Component dynamic artboard swap via VMI artboard "
"property",
"[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/stateful_artboard_swap.riv", &silver);
auto artboard = file->artboardNamed("Main");
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
int viewModelId = artboard.get()->viewModelId();
auto vmi = viewModelId == -1
? file->createViewModelInstance(artboard.get())
: file->createViewModelInstance(viewModelId, 0);
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.0f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
auto buttonArtboardProp = vmi->propertyValue("buttonArtboard");
REQUIRE(buttonArtboardProp != nullptr);
REQUIRE(buttonArtboardProp->is<rive::ViewModelInstanceArtboard>());
auto vmiArtboard =
buttonArtboardProp->as<rive::ViewModelInstanceArtboard>();
auto buttonSource = file->bindableArtboardNamed("Button");
auto strokedButtonSource = file->bindableArtboardNamed("StrokedButton");
REQUIRE(buttonSource != nullptr);
REQUIRE(strokedButtonSource != nullptr);
auto runFrames = [&](int frames) {
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
};
// Helper: navigate to the swap-target NestedArtboard's currently-bound
// stateful VMI so we can verify it was created fresh after each swap.
auto findSwappedNestedArtboard = [&]() -> rive::NestedArtboard* {
for (auto na : artboard->nestedArtboards())
{
// The swap-target NA is the one whose source is driven by
// `buttonArtboard`; the easiest way to recognize it is that
// its current source artboard matches one of the variants.
auto* src = na->sourceArtboard();
if (src != nullptr &&
(src->name() == "Button" || src->name() == "StrokedButton"))
{
return na;
}
}
return nullptr;
};
// Initial: buttonArtboard is null, so nothing is rendered in the
// swap target. Capture the empty baseline.
runFrames(2);
REQUIRE(findSwappedNestedArtboard() == nullptr);
// Swap to Button.
vmiArtboard->asset(buttonSource);
runFrames(5);
auto naAfterButton = findSwappedNestedArtboard();
REQUIRE(naAfterButton != nullptr);
REQUIRE(naAfterButton->sourceArtboard() != nullptr);
CHECK(naAfterButton->sourceArtboard()->name() == "Button");
// Swap to StrokedButton — exercises updateArtboard's dispose+rebuild
// path. The stateful artboard should change types entirely; the
// previous Button stateful VMI should be torn down.
vmiArtboard->asset(strokedButtonSource);
runFrames(5);
auto naAfterStroked = findSwappedNestedArtboard();
REQUIRE(naAfterStroked != nullptr);
REQUIRE(naAfterStroked->sourceArtboard() != nullptr);
CHECK(naAfterStroked->sourceArtboard()->name() == "StrokedButton");
// Sanity: the StrokedButton instance has its VM-specific
// `strokeWidth` property visible, but Button's VM does not.
auto strokedAbInst = naAfterStroked->artboardInstance();
REQUIRE(strokedAbInst != nullptr);
auto strokedCtx = strokedAbInst->dataContext();
REQUIRE(strokedCtx != nullptr);
auto strokedVmi = strokedCtx->viewModelInstance();
REQUIRE(strokedVmi != nullptr);
auto strokeWidthProp = strokedVmi->propertyValue("strokeWidth");
REQUIRE(strokeWidthProp != nullptr);
REQUIRE(strokeWidthProp->is<rive::ViewModelInstanceNumber>());
// Drive strokeWidth so the silver actually shows the stroke and we
// can visually verify the StrokedButton variant rendered.
strokeWidthProp->as<rive::ViewModelInstanceNumber>()->propertyValue(8);
runFrames(5);
// Swap back to Button — the previous StrokedButton stateful VMI
// (with strokeWidth) must be cleaned up; we should land back on a
// ButtonVM-shaped instance.
vmiArtboard->asset(buttonSource);
runFrames(5);
auto naAfterButton2 = findSwappedNestedArtboard();
REQUIRE(naAfterButton2 != nullptr);
REQUIRE(naAfterButton2->sourceArtboard() != nullptr);
CHECK(naAfterButton2->sourceArtboard()->name() == "Button");
auto buttonAbInst = naAfterButton2->artboardInstance();
REQUIRE(buttonAbInst != nullptr);
auto buttonCtx = buttonAbInst->dataContext();
REQUIRE(buttonCtx != nullptr);
auto buttonVmi = buttonCtx->viewModelInstance();
REQUIRE(buttonVmi != nullptr);
// Button's VM has count/tint/label/clipped/clicked/click but
// does NOT have strokeWidth.
CHECK(buttonVmi->propertyValue("count") != nullptr);
CHECK(buttonVmi->propertyValue("strokeWidth") == nullptr);
// Swap back to null — the swap target should disappear again.
// asset(nullptr) calls propertyValue(-1) internally, which together
// satisfy the cleared-state condition in NestedArtboard::updateArtboard.
vmiArtboard->asset(nullptr);
runFrames(5);
CHECK(findSwappedNestedArtboard() == nullptr);
// Swap back to Button after the null-clear. Verifies that the
// cleared-state path actually drops the cached stateful VMI — if it
// didn't, this swap would land on a VMI that was held over from the
// previous variant's lifetime instead of a freshly created one. We
// can't easily assert ptr-inequality without holding refs, so we
// settle for asserting the new VMI is valid and shaped like
// ButtonVM.
vmiArtboard->asset(buttonSource);
runFrames(5);
auto naAfterClear = findSwappedNestedArtboard();
REQUIRE(naAfterClear != nullptr);
REQUIRE(naAfterClear->sourceArtboard() != nullptr);
CHECK(naAfterClear->sourceArtboard()->name() == "Button");
auto buttonAbInst3 = naAfterClear->artboardInstance();
REQUIRE(buttonAbInst3 != nullptr);
auto buttonCtx3 = buttonAbInst3->dataContext();
REQUIRE(buttonCtx3 != nullptr);
auto buttonVmi3 = buttonCtx3->viewModelInstance();
REQUIRE(buttonVmi3 != nullptr);
CHECK(buttonVmi3->propertyValue("count") != nullptr);
CHECK(buttonVmi3->propertyValue("strokeWidth") == nullptr);
CHECK(silver.matches("stateful_artboard_swap"));
}
TEST_CASE("Stateful Component list bridge binds clean up on item remove",
"[silver]")
{
// Reuses stateful_list_props.riv. Verifies that adding and then
// removing list items doesn't leak bridge binds or crash, and that
// surviving items still propagate clicks correctly.
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/stateful_list_props.riv", &silver);
auto artboard = file->artboardNamed("Main");
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
int viewModelId = artboard.get()->viewModelId();
auto vmi = viewModelId == -1
? file->createViewModelInstance(artboard.get())
: file->createViewModelInstance(viewModelId, 0);
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.0f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
auto buttonsList =
vmi->propertyValue("buttons")->as<rive::ViewModelInstanceList>();
REQUIRE(buttonsList != nullptr);
auto makeAndAdd = [&](const std::string& label, uint32_t tint) {
auto inst = file->createViewModelInstance("ButtonVM");
REQUIRE(inst != nullptr);
inst->propertyValue("label")
->as<rive::ViewModelInstanceString>()
->propertyValue(label);
inst->propertyValue("tint")
->as<rive::ViewModelInstanceColor>()
->propertyValue(tint);
auto item = rive::make_rcp<rive::ViewModelInstanceListItem>();
item->viewModelInstance(inst);
buttonsList->addItem(item);
return inst;
};
auto runFrames = [&](int frames) {
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
};
auto buttonA = makeAndAdd("Alpha", 0xFFFF3344);
auto buttonB = makeAndAdd("Beta", 0xFF33AAFF);
auto buttonC = makeAndAdd("Gamma", 0xFF44CC55);
runFrames(3);
// Remove the middle item — Beta. Bridge binds for Beta must be torn
// down; Alpha and Gamma must keep working.
REQUIRE(buttonsList->listItems().size() == 3);
buttonsList->removeItem(1);
runFrames(5);
REQUIRE(buttonsList->listItems().size() == 2);
// Click survivor at the position now occupied by what was Gamma
// (originally index 2, now index 1 → y ≈ 73).
auto clickedC =
buttonC->propertyValue("clicked")->as<rive::ViewModelInstanceBoolean>();
auto clickedB =
buttonB->propertyValue("clicked")->as<rive::ViewModelInstanceBoolean>();
CHECK(clickedC->propertyValue() == false);
stateMachine->pointerDown(rive::Vec2D(50.0f, 73.0f));
stateMachine->pointerUp(rive::Vec2D(50.0f, 73.0f));
stateMachine->advanceAndApply(0.016f);
CHECK(clickedC->propertyValue() == true);
// Removed item's clicked must not flip — its bridge bind is gone.
CHECK(clickedB->propertyValue() == false);
runFrames(3);
// Re-add Beta at the end.
{
auto item = rive::make_rcp<rive::ViewModelInstanceListItem>();
item->viewModelInstance(buttonB);
buttonsList->addItem(item);
}
runFrames(5);
REQUIRE(buttonsList->listItems().size() == 3);
// Beta now lives at index 2 → y ≈ 118. Click should bridge-bind to
// its `clicked` again.
stateMachine->pointerDown(rive::Vec2D(50.0f, 118.0f));
stateMachine->pointerUp(rive::Vec2D(50.0f, 118.0f));
stateMachine->advanceAndApply(0.016f);
CHECK(clickedB->propertyValue() == true);
runFrames(3);
// Remove all items — must not crash, no leftover binds.
while (buttonsList->listItems().size() > 0)
{
buttonsList->removeItem(0);
}
runFrames(5);
REQUIRE(buttonsList->listItems().size() == 0);
CHECK(silver.matches("stateful_list_props_lifecycle"));
}
TEST_CASE("Stateful Component Keyed Triggers", "[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/stateful_keyed_trigger.riv", &silver);
auto artboard = file->artboardNamed("Artboard");
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
int viewModelId = artboard.get()->viewModelId();
auto vmi = viewModelId == -1
? file->createViewModelInstance(artboard.get())
: file->createViewModelInstance(viewModelId, 0);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.1f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
int frames = 60;
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
CHECK(silver.matches("stateful_keyed_trigger"));
}