blob: 654f98626ca06d6cd4cea7fdcf251e00b2ed83a7 [file]
/*
* Copyright 2026 Rive
*/
// Data-driven conformance over the generated layout matrix. Every .riv in
// assets/layout/matrix has an .expect sibling; see that folder's README.
// Adding a fixture needs no change here.
#include "rive/artboard.hpp"
#include "rive/artboard_component_list.hpp"
#include "rive/nested_artboard_layout.hpp"
#include "rive/node.hpp"
#include "rive/layout/layout_node_provider.hpp"
#include "rive/shapes/shape.hpp"
#include "rive/layout_component.hpp"
#include "rive/transform_component.hpp"
#include "rive/world_transform_component.hpp"
#include "rive/math/mat2d.hpp"
#include "rive/assets/file_asset.hpp"
#include "rive/decoders/bitmap_decoder.hpp"
#include "rive/renderer.hpp"
#include "utils/no_op_factory.hpp"
#include "rive/simple_array.hpp"
#include "rive/file_asset_loader.hpp"
#include "rive_file_reader.hpp"
#include "rive_testing.hpp"
#include <cmath>
#include <catch.hpp>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <iterator>
#include <vector>
namespace
{
struct Check
{
bool isLegacy = false;
bool isKnownDefect = false;
std::string component;
std::string field;
std::vector<float> values;
};
struct Manifest
{
std::string fixture;
std::string artboard;
std::string knownDefect;
std::vector<Check> checks;
};
// One .riv per asset set, one artboard per cell, so `F` starts a block.
std::vector<Manifest> parseManifests(const std::string& path)
{
std::vector<Manifest> out;
std::ifstream in(path);
REQUIRE(in.good());
std::string line;
while (std::getline(in, line))
{
if (line.empty() || line[0] == '#')
{
continue;
}
std::istringstream ls(line);
std::string tag;
ls >> tag;
if (tag == "F")
{
out.emplace_back();
ls >> out.back().fixture;
continue;
}
REQUIRE(!out.empty());
Manifest& m = out.back();
if (tag == "A")
{
ls >> m.artboard;
}
else if (tag == "D")
{
std::getline(ls, m.knownDefect);
}
else if (tag == "C" || tag == "L" || tag == "X")
{
Check c;
c.isLegacy = tag == "L";
c.isKnownDefect = tag == "X";
ls >> c.component >> c.field;
float v;
while (ls >> v)
{
c.values.push_back(v);
}
m.checks.push_back(c);
}
}
return out;
}
std::vector<std::string> manifests()
{
std::vector<std::string> out;
const std::string dir = "assets/layout/matrix";
for (const auto& entry : std::filesystem::directory_iterator(dir))
{
if (entry.path().extension() == ".expect")
{
out.push_back(entry.path().string());
}
}
std::sort(out.begin(), out.end());
return out;
}
// The tolerance the positive assertions use. Every other comparison has to use
// the same one per component: summing deltas lets several within-tolerance
// differences add up past it, which would read a corrected value as still
// wrong.
constexpr float kMargin = 1e-3f;
// Whether every component is within kMargin. A size mismatch is never a match.
bool matchesWithin(const std::vector<float>& actual,
const std::vector<float>& expected)
{
if (actual.size() != expected.size())
{
return false;
}
for (size_t i = 0; i < actual.size(); i++)
{
const float d = std::abs(actual[i] - expected[i]);
// Tested for non-finiteness first: `NaN > kMargin` is false, so a NaN
// would fall through every guard and report as a match.
if (!std::isfinite(d) || d > kMargin)
{
return false;
}
}
return true;
}
// Returns false for a field this component type cannot report; the caller
// counts those rather than passing them silently.
bool readField(rive::Component* component,
rive::LayoutComponent* container,
const std::string& field,
std::vector<float>& out)
{
if (field == "collected")
{
// Asked directly rather than inferred from a solved slot.
out = {container->collectsForLayout(component) ? 1.0f : 0.0f};
return true;
}
if (field == "world")
{
if (!component->is<rive::WorldTransformComponent>())
{
return false;
}
const rive::Mat2D& w =
component->as<rive::WorldTransformComponent>()->worldTransform();
out = {w[0], w[1], w[2], w[3], w[4], w[5]};
return true;
}
if (field == "contentSize")
{
// What the layout content-sized this non-participant to. Its own local
// bounds, not a slot -- it has no layout node.
if (!component->is<rive::TransformComponent>())
{
return false;
}
auto b = component->as<rive::TransformComponent>()->localBounds();
out = {b.width(), b.height()};
return true;
}
// A list takes no slot of its own; what the layout decided is per item.
if (component->is<rive::ArtboardComponentList>())
{
auto* list = component->as<rive::ArtboardComponentList>();
if (field == "itemCount")
{
out = {static_cast<float>(list->artboardCount())};
return true;
}
if (field.size() > 4 && field.compare(0, 4, "item") == 0)
{
const int index = field[4] - '0';
const std::string what = field.substr(5);
if (index < 0 || index >= (int)list->artboardCount())
{
return false;
}
if (what == "Bounds")
{
auto b = list->layoutBoundsForNode(index);
out = {b.left(), b.top(), b.width(), b.height()};
return true;
}
if (what == "Pos")
{
auto p = list->itemPosition(index);
out = {p.x, p.y};
return true;
}
}
}
// Structural: with the same scale type on both axes a row and a column
// give the same box, so geometry cannot see a wrong main axis.
if (field == "isRow" || field == "isStack")
{
const bool wantRow = field == "isRow";
if (component->is<rive::NestedArtboardLayout>())
{
auto* n = component->as<rive::NestedArtboardLayout>();
out = {(wantRow ? n->isRow() : n->isStack()) ? 1.0f : 0.0f};
return true;
}
if (component->is<rive::ArtboardComponentList>())
{
auto* l = component->as<rive::ArtboardComponentList>();
out = {(wantRow ? l->mainAxisIsRow() : l->isStack()) ? 1.0f : 0.0f};
return true;
}
return false;
}
// The read path data binding exposes; must agree with the transform and
// bounds above. Anchored on a LayoutComponent, plain elsewhere.
if (field.compare(0, 8, "computed") == 0)
{
if (!component->is<rive::Node>())
{
return false;
}
auto* node = component->as<rive::Node>();
if (field == "computedLocal")
{
out = {node->computedLocalX(), node->computedLocalY()};
return true;
}
if (field == "computedWorld")
{
out = {node->computedWorldX(), node->computedWorldY()};
return true;
}
if (field == "computedRoot")
{
out = {node->computedRootX(), node->computedRootY()};
return true;
}
if (field == "computedSize")
{
out = {node->computedWidth(), node->computedHeight()};
return true;
}
return false;
}
// Bounds live on three unrelated declarations, so each is read where it
// actually exists.
auto emit = [&out](rive::AABB b) {
out = {b.left(), b.top(), b.width(), b.height()};
return true;
};
if (field == "layoutBounds")
{
if (component->is<rive::LayoutComponent>())
{
return emit(component->as<rive::LayoutComponent>()->layoutBounds());
}
if (auto* provider = rive::LayoutNodeProvider::from(component))
{
return emit(provider->layoutBounds());
}
return false;
}
if (field == "localBounds")
{
if (!component->is<rive::TransformComponent>())
{
return false;
}
return emit(component->as<rive::TransformComponent>()->localBounds());
}
if (field == "worldBounds")
{
if (component->is<rive::LayoutComponent>())
{
return emit(component->as<rive::LayoutComponent>()->worldBounds());
}
if (component->is<rive::Shape>())
{
return emit(component->as<rive::Shape>()->worldBounds());
}
return false;
}
return false;
}
// ImageAsset serialises no dimensions and NoOpFactory::decodeImage returns
// nullptr, which would size every Image cell to zero. Decodes dimensions only.
class SizedImage : public rive::RenderImage
{
public:
SizedImage(int w, int h)
{
m_Width = w;
m_Height = h;
}
};
class MatrixFactory : public rive::NoOpFactory
{
public:
rive::rcp<rive::RenderImage> decodeImage(
rive::Span<const uint8_t> bytes) override
{
auto bitmap = Bitmap::decode(bytes.data(), bytes.size());
if (bitmap == nullptr)
{
return nullptr;
}
return rive::make_rcp<SizedImage>(static_cast<int>(bitmap->width()),
static_cast<int>(bitmap->height()));
}
};
// Corpus assets are referenced, not embedded; resolve them from the runtime's
// own test assets so a 1.26 MB font stays out of the corpus.
class MatrixAssetLoader : public rive::FileAssetLoader
{
public:
// Asserted below: a declared referenced asset must actually resolve, or
// Text cells pass whether or not the font loaded.
int resolved = 0;
int attempted = 0;
bool loadContents(rive::FileAsset& asset,
rive::Span<const uint8_t> inBandBytes,
rive::Factory* factory) override
{
attempted++;
for (const auto& path :
{"assets/" + asset.name() + "." + asset.fileExtension(),
"assets/" + asset.uniqueFilename()})
{
std::ifstream in(path, std::ios::binary);
if (!in.good())
{
continue;
}
std::vector<uint8_t> raw((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
rive::SimpleArray<uint8_t> bytes(raw.data(), raw.size());
if (asset.decode(bytes, factory))
{
resolved++;
return true;
}
return false;
}
return false;
}
};
std::string join(const std::vector<float>& v)
{
std::ostringstream os;
for (size_t i = 0; i < v.size(); i++)
{
os << (i ? ", " : "") << v[i];
}
return os.str();
}
} // namespace
TEST_CASE("the generated layout matrix conforms", "[layout]")
{
auto paths = manifests();
REQUIRE(!paths.empty());
size_t checked = 0;
size_t knownDefectsConfirmed = 0;
int assetsAttempted = 0;
int assetsResolved = 0;
std::vector<std::string> unknownFields;
for (const auto& path : paths)
{
auto rivPath = path.substr(0, path.size() - strlen(".expect")) + ".riv";
MatrixAssetLoader loader;
MatrixFactory factory;
auto file = ReadRiveFile(rivPath.c_str(), &factory, &loader);
INFO("file " << rivPath);
CHECK(loader.resolved == loader.attempted);
// Counts are reported too: resolved == attempted also holds when
// nothing was attempted.
assetsAttempted += loader.attempted;
assetsResolved += loader.resolved;
for (const auto& manifest : parseManifests(path))
{
INFO("fixture " << manifest.fixture);
auto artboard = file->artboardNamed(manifest.artboard);
REQUIRE(artboard != nullptr);
// A list gets its items from a data bind; unbound it reports zero.
auto viewModelInstance =
file->createDefaultViewModelInstance(artboard.get());
if (viewModelInstance != nullptr)
{
artboard->bindViewModelInstance(viewModelInstance);
}
artboard->advance(0.0f);
// With no container the artboard IS the layout the subject sits in,
// and the one that collects it.
rive::LayoutComponent* container =
artboard->find<rive::LayoutComponent>("Container");
if (container == nullptr)
{
container = artboard.get();
}
REQUIRE(container != nullptr);
for (const auto& check : manifest.checks)
{
INFO("fixture " << manifest.fixture << " component "
<< check.component << " field " << check.field);
auto* component =
artboard->find<rive::Component>(check.component);
REQUIRE(component != nullptr);
std::vector<float> actual;
if (!readField(component, container, check.field, actual))
{
// Non-fatal so the rest of the corpus still runs, but a
// field this runner cannot read is a generated assertion
// silently going missing -- never a pass.
unknownFields.push_back(check.field);
FAIL_CHECK("no reader for field "
<< check.field << " (component "
<< check.component << ", fixture "
<< manifest.fixture
<< ") -- add one to readField()");
continue;
}
REQUIRE(actual.size() == check.values.size());
if (check.isKnownDefect)
{
// The recorded value is the correct one and the assertion
// is inverted: a match means it was fixed, so clear the
// marker.
if (matchesWithin(actual, check.values))
{
// Non-fatal so one run reports the whole corpus.
FAIL_CHECK(
"known defect no longer reproduces ("
<< manifest.knownDefect
<< ") -- clear knownDefect in the spec. fixture "
<< manifest.fixture << " " << check.component << "."
<< check.field << " actual [" << join(actual)
<< "] == correct [" << join(check.values) << "]");
}
else
{
WARN("known defect confirmed ("
<< manifest.knownDefect << ") in "
<< manifest.fixture << ": " << check.component
<< "." << check.field << " is [" << join(actual)
<< "] but should be [" << join(check.values)
<< "]");
knownDefectsConfirmed++;
}
checked++;
continue;
}
if (check.isLegacy)
{
// Assert the value differs from what a wrong one would
// give.
CHECK(!matchesWithin(actual, check.values));
}
else
{
for (size_t i = 0; i < actual.size(); i++)
{
INFO("index " << i);
// CHECK, not REQUIRE, so one run reports every
// mismatch.
CHECK(actual[i] ==
Approx(check.values[i]).margin(kMargin));
}
}
checked++;
}
}
}
// Guards against the corpus quietly checking nothing.
REQUIRE(checked > 0);
WARN("layout matrix: " << paths.size() << " files, " << checked
<< " fields checked, " << unknownFields.size()
<< " not yet readable by this runner, "
<< knownDefectsConfirmed
<< " known defects still reproducing, "
<< assetsResolved << "/" << assetsAttempted
<< " referenced assets resolved");
}