| /* |
| * 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"); |
| } |