LUMIERA.clone/tests/steam/engine/node-opera-test.cpp
Ichthyostega 64416e3ba8 Invocation: considerations regarding the DataSink to use
While the setup here in the test seems trivial,
this touches on some challenging questions of design:
Presumably the invocation of a Render Job will need some kind of
''Render Context'' — since the relation of the absolute nominal time
to some output frame number is strictly ''a concern of the render process''.

However, it seems prudent to keep that relation separate from the `RenderInvocation`,
so that access to all invocation parameters has to be supplied by the
Render Job Functor.

This change in the test setup now uses a `DiagnosticOutputSlot`
instead of directly allocating some buffer for output; the data
written into the `DataSink` can be investigated after-the-fact.
2026-06-28 01:41:59 +02:00

397 lines
17 KiB
C++
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
NodeOpera(Test) - verify proper render node operation modes
Copyright (C)
2024, Hermann Vosseler <Ichthyostega@web.de>
  **Lumiera** is free software; you can redistribute it and/or modify it
  under the terms of the GNU General Public License as published by the
  Free Software Foundation; either version 2 of the License, or (at your
  option) any later version. See the file COPYING for further details.
* *****************************************************************/
/** @file node-opera-test.cpp
** Let the nodes sing with \ref NodeOpera_test.
*/
#include "test/run.hpp"
#include "lib/allocation-cluster.hpp"
#include "test/test-rand-ontology.hpp"
#include "vault/out/diagnostic-output-slot.hpp"
#include "vault/mem/buffer-provider-setup.hpp"
#include "vault/mem/engine-buffer-metadata.hpp"
#include "vault/mem/engine-buffer-manager.hpp"
#include "vault/mem/local-buffer-stage.hpp"
#include "vault/mem/local-buffer-store.hpp"
#include "steam/engine/engine-facilities.hpp"
#include "steam/engine/node-builder.hpp"
#include "steam/asset/meta/time-grid.hpp"
#include "lib/time/timequant.hpp"
#include "lib/time/timecode.hpp"
#include "lib/depend-inject.hpp"
#include "lib/thread.hpp"
//#include "lib/symbol.hpp"
#include "lib/util.hpp"
#include "test/diagnostic-output.hpp"///////////////TODO
//using std::string;
using lib::Thread;
using std::this_thread::yield;
using std::this_thread::sleep_for;
using std::chrono_literals::operator ""us;
using lib::time::Time;
using lib::time::QuTime;
using lib::time::FrameNr;
using lib::time::FrameCnt;
using lib::time::FrameRate;
using lib::time::PGrid;
using steam::asset::meta::TimeGrid;
using vault::out::DiagnosticOutputSlot;
//using lib::Symbol;
//using util::isnil;
//using util::isSameObject;
using util::isLimited;
using test::TestFrame;
using test::testRand;
namespace ont = test::ont;
namespace steam {
namespace engine{
namespace test {
namespace { // ========== Test Setup ==========
using vault::mem::BufferProviderSetup;
using vault::mem::LocalBufferStage;
using vault::mem::LocalBufferStore;
using vault::mem::EngineBufferMetadata;
using vault::mem::EngineBufferManager;
class TestEngineBufferSetup
: public BufferProviderSetup
, public EngineFacilities
{
public:
TestEngineBufferSetup()
: BufferProviderSetup{*this}
{ }
auto buildStage() { return std::make_unique<LocalBufferStage>("NodeOpera_test"); }
auto buildStore() { return std::make_unique<LocalBufferStore>(); }
BufferProvider& setupBufferProvider() override { return *this; }
};
struct TestEngineCtx
{
// install mock instances for the relevant services
lib::DependInject<EngineBufferMetadata>::Local<> metaHub;
lib::DependInject<EngineBufferManager>::Local<> globalPool;
// install a rigged access front-end for the buffer management
lib::DependInject<EngineFacilities>::Local<TestEngineBufferSetup> bufferSetup;
lib::DependInject<EngineCtx>::Local<EngineCtx> frontEndCtx;
};
}
/***************************************************************//**
* @test check render node operation modes and collaboration.
* @todo 6/2026 this test shell demonstrate all relevant operation modes
* of render nodes, at the level of an component-integration-test.
* It is not clear yet what further operation modes this entails,
* beyond (obviously) invoking a render job... ////////////////////////////////////////////////TICKET #1367 : build a node invocation
* @remark the purpose of this test is to accomplish component integration
* regarding relevant usages of the render node network, seen from the
* perspective of an (exit) render node that is used / invoked / pulled.
*/
class NodeOpera_test : public Test
{
virtual void
run (Arg)
{
seedRand();
invokeSimpleNode();
invokeNodeTree();
}
/** @test directly invoke a simple source node.
* - use the node builder to create a simple node
* immediately into the current stack frame
* - the processing function feeds a fixed random number
* into the given output buffer
* - then launch a worker thread to pull this node
* - create an output buffer directly from the worker thread
* - invoke the single port of the node, which is visible
* since it was created before starting the local worker thread
* - after the invocation, the fixed random number should have been
* placed into the output buffer
* - after the worker thread has finished, a single buffer allocation
* should have happened; this allocation was sent back automatically
* and can be claimed and released by the global buffer manager.
*/
void
invokeSimpleNode()
{
int randNum{rani()};
auto fun = [&](int* buff){ *buff = randNum; };
TestEngineCtx mockEngine;
ProcNode node{prepareNode("Test")
.preparePort()
.invoke("fun()", fun)
.completePort()
.build()};
Thread testWorker{[&] /* === directly perform a node invocation === */
{
BuffHandle buff = EngineCtx::access().mem.lockBufferFor<int> (-55);
Time nomTime{Time::ZERO};
ProcessKey key{0};
uint port{0};
CHECK (-55 == buff.accessAs<int>());
// Trigger Node invocation...
buff = node.pull (port, buff, nomTime, key);
CHECK (randNum == buff.accessAs<int>());
buff.release();
}};
while (testWorker)
yield(); // wait for worker to finish
// the central services were indeed requested / created
CHECK (mockEngine.bufferSetup);
CHECK (mockEngine.globalPool);
CHECK (mockEngine.metaHub);
// at least two buffer types registered (one generic for int)
CHECK (2 <= mockEngine.metaHub->cntEntries());
// one buffer was requested...
CHECK (1 == watch(*mockEngine.globalPool).numAllocs());
sleep_for (500us); // additional delay for the local pool's destructor
// (which is invoked after the thread has terminated)
// process and close remaining allocations
// sent back by the local pool's destructor...
mockEngine.globalPool->processPendingRequests();
// now there should be no memory leaks.......
CHECK (0 == watch(*mockEngine.globalPool).bytesLeased());
}
/** @test perform a RenderInvocation to pull from a complex node tree into an output sink.
* - use a test helper class to build a node topology similar to NodeLink_test
* - the ProcNodes and all backing structures are managed by an AllocationCluster
* - use a DiagnosticOutputSlot (by default configured for buffers sized for TestFrame)
*/
void
invokeNodeTree()
{
TestEngineCtx mockEngine;
RenderPipeline pipeline;
DiagnosticOutputSlot oSlot;
auto sink0 = * oSlot.getOpenedSinks();
const uint OUTPUT_FRAME{123};
Thread testWorker{[&] /* === perform a render invocation === */
{
Time nomTime{rani(60'000),0}; // drive test with a random »nominal Time« <60s with ms granularity
ProcessKey key{0};
uint port{0};
BuffHandle buff = sink0.lockFrame (OUTPUT_FRAME); // claim output buffer
CHECK (not buff.accessAs<TestFrame>().isValid()); // underlying buffer is zero initialised, thus no valid TestFrame yet
// Trigger Node invocation...
buff = pipeline.exitNode.pull (port, buff, nomTime, key);
buff.release();
// DiagnosticOutputSlot allows to investigate the data sent into the DataSink...
TestFrame const& result = watch(oSlot).feed(0).frame(OUTPUT_FRAME).accessAs<TestFrame>();
CHECK (result.isValid()); // data matches checksum
CHECK (not result.isPristine()); // is not a source frame (but processed)
CHECK (result.getChecksum() == pipeline.expectedChecksum (nomTime));
}};
while (testWorker)
yield(); // wait for worker to finish
sleep_for (500us);
mockEngine.globalPool->processPendingRequests();
// now there should be no memory leaks.......
CHECK (0 == watch(*mockEngine.globalPool).bytesLeased());
}
/**
* Helper context to build and manage a processing chain of Render Nodes.
* In the real Render Engine, these processing chains are generated by the Builder,
* and organised into »Segments« of the timeline, where each segment features a
* distinct node connectivity (i.e. typically for one clip).
* For this test, a single top-level _exit node_ is exposed.
* @note this helper context is constructed in _RAII style_,
* and the embedded AllocationCluster unwinds automatically.
* @see \ref NodeLink_test for a detailed breakdown of a similar topology
*/
class RenderPipeline
: util::NonCopyable
{
static constexpr ont::Flavr SRC_A = 10; ///< »chain-A« arbitrary source frame marker
static constexpr ont::Flavr SRC_B = 20; ///< similar for »chain-B«
PGrid secondsGrid_{TimeGrid::build (FrameRate{1})};
FrameCnt quantSecs (Time time) { return secondsGrid_->gridPoint (time); };
/** custom allocator for this node tree */
lib::AllocationCluster alloc_;
public:
ProcNode& exitNode{buildTree()};
HashVal
expectedChecksum (Time nomTime)
{
ont::FraNo fraNo = quantSecs(nomTime);
ont::Param param = stepFilter(fraNo);
ont::Factr mix = stepMixer (fraNo);
TestFrame f1{uint(fraNo),SRC_A};
TestFrame f2{uint(fraNo),SRC_B};
ont::manipulateFrame (&f1, &f1, param);
ont::combineFrames (&f1, &f1, &f2, mix);
return f1.getChecksum();
}
private:
ont::Param stepFilter(FrameCnt id) { return util::limited (10, -10 + id, 50); };
ont::Factr stepMixer (FrameCnt id) { return util::limited (0, + id, 50) / 50.0; };
/** @internal wire the processing node topology.
* This fake "render pipeline" is based on hash chaining computations,
* operating on TestFrame (with reproducible random data content).
* Two processing chains are built, each producing test frames;
* the first chain even adds a "filter". Some "parameters" are added
* to change the random data in a deterministic way. Finally, both chains
* are "mixed" together into a single result TestFrame. Furthermore, the
* parameter automation is based on a precomputed _frame number_ parameter,
* that is derived from the _absolute nominal time_ by quantisation into a
* time grid with a stepping of 1 second (-> `FrameRate{1}`). This precomputed
* frame number parameter is stored in a parameter data block on the call stack,
* prior to recursively evaluating the node chain attached below.
* @note the generated Render Nodes are placed into the AllocationCluster,
* together with all the further storage used by those nodes internally.
* The real Render Engine uses an AllocationCluster per Segment of the timeline.
*/
ProcNode&
buildTree()
{
// Prepare a precomputed parameter for the complete tree
auto selectFrameNo = [&](TurnoutSystem& turSys){ return quantSecs (turSys.getNomTime()); };
auto paramSpec = buildParamSpec()
.addSlot (selectFrameNo);
auto accFrameNo = paramSpec.makeAccessor<0>();
// Automation functions using the accessor for the precomputed FrameNo-parameter
auto autoFilter = [&](TurnoutSystem& turSys){ return stepFilter (turSys.get (accFrameNo)); };
auto autoMixer = [&](TurnoutSystem& turSys){ return stepMixer (turSys.get (accFrameNo)); };
// use processing functions from the »TestRand Ontology«
auto testGen = testRand().setupGenerator();
auto testMan = testRand().setupManipulator();
auto testMix = testRand().setupCombinator();
// A source node to produce random test frames
auto& n1s = alloc_.create<ProcNode>(
prepareNode("srcA")
.withAllocator(alloc_)
.preparePort()
.invoke(testGen.procID(), testGen.makeFun()) // params(frameNo, flavour)
.closeParam<1>(SRC_A) // --> fix the flavour ≔ SRC_A
.retrieveParam(accFrameNo)
.completePort()
.build());
// A node to »filter« the data in chain-A
auto& n1f = alloc_.create<ProcNode>(
prepareNode("filterA")
.withAllocator(alloc_)
.preparePort()
.invoke(testMan.procID(), testMan.makeFun())
.attachParamFun(autoFilter) // filter-param <-- autoFilter(frameNo)
.connectLead(n1s)
.completePort()
.build());
// A secondary source Node for the »chain-B«
auto& n2s = alloc_.create<ProcNode>(
prepareNode("srcB")
.withAllocator(alloc_)
.preparePort()
.invoke(testGen.procID(), testGen.makeFun()) // params(frameNo, flavour)
.closeParam<1>(SRC_B) // --> fix the flavour ≔ SRC_B
.retrieveParam(accFrameNo)
.completePort()
.build());
// A mixing Node to combine both chains
auto& mix = alloc_.create<ProcNode>(
prepareNode("mix")
.withAllocator(alloc_)
.preparePort()
.invoke(testMix.procID(), testMix.makeFun())
.attachParamFun(autoMixer) // mixer-param <-- autoMixer(frameNo)
.connectLead(n1f)
.connectLead(n2s)
.completePort()
.build());
// Place a »Param-Agent«-Node on top to pre-compute the FrameNo
auto& parNode = alloc_.create<ProcNode>(
prepareNode("Param")
.withAllocator(alloc_)
.preparePort()
.computeParam(paramSpec)
.delegateLead(mix)
.completePort()
.build());
// the AllocationCluster managed some storage...
CHECK (isLimited (1_KiB, alloc_.numBytes(), 5_KiB));
// here the »Param-Agent« also acts as the Exit-Node
return parNode;
}
};
};
/** Register this test class... */
LAUNCHER (NodeOpera_test, "unit node");
}}} // namespace steam::engine::test