xo-umbrella2/xo-printjson/utest/ObjectSlotJson.test.cpp
Roland Conybeare e8b8653c47
All checks were successful
cmake-docker / cmake-build (clang, clang++) (push) Successful in 42m54s
cmake-docker / cmake-build (gcc, g++) (push) Successful in 42m4s
CI / smoke-test (push) Successful in 28m23s
xo-printjson: ObjectSlot utest patches for clang [UTEST]
2026-09-26 09:45:03 -04:00

331 lines
14 KiB
C++

/* file ObjectSlotJson.test.cpp
*
* author: Roland Conybeare, Sep 2026
*
* json rendering for xo::facet::ObjectSlot -- a flywheel root slot.
*
* The shape to keep in view: an ObjectSlot is an erased fop, so reflection
* CANNOT describe it. FopTdx's erased path rotates through AReflectable to
* render the representation's contents, and throws for a representation that
* has not opted in. ObjectSlot escapes that only because it DERIVES from
* obj<ATop> rather than aliasing it, so it does not match
* EstablishTdx<obj<AFacet,DRepr>> and reflects as an opaque atom instead --
* which a bespoke printer then takes over. That is what the shim is for, and
* `slot-does-not-take-the-erased-fop-path' below is what pins it.
*
* Expectations are OBSERVED, never predicted.
*/
#include "printjson_utest_appcx.hpp"
#include "xo/printjson/PrintJson.hpp"
#include "xo/printjson/init_printjson.hpp"
#include <xo/reflectable2/FopTdx.hpp>
#include <xo/reflectable2/Reflectable.hpp>
#include <xo/facet/handlestore/ObjectSlot.hpp>
#include <xo/facet/handlestore/DHandleStore.hpp>
#include <xo/facet/AllocFlywheel.hpp>
#include <xo/facet/TypeRegistry.hpp>
#include <xo/facet/FacetRegistry.hpp>
#include <xo/printable2/detail/APrintable.hpp>
#include <xo/printable2/detail/IPrintable_Any.hpp>
#include <xo/printable2/detail/IPrintable_Xfer.hpp>
#include <xo/printable2/detail/RPrintable.hpp>
#include <xo/arena/DArena.hpp>
#include <xo/reflect/Reflect.hpp>
#include <catch2/catch.hpp>
#include <sstream>
#include <string>
namespace xo {
using xo::json::PrintJson;
using xo::reflect::Metatype;
using xo::reflect::Reflect;
namespace ut {
namespace {
/** throwaway representation, printable so an ObjectSlot can be
* built the way DObjectHandle::make_strong_ref builds one.
*
* NB being in an anonymous namespace puts an internal-linkage
* marker in the name TypeRegistry reports.
* gcc/clang spell it differently.
*
* @code
* g++: xo::ut::{anonymous}::DSlotProbe
* clang++: xo::ut::(anonymous namespace)::DSlotProbe
* @endcode
*
* Test assertions use expect_type_key() instead of assuming
* a fixed spelling.
**/
struct DSlotProbe {
double value_;
};
class IPrintable_DSlotProbe {
public:
using PpSink = xo::print::APrintable::PpSink;
static void pretty(const DSlotProbe & self, PpSink & sink) {
sink.pp(self.value_);
}
};
}
} /*namespace ut*/
namespace facet {
template <>
struct FacetImplementation<xo::print::APrintable, xo::ut::DSlotProbe> {
using ImplType = xo::print::IPrintable_Xfer<xo::ut::DSlotProbe,
xo::ut::IPrintable_DSlotProbe>;
};
}
namespace ut {
using xo::facet::ObjectSlot;
using xo::facet::AllocFlywheel;
using xo::facet::DHandleStoreBase;
using xo::facet::FacetRegistry;
using xo::print::APrintable;
using xo::mm::ArenaConfig;
using xo::mm::ArenaNameStr;
namespace {
void require_registered() {
static bool s_once = []() {
FacetRegistry::register_impl<APrintable, DSlotProbe>();
return true;
}();
(void)s_once;
}
/** a flywheel, whose storage arena is header-enabled and aligned
* on the one agreed base alignment.
*
* Was a bare DArena until 2026-09-24. A non-empty ObjectSlot can
* now only be made by a DHandleStore, so a test that wants one
* has to have a store -- which is the point, since a storeless
* slot is exactly the unvouched-for state the change removes.
**/
rp<AllocFlywheel> make_flywheel(const char * tag) {
ArenaConfig storage_cfg{ .name_ = ArenaNameStr::sprintf("%s.storage", tag),
.size_ = 16*1024 };
ArenaConfig strong_cfg { .name_ = ArenaNameStr::sprintf("%s.strong", tag),
.size_ = 4*1024 };
return AllocFlywheel::make_app(printjson_utest_facet_appcx(),
storage_cfg, strong_cfg);
}
/** allocate a DSlotProbe from @p fw and adopt it as a root,
* exactly as DObjectHandle::make_strong_ref does.
*
* @return a COPY of the slot the store made. The store keeps the
* original; nothing here releases it, so the copy stays valid for
* the life of @p fw.
**/
ObjectSlot root(AllocFlywheel & fw, double x) {
auto * mem = fw.storage().alloc(xo::reflect::typeseq::id<DSlotProbe>(),
sizeof(DSlotProbe));
REQUIRE(mem);
auto * p = new (mem) DSlotProbe{x};
auto typed = xo::facet::with_facet<APrintable>::mkobj(p);
auto ref = fw.add_strong_ref
(static_cast<const xo::facet::ATop *>(typed.iface()),
typed.opaque_data());
REQUIRE(ref.second);
return *ref.second;
}
std::string render(PrintJson & pj, const ObjectSlot & slot) {
std::stringstream ss;
pj.print(slot, &ss);
return ss.str();
}
/** Expect to see this key when json-printing an ObjectSlot;
* spelling is compiler-dependent.
**/
template <typename T>
std::string expect_type_key() {
return (std::string("\"type\": \"")
+ std::string(xo::reflect::type_name<T>())
+ '"');
}
}
/* the restriction itself, checked by the compiler. is_constructible
* respects access, so this fails the moment the ctor goes public
* again -- which is the whole of .xo-backlog/xo-facet/issues/04's
* first claim.
*/
static_assert(!std::is_constructible_v<ObjectSlot,
const xo::facet::ATop *, void *>,
"a non-empty ObjectSlot must only be constructible by"
" DHandleStore -- see ObjectSlot's Provenance note");
/* ..and the empty one stays available to anybody */
static_assert(std::is_default_constructible_v<ObjectSlot>);
TEST_CASE("slot-does-not-take-the-erased-fop-path", "[printjson][ObjectSlot]")
{
/* the property the shim exists for. obj<ATop> reflects through
* FopTdx as a POINTER, whose erased branch rotates to AReflectable
* and throws for an unregistered representation. ObjectSlot is a
* distinct type, so it gets the unreflected default instead -- an
* atom -- and the bespoke printer keys on that.
*
* If this ever reads mt_pointer, ObjectSlot has become an alias
* again and every render below is one throw away.
*/
REQUIRE(Reflect::require<ObjectSlot>()->metatype() == Metatype::mt_atomic);
REQUIRE(Reflect::require<xo::facet::obj<xo::facet::ATop>>()->metatype()
== Metatype::mt_pointer);
} /*TEST_CASE(slot-does-not-take-the-erased-fop-path)*/
TEST_CASE("empty-slot-renders-as-null", "[printjson][ObjectSlot]")
{
require_registered();
PrintJson pj;
ObjectSlot empty;
/* null rather than omitted: slots are emitted including the empty
* ones, so a consumer reads a slot's index from its position in
* the enclosing array
*/
REQUIRE(render(pj, empty) == std::string("null"));
} /*TEST_CASE(empty-slot-renders-as-null)*/
TEST_CASE("occupied-slot-reports-identity-offset-and-size",
"[printjson][ObjectSlot]")
{
require_registered();
PrintJson pj;
auto fw = make_flywheel("utest.slot.one");
ObjectSlot s0 = root(*fw.get(), 1.5);
const std::string frame = render(pj, s0);
INFO("frame: " << frame);
REQUIRE(frame.find("\"typeseq\": ") != std::string::npos);
INFO("expected type key: " << expect_type_key<DSlotProbe>());
REQUIRE(frame.find(expect_type_key<DSlotProbe>()) != std::string::npos);
/* 16 == the arena preamble (back pointer) + one AllocHeader. The
* number is a consequence of storage being header-enabled, which
* DHandleStore requires; if it reads 0 the preamble vanished.
*/
REQUIRE(frame.find("\"offset\": 16") != std::string::npos);
/* size comes from the ALLOC HEADER, not sizeof(DSlotProbe) -- the
* two agree here only because DSlotProbe is fixed-size and needs
* no padding. Observed, not predicted.
*/
REQUIRE(frame.find("\"size\": 8") != std::string::npos);
} /*TEST_CASE(occupied-slot-reports-identity-offset-and-size)*/
TEST_CASE("offset-is-resolved-from-the-pointer-alone", "[printjson][ObjectSlot]")
{
/* the point of the maskable base alignment: a slot knows where it
* sits WITHOUT anyone handing it an arena. Two arenas, and each
* slot's offset is relative to its OWN -- which a printer that
* captured a single base could not do.
*
* This is also what makes store-only slot creation worth the
* trouble rather than just passing the arena down the print tree:
* the slot is self-describing, so every consumer of
* visit_object_slots gets the same answer without plumbing.
*/
require_registered();
PrintJson pj;
auto fw1 = make_flywheel("utest.slot.a1");
auto fw2 = make_flywheel("utest.slot.a2");
/* fw2's first object is at the same offset as fw1's, from a
* DIFFERENT base -- so equal offsets here mean each was resolved
* against its own arena
*/
ObjectSlot s1 = root(*fw1.get(), 1.5);
ObjectSlot s2 = root(*fw2.get(), 2.5);
REQUIRE(render(pj, s1).find("\"offset\": 16") != std::string::npos);
REQUIRE(render(pj, s2).find("\"offset\": 16") != std::string::npos);
/* second object in fw1 moves on; fw2 is untouched by it */
ObjectSlot s3 = root(*fw1.get(), 3.5);
REQUIRE(render(pj, s3).find("\"offset\": 16") == std::string::npos);
REQUIRE(render(pj, s2).find("\"offset\": 16") != std::string::npos);
} /*TEST_CASE(offset-is-resolved-from-the-pointer-alone)*/
TEST_CASE("a-store-refuses-a-foreign-pointer", "[printjson][ObjectSlot]")
{
/* the runtime half of the provenance contract. add_strong_ref's
* precondition -- "x refers to memory owned by storage_" -- was a
* doc comment until 2026-09-24; now the store checks it, which is
* what lets the resulting slot vouch for itself.
*
* Allocate from ONE flywheel, offer it to ANOTHER. The pointer is
* perfectly valid; it just did not come from the store being asked
* to adopt it.
*/
require_registered();
auto fw1 = make_flywheel("utest.slot.owner");
auto fw2 = make_flywheel("utest.slot.stranger");
auto * mem = fw1->storage().alloc(xo::reflect::typeseq::id<DSlotProbe>(),
sizeof(DSlotProbe));
REQUIRE(mem);
auto * p = new (mem) DSlotProbe{1.5};
auto typed = xo::facet::with_facet<APrintable>::mkobj(p);
auto * iface = static_cast<const xo::facet::ATop *>(typed.iface());
REQUIRE_THROWS(fw2->add_strong_ref(iface, typed.opaque_data()));
/* and the rightful owner takes it */
REQUIRE_NOTHROW(fw1->add_strong_ref(iface, typed.opaque_data()));
} /*TEST_CASE(a-store-refuses-a-foreign-pointer)*/
TEST_CASE("storage-base-align-is-write-once", "[printjson][ObjectSlot]")
{
/* the other half. A slot's offset is masked with this value at
* PRINT time, long after the store that validated an arena against
* it was built -- so a silent reassignment would recover the wrong
* arena and nothing would notice.
*
* Replaces `slot-without-an-agreed-alignment-reports-null-offset',
* which set the value to 0 to exercise the printer's null-offset
* branch. That branch is now unreachable for a slot that exists
* (a slot implies a store, a store implies a non-zero value, and
* the value cannot change), so the test that reached it has become
* the test that it CANNOT be reached.
*/
const std::size_t in_force = DHandleStoreBase::storage_base_align();
REQUIRE(in_force != 0);
/* idempotent: several FacetAppcx agreeing is fine */
REQUIRE_NOTHROW(DHandleStoreBase::assign_storage_base_align(in_force));
REQUIRE_THROWS(DHandleStoreBase::assign_storage_base_align(0));
REQUIRE_THROWS(DHandleStoreBase::assign_storage_base_align(in_force * 2));
/* refused, not quietly recorded */
REQUIRE(DHandleStoreBase::storage_base_align() == in_force);
} /*TEST_CASE(storage-base-align-is-write-once)*/
} /*namespace ut*/
} /*namespace xo*/
/* end ObjectSlotJson.test.cpp */