1473 lines
77 KiB
C++
1473 lines
77 KiB
C++
#include "../app/Gallery_Plot_Session.h"
|
|
#include "../app/Gallery_Observer_Adminive.h"
|
|
#include "../app/Gallery_Protocol.h"
|
|
#include "../app/Gallery_Renderables.h"
|
|
#include "../app/Pixel_Frame.h"
|
|
#include "../app/Web_Event_Adapter.h"
|
|
#include "../app/Web_Plot_Session.h"
|
|
|
|
#include <gtest/gtest.h>
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <memory>
|
|
#include <set>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <thread>
|
|
#include <tuple>
|
|
#include <variant>
|
|
#include <vector>
|
|
|
|
namespace renderive::web {
|
|
namespace {
|
|
|
|
struct Spatial_Test_Event {
|
|
int layer{};
|
|
};
|
|
|
|
struct Spatial_Test_Decoder {
|
|
using event_type = Spatial_Test_Event;
|
|
|
|
static std::optional<event_type> decode(const Json::Value& root, std::string_view) {
|
|
if (root["type"].asString() != "spatial_test" || !root["layer"].isInt())
|
|
return std::nullopt;
|
|
return Spatial_Test_Event{root["layer"].asInt()};
|
|
}
|
|
};
|
|
|
|
using Extended_Web_Event = Basic_Web_Event<Event, Pointer_Event, Wheel_Event,
|
|
Key_Event, Spatial_Test_Event>;
|
|
using Extended_Web_Event_Adapter = Basic_Web_Event_Adapter<
|
|
Extended_Web_Event, Web_Transport_Event_Decoder, Render_2D_Web_Event_Decoder,
|
|
Web_Demo_Control_Decoder, Spatial_Test_Decoder>;
|
|
|
|
std::uint32_t read_u32_le(const std::string& value, std::size_t offset) {
|
|
return static_cast<std::uint8_t>(value[offset]) |
|
|
(static_cast<std::uint32_t>(static_cast<std::uint8_t>(value[offset + 1])) << 8U) |
|
|
(static_cast<std::uint32_t>(static_cast<std::uint8_t>(value[offset + 2])) << 16U) |
|
|
(static_cast<std::uint32_t>(static_cast<std::uint8_t>(value[offset + 3])) << 24U);
|
|
}
|
|
|
|
nlohmann::json parse_json(const std::string& value) {
|
|
return nlohmann::json::parse(value);
|
|
}
|
|
|
|
Gallery_Request gallery_request(Gallery_Request_Kind kind, std::string message) {
|
|
return {kind, std::move(message)};
|
|
}
|
|
|
|
std::string open_message(std::string_view case_id, std::string_view frame_mode) {
|
|
return std::string(R"({"category":"event","type":"gallery_open","case":")") +
|
|
std::string(case_id) + R"(","frame_mode":")" + std::string(frame_mode) + R"("})";
|
|
}
|
|
|
|
nlohmann::json response_json(std::optional<Web_Response> response) {
|
|
if (!response) {
|
|
ADD_FAILURE() << "expected a Web response";
|
|
return nlohmann::json::object();
|
|
}
|
|
EXPECT_EQ(response->type, Web_Response_Type::Json);
|
|
return parse_json(response->payload);
|
|
}
|
|
|
|
nlohmann::json observe_telemetry(Gallery_Plot_Session& session) {
|
|
return response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe"})"))).value(
|
|
"telemetry", nlohmann::json::object());
|
|
}
|
|
|
|
nlohmann::json invoke_action(Gallery_Plot_Session& session, std::string_view action_id,
|
|
std::optional<double> argument = std::nullopt) {
|
|
nlohmann::json request{{"category", "event"}, {"type", "gallery_action"},
|
|
{"action", action_id}};
|
|
if (argument)
|
|
request["argument"] = *argument;
|
|
return response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Action, request.dump())));
|
|
}
|
|
|
|
nlohmann::json patch_controls(Gallery_Plot_Session& session, nlohmann::json patch,
|
|
std::string_view target = "scene") {
|
|
const nlohmann::json request{{"category", "event"}, {"type", "gallery_patch"},
|
|
{"target", target}, {"patch", std::move(patch)}};
|
|
return response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch, request.dump())));
|
|
}
|
|
|
|
template <class Predicate>
|
|
bool wait_for_condition(Predicate&& predicate,
|
|
std::chrono::milliseconds timeout = std::chrono::milliseconds(750)) {
|
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
|
do {
|
|
if (predicate())
|
|
return true;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
} while (std::chrono::steady_clock::now() < deadline);
|
|
return predicate();
|
|
}
|
|
|
|
std::optional<Web_Response> wait_for_pixel_frame(Gallery_Plot_Session& session,
|
|
std::uint32_t width,
|
|
std::uint32_t height) {
|
|
std::optional<Web_Response> frame;
|
|
wait_for_condition([&] {
|
|
frame = session.handle(Frame_Request{});
|
|
return frame && frame->type == Web_Response_Type::Pixels &&
|
|
frame->payload.size() >= pixel_frame_header_size &&
|
|
read_u32_le(frame->payload, 4) == width &&
|
|
read_u32_le(frame->payload, 8) == height;
|
|
});
|
|
return frame;
|
|
}
|
|
|
|
void set_gallery_view_active(Gallery_Plot_Session& session, bool active) {
|
|
const auto event = Web_Event_Adapter::decode(active ?
|
|
R"({"category":"event","type":"show"})" :
|
|
R"({"category":"event","type":"hide"})");
|
|
ASSERT_TRUE(event.has_value());
|
|
EXPECT_FALSE(session.handle(*event).has_value());
|
|
}
|
|
|
|
std::uint64_t successful_render_count(Gallery_Plot_Session& session) {
|
|
return observe_telemetry(session).at("performance").at("successful_render_count")
|
|
.get<std::uint64_t>();
|
|
}
|
|
|
|
TEST(RenderiveWebBridge, DecodesOnlyEventMessages) {
|
|
const auto resize = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"resize","width":800,"height":450})");
|
|
ASSERT_TRUE(resize.has_value());
|
|
ASSERT_TRUE(std::holds_alternative<Viewport_Resize>(*resize));
|
|
EXPECT_EQ(std::get<Viewport_Resize>(*resize).size, (Size{800, 450}));
|
|
|
|
const auto pointer = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"pointer_press","x":22.5,"y":18,"button":"left","buttons":1,"modifiers":3})");
|
|
ASSERT_TRUE(pointer.has_value());
|
|
ASSERT_TRUE(std::holds_alternative<Pointer_Event>(*pointer));
|
|
EXPECT_EQ(std::get<Pointer_Event>(*pointer).button, Mouse_Button::Left);
|
|
EXPECT_DOUBLE_EQ(std::get<Pointer_Event>(*pointer).position.x, 22.5);
|
|
|
|
EXPECT_FALSE(Web_Event_Adapter::decode(R"({"category":"command","type":"frame"})"));
|
|
EXPECT_FALSE(Web_Event_Adapter::decode("not-json"));
|
|
|
|
const auto catalog = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"gallery_catalog"})");
|
|
ASSERT_TRUE(catalog.has_value());
|
|
ASSERT_TRUE(std::holds_alternative<Gallery_Request>(*catalog));
|
|
EXPECT_EQ(std::get<Gallery_Request>(*catalog).kind, Gallery_Request_Kind::Catalog);
|
|
|
|
const auto open = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"gallery_open","case":"spectrum","frame_mode":"manual"})");
|
|
ASSERT_TRUE(open.has_value());
|
|
EXPECT_EQ(std::get<Gallery_Request>(*open).kind, Gallery_Request_Kind::Open);
|
|
|
|
const auto observe = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"gallery_observe"})");
|
|
ASSERT_TRUE(observe.has_value());
|
|
EXPECT_EQ(std::get<Gallery_Request>(*observe).kind, Gallery_Request_Kind::Observe);
|
|
|
|
const auto bounded = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"resize","width":1e100,"height":-1e100})");
|
|
ASSERT_TRUE(bounded.has_value());
|
|
EXPECT_EQ(std::get<Viewport_Resize>(*bounded).size, (Size{1920, 180}));
|
|
}
|
|
|
|
TEST(RenderiveWebBridge, EncodesRgbaPixelFrame) {
|
|
const Pixel pixels[] = {pack_rgba(255, 0, 0), pack_rgba(0, 255, 0)};
|
|
const Image_View image{reinterpret_cast<const std::byte*>(pixels), 2, 1,
|
|
static_cast<int>(sizeof(pixels)), Pixel_Format::Premultiplied_32};
|
|
const std::string frame = encode_pixel_frame(image);
|
|
ASSERT_EQ(frame.size(), pixel_frame_header_size + 8);
|
|
EXPECT_EQ(frame.substr(0, 4), "RVP1");
|
|
EXPECT_EQ(read_u32_le(frame, 4), 2U);
|
|
EXPECT_EQ(read_u32_le(frame, 8), 1U);
|
|
EXPECT_EQ(read_u32_le(frame, 12), 8U);
|
|
const auto* rgba = reinterpret_cast<const std::uint8_t*>(frame.data() + pixel_frame_header_size);
|
|
EXPECT_EQ(rgba[0], 255);
|
|
EXPECT_EQ(rgba[1], 0);
|
|
EXPECT_EQ(rgba[2], 0);
|
|
EXPECT_EQ(rgba[3], 255);
|
|
EXPECT_EQ(rgba[4], 0);
|
|
EXPECT_EQ(rgba[5], 255);
|
|
}
|
|
|
|
TEST(RenderiveWebBridge, LegacyReceiverStillRendersPixels) {
|
|
Web_Plot_Session session;
|
|
EXPECT_FALSE(session.handle(Viewport_Resize{{640, 360}}).has_value());
|
|
EXPECT_FALSE(session.handle(Set_Demo_Mode{Demo_Mode::Usb}).has_value());
|
|
const auto frame = session.handle(Frame_Request{});
|
|
ASSERT_TRUE(frame.has_value());
|
|
EXPECT_EQ(frame->type, Web_Response_Type::Pixels);
|
|
ASSERT_GE(frame->payload.size(), pixel_frame_header_size);
|
|
EXPECT_EQ(frame->payload.substr(0, 4), "RVP1");
|
|
EXPECT_EQ(read_u32_le(frame->payload, 4), 640U);
|
|
EXPECT_EQ(read_u32_le(frame->payload, 8), 360U);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, AdminiveCatalogCoversEveryControlCaseAndThreeModes) {
|
|
const nlohmann::json catalog = parse_json(Gallery_Protocol::catalog_json());
|
|
EXPECT_EQ(catalog.at("category"), "gallery");
|
|
EXPECT_EQ(catalog.at("type"), "catalog");
|
|
EXPECT_EQ(catalog.at("protocol"), "renderive.control-gallery");
|
|
EXPECT_EQ(catalog.at("protocol_version"), 4);
|
|
EXPECT_EQ(catalog.at("case_descriptor").at("protocol"), "adminive.resource");
|
|
EXPECT_EQ(catalog.at("cases").size(), 8U);
|
|
EXPECT_EQ(catalog.at("frame_modes").size(), 3U);
|
|
EXPECT_EQ(catalog.at("coverage").at("page_count"), 3);
|
|
EXPECT_EQ(catalog.at("coverage").at("canvas_count"), 24);
|
|
EXPECT_GT(catalog.at("coverage").at("manual_control_count").get<std::size_t>(), 180U);
|
|
EXPECT_GT(catalog.at("coverage").at("manual_action_count").get<std::size_t>(), 50U);
|
|
EXPECT_EQ(catalog.at("coverage").at("frequency_modes"),
|
|
nlohmann::json::array({"spectrum", "afterglow", "sweep_spectrum"}));
|
|
EXPECT_EQ(catalog.at("coverage").at("image_interpolation_modes"),
|
|
nlohmann::json::array({"Nearest", "Bilinear", "Bicubic"}));
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, CatalogOwnsDashboardFieldsAndDynamicBehavior) {
|
|
const nlohmann::json catalog = parse_json(Gallery_Protocol::catalog_json());
|
|
const auto& dashboard = catalog.at("dashboard");
|
|
EXPECT_EQ(dashboard.at("performance").at("fields").size(), 22U);
|
|
EXPECT_EQ(dashboard.at("limits").at("fields").size(), 4U);
|
|
EXPECT_EQ(dashboard.at("observer").at("sections").size(), 4U);
|
|
std::set<std::string> client_sources;
|
|
for (const auto& section : dashboard.at("observer").at("sections")) {
|
|
for (const auto& field : section.at("fields")) {
|
|
const std::string source = field.at("source").get<std::string>();
|
|
if (source.starts_with("client_performance."))
|
|
client_sources.insert(source);
|
|
}
|
|
}
|
|
constexpr std::array<std::string_view, 14> client_fields{
|
|
"display_interval_latest_ms", "display_interval_ms", "display_interval_p95_ms",
|
|
"display_jitter_ms", "frame_round_trip_ms", "transport_fps", "presentation_fps",
|
|
"websocket_buffered_bytes", "overwritten_pixel_frames", "changed_pixel_frames",
|
|
"duplicate_pixel_frames", "frame_request_timeout_count", "last_pixel_receive_age_ms",
|
|
"last_pixel_change_age_ms"
|
|
};
|
|
EXPECT_EQ(client_sources.size(), client_fields.size());
|
|
for (const auto field : client_fields)
|
|
EXPECT_TRUE(client_sources.contains("client_performance." + std::string(field))) << field;
|
|
for (const auto& mode : catalog.at("frame_modes")) {
|
|
EXPECT_TRUE(mode.contains("frame_button_label"));
|
|
EXPECT_TRUE(mode.contains("request_after_response"));
|
|
EXPECT_TRUE(mode.contains("request_on_animation_frame"));
|
|
EXPECT_TRUE(mode.contains("observer_visible"));
|
|
EXPECT_TRUE(mode.contains("accent"));
|
|
}
|
|
Gallery_Plot_Session session;
|
|
const auto contract = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "manual"))));
|
|
const auto action = std::find_if(contract.at("actions").at("data").begin(),
|
|
contract.at("actions").at("data").end(),
|
|
[](const auto& item) { return item.at("id") == "mode_render"; });
|
|
ASSERT_NE(action, contract.at("actions").at("data").end());
|
|
EXPECT_TRUE(action->at("request_frame"));
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ObserverTelemetryAndDashboardUseAdminiveDescriptors) {
|
|
const auto catalog = parse_json(Gallery_Protocol::catalog_json());
|
|
const auto& observer_contract = catalog.at("dashboard").at("observer");
|
|
const auto& observer_descriptor = observer_contract.at("descriptor");
|
|
const auto& feedback_descriptor =
|
|
observer_contract.at("consumer_feedback_descriptor");
|
|
|
|
Gallery_Plot_Session session;
|
|
const auto opened = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "manual"))));
|
|
const auto& telemetry = opened.at("telemetry");
|
|
const auto& observer = telemetry.at("kernel_observer");
|
|
const auto& feedback = telemetry.at("consumer_feedback");
|
|
|
|
EXPECT_EQ(observer.size(), observer_descriptor.at("fields").size());
|
|
for (const auto& field : observer_descriptor.at("fields"))
|
|
EXPECT_TRUE(observer.contains(field.at("name").get<std::string>()));
|
|
EXPECT_EQ(feedback.size(), feedback_descriptor.at("fields").size());
|
|
for (const auto& field : feedback_descriptor.at("fields"))
|
|
EXPECT_TRUE(feedback.contains(field.at("name").get<std::string>()));
|
|
EXPECT_EQ(observer.at("mode"), "manual");
|
|
EXPECT_GT(observer.at("task_worker_count").get<std::size_t>(), 0U);
|
|
EXPECT_TRUE(observer.contains("task_execution_state"));
|
|
EXPECT_TRUE(observer.contains("task_graph_node_count"));
|
|
EXPECT_TRUE(observer.contains("task_running_node_count"));
|
|
EXPECT_TRUE(observer.contains("task_pending_node_count"));
|
|
EXPECT_TRUE(observer.contains("task_peak_parallelism"));
|
|
EXPECT_TRUE(observer.contains("task_graph_duration_ns"));
|
|
EXPECT_FALSE(telemetry.contains("low_latency_limit"));
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ActionsComeFromStructiveTypeMetadata) {
|
|
using Spectrum_Schema = structive::type_descriptor_schema_t<Gallery_Spectrum>;
|
|
using Time_Axis_Schema = structive::type_descriptor_schema_t<Gallery_Time_Axis>;
|
|
using Manual_Strategy =
|
|
Manual_Refresh_Strategy<Scene2D_Frame_Data, std::mutex, Observer_State<>>;
|
|
using Manual_Schema = structive::type_descriptor_schema_t<Manual_Strategy>;
|
|
static_assert(Spectrum_Schema::template type_attribute_count<
|
|
Gallery_Action_Category> == 12);
|
|
static_assert(Time_Axis_Schema::template type_attribute_count<
|
|
Gallery_Action_Category> == 2);
|
|
static_assert(Manual_Schema::template type_attribute_count<
|
|
Gallery_Action_Category> == 5);
|
|
|
|
std::vector<Gallery_Action_Model> actions;
|
|
append_gallery_actions<Gallery_Spectrum>(actions);
|
|
EXPECT_EQ(actions.size(), 12U);
|
|
EXPECT_TRUE(std::any_of(actions.begin(), actions.end(), [](const auto& action) {
|
|
return action.id == "power_at" && action.argument_input == "number";
|
|
}));
|
|
}
|
|
|
|
TEST(RenderiveWebBridge, ComposesAdditionalEventDecoders) {
|
|
const auto event = Extended_Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"spatial_test","layer":7})");
|
|
ASSERT_TRUE(event.has_value());
|
|
ASSERT_TRUE(std::holds_alternative<Spatial_Test_Event>(*event));
|
|
EXPECT_EQ(std::get<Spatial_Test_Event>(*event).layer, 7);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, AdminiveResourcesPatchTheRealRenderableState) {
|
|
Gallery_Plot_Session session;
|
|
auto response = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "manual"))));
|
|
ASSERT_EQ(response.at("type"), "case_state");
|
|
const auto find_resource = [](const nlohmann::json& value, std::string_view target)
|
|
-> const nlohmann::json& {
|
|
const auto& resources = value.at("controls").at("resources");
|
|
const auto found = std::find_if(resources.begin(), resources.end(),
|
|
[target](const auto& resource) {
|
|
return resource.at("target").template get<std::string>() == target;
|
|
});
|
|
if (found == resources.end())
|
|
throw std::out_of_range("missing gallery resource");
|
|
return *found;
|
|
};
|
|
|
|
const auto& spectrum = find_resource(response, "spectrum");
|
|
EXPECT_EQ(spectrum.at("descriptor").at("name"), "spectrum");
|
|
EXPECT_TRUE(spectrum.at("data").at("frequency_range").is_object());
|
|
EXPECT_EQ(spectrum.at("data").at("frequency_point_size"), 512);
|
|
const auto partition_field = std::find_if(
|
|
spectrum.at("descriptor").at("fields").begin(),
|
|
spectrum.at("descriptor").at("fields").end(),
|
|
[](const auto& field) { return field.at("name") == "partition_count"; });
|
|
ASSERT_NE(partition_field, spectrum.at("descriptor").at("fields").end());
|
|
EXPECT_EQ(partition_field->at("presentation").at("description"),
|
|
"0 = automatic, 1 = single task, N = N parallel partitions");
|
|
|
|
response = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"spectrum","patch":{"frequency_point_size":1024,"partition_count":4,"frequency_range":{"origin":90000000},"current_pen":{"color":"#ff8844"},"interpolation_mode":"Cubic_Value"}})")));
|
|
ASSERT_EQ(response.at("type"), "case_state");
|
|
const auto& updated = find_resource(response, "spectrum").at("data");
|
|
EXPECT_EQ(updated.at("frequency_point_size"), 1024);
|
|
EXPECT_EQ(updated.at("partition_count"), 4);
|
|
EXPECT_EQ(updated.at("frequency_range").at("origin"), 90'000'000);
|
|
EXPECT_EQ(updated.at("current_pen").at("color"), "#ff8844");
|
|
EXPECT_EQ(updated.at("interpolation_mode"), "Cubic_Value");
|
|
|
|
const auto invalid = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"spectrum","patch":{"current_pen":{"color":"not-a-color"}}})")));
|
|
EXPECT_EQ(invalid.at("type"), "error");
|
|
|
|
const auto foreign = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"waterfall","patch":{"visible_range_only":false}})")));
|
|
EXPECT_EQ(foreign.at("type"), "error");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, PartitionControlRebuildsTheObservedTaskGraph) {
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open,
|
|
open_message("spectrum", "manual")))).at("type"),
|
|
"case_state");
|
|
|
|
const auto render = [&session] {
|
|
EXPECT_EQ(invoke_action(session, "mode_prepare").at("type"), "case_state");
|
|
EXPECT_EQ(invoke_action(session, "mode_refresh").at("type"), "case_state");
|
|
return invoke_action(session, "mode_render").at("telemetry").at("kernel_observer");
|
|
};
|
|
|
|
ASSERT_EQ(patch_controls(session, {{"partition_count", 1}}, "spectrum").at("type"),
|
|
"case_state");
|
|
const auto single = render();
|
|
EXPECT_EQ(single.at("task_execution_state"), "Completed");
|
|
EXPECT_EQ(single.at("task_completed_node_count"),
|
|
single.at("task_graph_node_count"));
|
|
EXPECT_EQ(single.at("task_running_node_count"), 0);
|
|
EXPECT_EQ(single.at("task_pending_node_count"), 0);
|
|
|
|
ASSERT_EQ(patch_controls(session, {{"partition_count", 4}}, "spectrum").at("type"),
|
|
"case_state");
|
|
const auto partitioned = render();
|
|
EXPECT_EQ(partitioned.at("task_graph_node_count").get<std::size_t>(),
|
|
single.at("task_graph_node_count").get<std::size_t>() + 3U);
|
|
EXPECT_EQ(partitioned.at("task_completed_node_count"),
|
|
partitioned.at("task_graph_node_count"));
|
|
EXPECT_GT(partitioned.at("task_peak_parallelism").get<std::size_t>(), 0U);
|
|
EXPECT_GT(partitioned.at("task_graph_duration_ns").get<std::uint64_t>(), 0U);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, AxisSwapAndEveryPlotDirectionAreEditableAndRenderable) {
|
|
const auto patch = [](Gallery_Plot_Session& session, std::string_view target,
|
|
nlohmann::json value) {
|
|
nlohmann::json request{{"category", "event"}, {"type", "gallery_patch"},
|
|
{"target", target}, {"patch", std::move(value)}};
|
|
return response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch, request.dump())));
|
|
};
|
|
const auto resource_data = [](const nlohmann::json& state, std::string_view target)
|
|
-> const nlohmann::json& {
|
|
const auto& resources = state.at("controls").at("resources");
|
|
const auto found = std::find_if(resources.begin(), resources.end(),
|
|
[target](const auto& resource) {
|
|
return resource.at("target").template get<std::string>() == target;
|
|
});
|
|
if (found == resources.end())
|
|
throw std::out_of_range("missing gallery resource");
|
|
return found->at("data");
|
|
};
|
|
|
|
for (const std::string_view case_id : {"spectrum", "afterglow"}) {
|
|
Gallery_Plot_Session session;
|
|
auto state = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message(case_id, "manual"))));
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
state = patch(session, "frequency_axis",
|
|
{{"orientation", "Vertical"},
|
|
{"coordinates", {{"origin", 108'000'000.0},
|
|
{"target", 88'000'000.0}}}});
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
state = patch(session, "value_axis",
|
|
{{"orientation", "Horizontal"},
|
|
{"coordinates", {{"origin", -20.0}, {"target", -120.0}}}});
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
state = patch(session, case_id, {{"partition_count", 4}});
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
EXPECT_EQ(resource_data(state, "frequency_axis").at("orientation"), "Vertical");
|
|
EXPECT_EQ(resource_data(state, "value_axis").at("orientation"), "Horizontal");
|
|
EXPECT_EQ(resource_data(state, case_id).at("partition_count"), 4);
|
|
}
|
|
|
|
Gallery_Plot_Session waterfall;
|
|
auto state = response_json(waterfall.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("waterfall", "manual"))));
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
state = patch(waterfall, "frequency_axis",
|
|
{{"orientation", "Vertical"},
|
|
{"coordinates", {{"origin", 108'000'000.0},
|
|
{"target", 88'000'000.0}}}});
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
state = patch(waterfall, "time_axis",
|
|
{{"orientation", "Horizontal"}, {"newest_at_start", true}});
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
state = patch(waterfall, "waterfall", {{"partition_count", 4}});
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
EXPECT_EQ(resource_data(state, "frequency_axis").at("orientation"), "Vertical");
|
|
EXPECT_EQ(resource_data(state, "time_axis").at("orientation"), "Horizontal");
|
|
EXPECT_TRUE(resource_data(state, "time_axis").at("newest_at_start"));
|
|
EXPECT_EQ(resource_data(state, "waterfall").at("partition_count"), 4);
|
|
|
|
for (const auto& [frequency_orientation, time_orientation, newest_at_start] :
|
|
std::array{
|
|
std::tuple{"Horizontal", "Vertical", false},
|
|
std::tuple{"Horizontal", "Vertical", true},
|
|
std::tuple{"Vertical", "Horizontal", false},
|
|
std::tuple{"Vertical", "Horizontal", true}}) {
|
|
state = patch(waterfall, "frequency_axis",
|
|
{{"orientation", frequency_orientation}});
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
state = patch(waterfall, "time_axis",
|
|
{{"orientation", time_orientation},
|
|
{"newest_at_start", newest_at_start}});
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
EXPECT_EQ(resource_data(state, "frequency_axis").at("orientation"),
|
|
frequency_orientation);
|
|
EXPECT_EQ(resource_data(state, "time_axis").at("orientation"),
|
|
time_orientation);
|
|
EXPECT_EQ(resource_data(state, "time_axis").at("newest_at_start"),
|
|
newest_at_start);
|
|
}
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, EveryPublishedActionDispatchesToItsCanvasAndFrameMode) {
|
|
constexpr std::array<std::string_view, 8> cases{
|
|
"axis_lab", "spectrum", "afterglow", "sweep_spectrum",
|
|
"waterfall", "frequency_trace", "selection_overlay", "constellation"
|
|
};
|
|
constexpr std::array<std::string_view, 3> modes{"manual", "low_latency", "playback"};
|
|
std::size_t dispatched_action_instances{};
|
|
for (const auto case_id : cases) {
|
|
for (const auto mode : modes) {
|
|
Gallery_Plot_Session catalog_session;
|
|
const auto opened = response_json(catalog_session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message(case_id, mode))));
|
|
ASSERT_EQ(opened.at("type"), "case_state");
|
|
const auto actions = opened.at("actions").at("data");
|
|
std::set<std::string> ids;
|
|
for (const auto& action : actions) {
|
|
++dispatched_action_instances;
|
|
const std::string id = action.at("id");
|
|
SCOPED_TRACE(std::string(case_id) + "/" + std::string(mode) + "/" + id);
|
|
EXPECT_TRUE(ids.insert(id).second);
|
|
EXPECT_FALSE(action.at("api").get<std::string>().empty());
|
|
EXPECT_FALSE(action.at("group").get<std::string>().empty());
|
|
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message(case_id, mode)))).at("type"),
|
|
"case_state");
|
|
std::optional<double> argument;
|
|
if (!action.at("argument_input").get<std::string>().empty())
|
|
argument = action.at("argument_default").get<double>();
|
|
const auto result = invoke_action(session, id, argument);
|
|
EXPECT_EQ(result.at("type"), "case_state");
|
|
EXPECT_EQ(result.at("case").at("id").get<std::string>(), case_id);
|
|
EXPECT_EQ(result.at("frame_mode").at("id").get<std::string>(), mode);
|
|
EXPECT_TRUE(result.at("telemetry").contains("kernel_observer"));
|
|
}
|
|
}
|
|
}
|
|
EXPECT_GT(dispatched_action_instances, 150U);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, CanvasSpecificActionsProduceObservableStateChanges) {
|
|
{
|
|
Gallery_Plot_Session session;
|
|
const auto opened = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("axis_lab", "manual"))));
|
|
const auto before = opened.at("telemetry").at("axis_lab").at("time_axis_point_count")
|
|
.get<std::size_t>();
|
|
const auto after = invoke_action(session, "append_time").at("telemetry");
|
|
EXPECT_EQ(after.at("axis_lab").at("time_axis_point_count").get<std::size_t>(),
|
|
before + 1);
|
|
EXPECT_NE(after.at("last_action_result").get<std::string>().find("tick="),
|
|
std::string::npos);
|
|
}
|
|
{
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "manual")))).at("type"),
|
|
"case_state");
|
|
auto telemetry = invoke_action(session, "add_line_marker", 96e6).at("telemetry");
|
|
EXPECT_EQ(telemetry.at("spectrum").at("selectable_line_markers"), 1);
|
|
telemetry = invoke_action(session, "select_marker", 0).at("telemetry");
|
|
EXPECT_EQ(telemetry.at("spectrum").at("selected_marker_index"), 0);
|
|
telemetry = invoke_action(session, "set_marker_frequency", 99e6).at("telemetry");
|
|
EXPECT_DOUBLE_EQ(telemetry.at("spectrum").at("selected_marker_frequency"), 99e6);
|
|
telemetry = invoke_action(session, "clear_markers").at("telemetry");
|
|
EXPECT_EQ(telemetry.at("spectrum").at("selectable_line_markers"), 0);
|
|
EXPECT_EQ(telemetry.at("spectrum").at("selected_marker_index"), -1);
|
|
}
|
|
{
|
|
Gallery_Plot_Session session;
|
|
const auto opened = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("waterfall", "manual"))));
|
|
const auto before_rows = opened.at("telemetry").at("waterfall").at("row_count")
|
|
.get<std::size_t>();
|
|
auto telemetry = invoke_action(session, "append_row").at("telemetry");
|
|
EXPECT_EQ(telemetry.at("waterfall").at("row_count").get<std::size_t>(),
|
|
before_rows + 1);
|
|
telemetry = invoke_action(session, "append_tick_row").at("telemetry");
|
|
EXPECT_EQ(telemetry.at("waterfall").at("row_count").get<std::size_t>(),
|
|
before_rows + 2);
|
|
}
|
|
{
|
|
Gallery_Plot_Session session;
|
|
const auto opened = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("afterglow", "manual"))));
|
|
const auto before = opened.at("telemetry").at("afterglow")
|
|
.at("history_frame_count").get<std::size_t>();
|
|
const auto telemetry = invoke_action(session, "append_spectrum").at("telemetry");
|
|
EXPECT_EQ(telemetry.at("afterglow").at("history_frame_count").get<std::size_t>(),
|
|
before + 1);
|
|
}
|
|
{
|
|
Gallery_Plot_Session session;
|
|
const auto opened = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("sweep_spectrum", "manual"))));
|
|
const auto before = opened.at("telemetry").at("sweep_spectrum")
|
|
.at("stored_block_count").get<std::size_t>();
|
|
const auto telemetry = invoke_action(session, "append_block").at("telemetry");
|
|
EXPECT_EQ(telemetry.at("sweep_spectrum").at("stored_block_count").get<std::size_t>(),
|
|
before + 1);
|
|
}
|
|
{
|
|
Gallery_Plot_Session session;
|
|
const auto opened = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("frequency_trace", "manual"))));
|
|
const auto before = opened.at("telemetry").at("frequency_trace")
|
|
.at("sample_count").get<std::size_t>();
|
|
auto telemetry = invoke_action(session, "append_sample").at("telemetry");
|
|
EXPECT_EQ(telemetry.at("frequency_trace").at("sample_count").get<std::size_t>(),
|
|
before + 1);
|
|
telemetry = invoke_action(session, "append_tick_sample").at("telemetry");
|
|
EXPECT_EQ(telemetry.at("frequency_trace").at("sample_count").get<std::size_t>(),
|
|
before + 2);
|
|
}
|
|
{
|
|
Gallery_Plot_Session session;
|
|
const auto opened = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("constellation", "manual"))));
|
|
const auto before = opened.at("telemetry").at("constellation")
|
|
.at("point_count").get<std::size_t>();
|
|
const auto telemetry = invoke_action(session, "append_points").at("telemetry");
|
|
EXPECT_EQ(telemetry.at("constellation").at("point_count").get<std::size_t>(),
|
|
before + 24);
|
|
}
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, CommonControlsAndViewLifecycleProduceObservableEffects) {
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "manual")))).at("type"),
|
|
"case_state");
|
|
|
|
const auto background_state = patch_controls(session, {{"background_color", "#123456"}});
|
|
ASSERT_EQ(background_state.at("type"), "case_state") << background_state.dump();
|
|
EXPECT_EQ(background_state.at("controls").at("resources").at(0).at("data")
|
|
.at("background_color"), "#123456");
|
|
ASSERT_EQ(invoke_action(session, "mode_cycle").at("type"), "case_state");
|
|
const auto background_frame = session.handle(Frame_Request{});
|
|
ASSERT_TRUE(background_frame.has_value());
|
|
ASSERT_EQ(background_frame->type, Web_Response_Type::Pixels);
|
|
ASSERT_GE(background_frame->payload.size(), pixel_frame_header_size + 4);
|
|
const auto* corner = reinterpret_cast<const std::uint8_t*>(
|
|
background_frame->payload.data() + pixel_frame_header_size);
|
|
EXPECT_EQ(corner[0], 0x12);
|
|
EXPECT_EQ(corner[1], 0x34);
|
|
EXPECT_EQ(corner[2], 0x56);
|
|
EXPECT_EQ(corner[3], 0xff);
|
|
|
|
auto state = patch_controls(session, {
|
|
{"performance_overlay", true},
|
|
{"renderable_visible", false},
|
|
{"cache_mode", "Direct"},
|
|
{"object_name", "Observable_Spectrum"}
|
|
});
|
|
auto telemetry = state.at("telemetry");
|
|
EXPECT_TRUE(telemetry.at("performance_overlay_enabled").get<bool>());
|
|
EXPECT_FALSE(telemetry.at("renderable").at("visible").get<bool>());
|
|
EXPECT_EQ(telemetry.at("renderable").at("cache_mode"), "Direct");
|
|
EXPECT_EQ(telemetry.at("renderable").at("object_name"), "Observable_Spectrum");
|
|
|
|
state = invoke_action(session, "toggle_view");
|
|
EXPECT_FALSE(state.at("telemetry").at("view_active").get<bool>());
|
|
EXPECT_FALSE(session.handle(Frame_Request{}).has_value());
|
|
state = invoke_action(session, "toggle_view");
|
|
EXPECT_TRUE(state.at("telemetry").at("view_active").get<bool>());
|
|
ASSERT_EQ(invoke_action(session, "mode_cycle").at("type"), "case_state");
|
|
EXPECT_TRUE(session.handle(Frame_Request{}).has_value());
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, SelectionOverlayPointerLifecycleCreatesAndClearsRegion) {
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("selection_overlay", "manual")))).at("type"),
|
|
"case_state");
|
|
|
|
const auto resize = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"resize","width":560,"height":320})");
|
|
ASSERT_TRUE(resize.has_value());
|
|
EXPECT_FALSE(session.handle(*resize).has_value());
|
|
constexpr std::array<std::string_view, 3> drag{
|
|
R"({"category":"event","type":"pointer_press","x":120,"y":90,"button":"left","buttons":1,"modifiers":0})",
|
|
R"({"category":"event","type":"pointer_move","x":300,"y":210,"button":"none","buttons":1,"modifiers":0})",
|
|
R"({"category":"event","type":"pointer_release","x":300,"y":210,"button":"left","buttons":0,"modifiers":0})"
|
|
};
|
|
for (const auto encoded : drag) {
|
|
const auto event = Web_Event_Adapter::decode(encoded);
|
|
ASSERT_TRUE(event.has_value());
|
|
EXPECT_FALSE(session.handle(*event).has_value());
|
|
}
|
|
|
|
EXPECT_EQ(observe_telemetry(session).at("selection_regions"), 1);
|
|
EXPECT_EQ(invoke_action(session, "clear_selection").at("telemetry")
|
|
.at("selection_regions"), 0);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, EveryCanvasAcceptsTheCompleteWebEventSetAndStillRendersPixels) {
|
|
constexpr std::array<std::string_view, 8> cases{
|
|
"axis_lab", "spectrum", "afterglow", "sweep_spectrum",
|
|
"waterfall", "frequency_trace", "selection_overlay", "constellation"
|
|
};
|
|
constexpr std::array<std::string_view, 3> modes{"manual", "low_latency", "playback"};
|
|
constexpr std::array<std::string_view, 10> interactive_events{
|
|
R"({"category":"event","type":"show"})",
|
|
R"({"category":"event","type":"pointer_move","x":100,"y":100,"button":"none","buttons":0,"modifiers":0})",
|
|
R"({"category":"event","type":"pointer_press","x":100,"y":100,"button":"left","buttons":1,"modifiers":1})",
|
|
R"({"category":"event","type":"pointer_move","x":180,"y":160,"button":"none","buttons":1,"modifiers":1})",
|
|
R"({"category":"event","type":"pointer_release","x":180,"y":160,"button":"left","buttons":0,"modifiers":1})",
|
|
R"({"category":"event","type":"wheel","x":160,"y":120,"pixelDeltaX":0,"pixelDeltaY":-24,"angleDeltaX":0,"angleDeltaY":192,"buttons":0,"modifiers":2})",
|
|
R"({"category":"event","type":"key_press","key":"Escape","nativeKey":27,"repeat":false,"modifiers":0})",
|
|
R"({"category":"event","type":"key_release","key":"Escape","nativeKey":27,"repeat":false,"modifiers":0})",
|
|
R"({"category":"event","type":"leave"})",
|
|
R"({"category":"event","type":"hide"})"
|
|
};
|
|
|
|
for (const auto case_id : cases) {
|
|
for (const auto mode : modes) {
|
|
SCOPED_TRACE(std::string(case_id) + "/" + std::string(mode));
|
|
Gallery_Plot_Session session(mode == "low_latency");
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message(case_id, mode)))).at("type"),
|
|
"case_state");
|
|
const auto resize = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"resize","width":420,"height":260})");
|
|
ASSERT_TRUE(resize.has_value());
|
|
EXPECT_FALSE(session.handle(*resize).has_value());
|
|
|
|
for (const auto encoded : interactive_events) {
|
|
const auto event = Web_Event_Adapter::decode(encoded);
|
|
ASSERT_TRUE(event.has_value()) << encoded;
|
|
EXPECT_FALSE(session.handle(*event).has_value()) << encoded;
|
|
}
|
|
EXPECT_FALSE(session.handle(Frame_Request{}).has_value());
|
|
|
|
const auto show = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"show"})");
|
|
ASSERT_TRUE(show.has_value());
|
|
EXPECT_FALSE(session.handle(*show).has_value());
|
|
const auto frame = wait_for_pixel_frame(session, 420, 260);
|
|
ASSERT_TRUE(frame.has_value());
|
|
ASSERT_EQ(frame->type, Web_Response_Type::Pixels);
|
|
EXPECT_EQ(frame->payload.substr(0, 4), "RVP1");
|
|
EXPECT_EQ(read_u32_le(frame->payload, 4), 420U);
|
|
EXPECT_EQ(read_u32_le(frame->payload, 8), 260U);
|
|
const auto telemetry = observe_telemetry(session);
|
|
EXPECT_TRUE(telemetry.at("view_active").get<bool>());
|
|
EXPECT_EQ(telemetry.at("viewport").at("width"), 420);
|
|
EXPECT_EQ(telemetry.at("viewport").at("height"), 260);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, InvalidInteractiveEventsAreRejectedBeforeReachingCore2) {
|
|
constexpr std::array<std::string_view, 8> invalid{
|
|
R"({"category":"command","type":"pointer_move","x":1,"y":2})",
|
|
R"({"category":"event","type":"pointer_move","x":1})",
|
|
R"({"category":"event","type":"pointer_press","x":"bad","y":2})",
|
|
R"({"category":"event","type":"wheel","x":1,"y":2})",
|
|
R"({"category":"event","type":"resize","width":"bad","height":200})",
|
|
R"({"category":"event","type":"control","control":"mode","value":"INVALID"})",
|
|
R"({"category":"event","type":"unknown"})",
|
|
"not-json"
|
|
};
|
|
for (const auto encoded : invalid)
|
|
EXPECT_FALSE(Web_Event_Adapter::decode(encoded).has_value()) << encoded;
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, EveryIndependentCore2CanvasBuildsAndRenders) {
|
|
constexpr std::array<std::string_view, 8> cases{
|
|
"axis_lab", "spectrum", "afterglow", "sweep_spectrum",
|
|
"waterfall", "frequency_trace", "selection_overlay", "constellation"
|
|
};
|
|
constexpr std::array<std::string_view, 3> modes{"manual", "low_latency", "playback"};
|
|
for (const std::string_view mode : modes) {
|
|
for (const std::string_view case_id : cases) {
|
|
Gallery_Plot_Session session(mode == "low_latency");
|
|
const auto opened = session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message(case_id, mode)));
|
|
ASSERT_TRUE(opened.has_value()) << case_id;
|
|
ASSERT_EQ(opened->type, Web_Response_Type::Json) << case_id;
|
|
const auto state = parse_json(opened->payload);
|
|
EXPECT_EQ(state.at("type"), "case_state") << case_id;
|
|
EXPECT_EQ(state.at("case").at("id").get<std::string>(), std::string(case_id))
|
|
<< case_id;
|
|
EXPECT_EQ(state.at("frame_mode").at("id"), std::string(mode)) << case_id;
|
|
const auto& resources = state.at("controls").at("resources");
|
|
ASSERT_FALSE(resources.empty()) << case_id;
|
|
for (const auto& resource : resources) {
|
|
EXPECT_EQ(resource.at("descriptor").at("protocol"),
|
|
"adminive.resource") << case_id;
|
|
EXPECT_EQ(resource.at("view").at("protocol"),
|
|
"adminive.view") << case_id;
|
|
}
|
|
EXPECT_FALSE(session.handle(Viewport_Resize{{480, 280}}).has_value()) << case_id;
|
|
const auto frame = wait_for_pixel_frame(session, 480, 280);
|
|
ASSERT_TRUE(frame.has_value()) << case_id;
|
|
ASSERT_EQ(frame->type, Web_Response_Type::Pixels) << case_id;
|
|
EXPECT_EQ(frame->payload.substr(0, 4), "RVP1") << case_id;
|
|
EXPECT_EQ(read_u32_le(frame->payload, 4), 480U) << case_id;
|
|
EXPECT_EQ(read_u32_le(frame->payload, 8), 280U) << case_id;
|
|
const auto observed = session.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe"})"));
|
|
ASSERT_TRUE(observed.has_value()) << case_id;
|
|
const auto telemetry = parse_json(observed->payload);
|
|
EXPECT_EQ(telemetry.at("type"), "observer_state") << case_id;
|
|
EXPECT_EQ(telemetry.at("frame_mode").at("id"), std::string(mode)) << case_id;
|
|
EXPECT_TRUE(telemetry.at("telemetry").contains("kernel_observer")) << case_id;
|
|
EXPECT_TRUE(telemetry.at("telemetry").contains("performance")) << case_id;
|
|
EXPECT_TRUE(telemetry.at("telemetry").contains("data_shape")) << case_id;
|
|
EXPECT_GT(telemetry.at("telemetry").at("data_shape").at("rendered_elements")
|
|
.get<std::size_t>(), 0U) << case_id;
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ActionsMutateLiveCore2ObjectsAndReturnTelemetry) {
|
|
Gallery_Plot_Session session;
|
|
ASSERT_TRUE(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open,
|
|
open_message("spectrum", "low_latency"))));
|
|
|
|
const auto add = session.handle(gallery_request(
|
|
Gallery_Request_Kind::Action,
|
|
R"({"category":"event","type":"gallery_action","action":"add_line_marker","argument":97000000})"));
|
|
ASSERT_TRUE(add.has_value());
|
|
const auto add_state = parse_json(add->payload);
|
|
EXPECT_EQ(add_state.at("type"), "case_state");
|
|
EXPECT_EQ(add_state.at("telemetry").at("spectrum").at("selectable_line_markers"), 1);
|
|
|
|
const auto patch = session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"spectrum","patch":{"interpolation_mode":"Step_Left","max_hold_visible":true}})"));
|
|
ASSERT_TRUE(patch.has_value());
|
|
EXPECT_EQ(parse_json(patch->payload).at("type"), "case_state");
|
|
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ThreeFrameStrategiesExposeTheirRealActionsAndObservers) {
|
|
const auto action = [](Gallery_Plot_Session& session, std::string_view id,
|
|
std::optional<int> argument = std::nullopt) {
|
|
nlohmann::json request{{"category", "event"}, {"type", "gallery_action"},
|
|
{"action", id}};
|
|
if (argument)
|
|
request["argument"] = *argument;
|
|
return session.handle(gallery_request(Gallery_Request_Kind::Action, request.dump()));
|
|
};
|
|
const auto observe = [](Gallery_Plot_Session& session) {
|
|
const auto response = session.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe"})"));
|
|
EXPECT_TRUE(response.has_value());
|
|
return parse_json(response->payload).at("telemetry");
|
|
};
|
|
|
|
Gallery_Plot_Session manual;
|
|
ASSERT_TRUE(manual.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "manual"))));
|
|
ASSERT_TRUE(action(manual, "mode_prepare"));
|
|
auto manual_observer = observe(manual).at("kernel_observer");
|
|
EXPECT_EQ(manual_observer.at("last_event"), "prepared");
|
|
EXPECT_EQ(manual_observer.at("pending_frame_count"), 1);
|
|
ASSERT_TRUE(action(manual, "mode_refresh"));
|
|
ASSERT_TRUE(action(manual, "mode_render"));
|
|
manual_observer = observe(manual).at("kernel_observer");
|
|
EXPECT_EQ(manual_observer.at("last_event"), "rendered");
|
|
EXPECT_GE(manual_observer.at("consumed_frame_count").get<std::uint64_t>(), 2U);
|
|
|
|
Gallery_Plot_Session low_latency;
|
|
ASSERT_TRUE(low_latency.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "low_latency"))));
|
|
const auto high_frequency = low_latency.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"scene","patch":{"max_render_fps":1000000000}})"));
|
|
ASSERT_TRUE(high_frequency.has_value());
|
|
ASSERT_EQ(parse_json(high_frequency->payload).at("type"), "case_state");
|
|
ASSERT_TRUE(low_latency.handle(Frame_Request{}));
|
|
const auto low_telemetry = observe(low_latency);
|
|
EXPECT_EQ(low_telemetry.at("frame_mode"), "low_latency");
|
|
const auto& low_observer = low_telemetry.at("kernel_observer");
|
|
EXPECT_GT(low_observer.at("observation_count").get<std::uint64_t>(), 0U);
|
|
EXPECT_FALSE(low_telemetry.contains("render_fps"));
|
|
EXPECT_FALSE(low_telemetry.contains("low_latency_limit"));
|
|
EXPECT_NE(low_observer.at("limit_state"), "consumer_limited");
|
|
EXPECT_NE(low_observer.at("limit_state"), "frequency_limited");
|
|
EXPECT_TRUE(low_observer.at("limit_state") == "paint_limited" ||
|
|
low_observer.at("limit_state") == "render_limited");
|
|
EXPECT_EQ(low_observer.at("target_interval_ns"), 1);
|
|
EXPECT_EQ(low_observer.at("consumer_interval_ns"), 0);
|
|
EXPECT_GT(low_observer.at("bottleneck_duration_ns").get<std::uint64_t>(), 1U);
|
|
EXPECT_TRUE(low_observer.contains("paint_lease_wait_ns"));
|
|
EXPECT_TRUE(low_observer.contains("render_finish_state_wait_ns"));
|
|
constexpr std::array<std::string_view, 33> observer_fields{
|
|
"mode", "last_event", "limit_state", "frequency_hz", "observation_count",
|
|
"produced_frame_count", "consumed_frame_count", "dropped_frame_count",
|
|
"failed_operation_count", "pending_frame_count", "latest_sequence",
|
|
"paint_duration_ns", "render_duration_ns", "target_interval_ns", "frequency_limit_enabled",
|
|
"consumer_feedback_enabled", "bottleneck_duration_ns", "consumer_sample_interval_ns",
|
|
"consumer_smoothed_interval_ns", "consumer_variation_ns", "consumer_safety_interval_ns", "consumer_interval_ns",
|
|
"next_refresh_interval_ns", "paint_lease_wait_ns",
|
|
"paint_state_wait_ns", "publish_state_wait_ns", "ready_wait_ns",
|
|
"frame_age_at_render_ns", "render_lease_wait_ns", "render_state_wait_ns",
|
|
"render_finish_state_wait_ns", "queue_wait_ns", "end_to_end_ns"
|
|
};
|
|
for (const auto field : observer_fields)
|
|
EXPECT_TRUE(low_observer.contains(field)) << field;
|
|
|
|
Gallery_Plot_Session playback;
|
|
ASSERT_TRUE(playback.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "playback"))));
|
|
ASSERT_TRUE(action(playback, "mode_enqueue_burst", 5));
|
|
auto playback_observer = observe(playback).at("kernel_observer");
|
|
EXPECT_EQ(playback_observer.at("last_event"), "enqueued");
|
|
EXPECT_EQ(playback_observer.at("pending_frame_count"), 5);
|
|
ASSERT_TRUE(action(playback, "mode_dequeue"));
|
|
playback_observer = observe(playback).at("kernel_observer");
|
|
EXPECT_EQ(playback_observer.at("last_event"), "rendered");
|
|
EXPECT_EQ(playback_observer.at("pending_frame_count"), 4);
|
|
EXPECT_GT(playback_observer.at("queue_wait_ns").get<std::uint64_t>(), 0U);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ManualStrategyRunsOnlyExplicitPrepareRefreshRenderCycles) {
|
|
Gallery_Plot_Session session(true);
|
|
const auto opened = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "manual"))));
|
|
ASSERT_EQ(opened.at("type"), "case_state");
|
|
const auto baseline = opened.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(baseline.at("mode"), "manual");
|
|
EXPECT_EQ(baseline.at("limit_state"), "not_applicable");
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(80));
|
|
auto observer = observe_telemetry(session).at("kernel_observer");
|
|
EXPECT_EQ(observer.at("latest_sequence"), baseline.at("latest_sequence"));
|
|
EXPECT_EQ(observer.at("produced_frame_count"), baseline.at("produced_frame_count"));
|
|
EXPECT_EQ(observer.at("consumed_frame_count"), baseline.at("consumed_frame_count"));
|
|
|
|
auto state = invoke_action(session, "mode_prepare");
|
|
observer = state.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("last_event"), "prepared");
|
|
EXPECT_EQ(observer.at("pending_frame_count"), 1);
|
|
EXPECT_EQ(observer.at("produced_frame_count").get<std::uint64_t>(),
|
|
baseline.at("produced_frame_count").get<std::uint64_t>() + 1);
|
|
|
|
state = invoke_action(session, "mode_refresh");
|
|
observer = state.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("last_event"), "refresh_succeeded");
|
|
EXPECT_EQ(observer.at("pending_frame_count"), 0);
|
|
EXPECT_EQ(observer.at("consumed_frame_count"), baseline.at("consumed_frame_count"));
|
|
|
|
state = invoke_action(session, "mode_render");
|
|
observer = state.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("last_event"), "rendered");
|
|
EXPECT_EQ(observer.at("pending_frame_count"), 0);
|
|
EXPECT_EQ(observer.at("consumed_frame_count").get<std::uint64_t>(),
|
|
baseline.at("consumed_frame_count").get<std::uint64_t>() + 1);
|
|
|
|
ASSERT_EQ(invoke_action(session, "mode_prepare").at("type"), "case_state");
|
|
state = invoke_action(session, "mode_discard");
|
|
observer = state.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("last_event"), "manually_discarded");
|
|
EXPECT_EQ(observer.at("pending_frame_count"), 0);
|
|
EXPECT_EQ(observer.at("dropped_frame_count").get<std::uint64_t>(),
|
|
baseline.at("dropped_frame_count").get<std::uint64_t>() + 1);
|
|
|
|
const auto failed_before = observer.at("failed_operation_count").get<std::uint64_t>();
|
|
state = invoke_action(session, "mode_refresh");
|
|
observer = state.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("last_event"), "refresh_failed");
|
|
EXPECT_EQ(observer.at("failed_operation_count").get<std::uint64_t>(), failed_before + 1);
|
|
|
|
const auto sequence_after_actions = observer.at("latest_sequence");
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(80));
|
|
EXPECT_EQ(observe_telemetry(session).at("kernel_observer").at("latest_sequence"),
|
|
sequence_after_actions);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, LowLatencyStrategyKeepsForegroundResponsiveUnderAggressiveFrequency) {
|
|
Gallery_Plot_Session session(true);
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))).at("type"),
|
|
"case_state");
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"scene","patch":{"max_render_fps":1000}})"))).at("type"),
|
|
"case_state");
|
|
|
|
ASSERT_TRUE(wait_for_condition([&session] {
|
|
return successful_render_count(session) >= 4;
|
|
}));
|
|
std::string first_pixels;
|
|
std::string last_pixels;
|
|
for (int iteration = 0; iteration < 12; ++iteration) {
|
|
const auto started = std::chrono::steady_clock::now();
|
|
const auto frame = session.handle(Frame_Request{});
|
|
ASSERT_TRUE(frame.has_value()) << iteration;
|
|
ASSERT_EQ(frame->type, Web_Response_Type::Pixels) << iteration;
|
|
const auto telemetry = observe_telemetry(session);
|
|
EXPECT_LT(std::chrono::steady_clock::now() - started,
|
|
std::chrono::milliseconds(250)) << iteration;
|
|
EXPECT_EQ(telemetry.at("kernel_observer").at("frequency_hz"), 1000.0);
|
|
if (iteration == 0)
|
|
first_pixels = frame->payload;
|
|
last_pixels = frame->payload;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(3));
|
|
}
|
|
EXPECT_NE(first_pixels, last_pixels);
|
|
|
|
const auto telemetry = observe_telemetry(session);
|
|
const auto& observer = telemetry.at("kernel_observer");
|
|
EXPECT_EQ(observer.at("target_interval_ns"), 1'000'000);
|
|
EXPECT_GT(observer.at("latest_sequence").get<std::uint64_t>(), 4U);
|
|
EXPECT_GT(telemetry.at("performance").at("successful_render_count").get<std::uint64_t>(), 4U);
|
|
EXPECT_TRUE(observer.at("limit_state") == "frequency_limited" ||
|
|
observer.at("limit_state") == "paint_limited" ||
|
|
observer.at("limit_state") == "render_limited" ||
|
|
observer.at("limit_state") == "consumer_limited");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, FrequencyLimitCannotBeBypassedByForegroundRescheduleEvents) {
|
|
Gallery_Plot_Session session(true);
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))).at("type"),
|
|
"case_state");
|
|
const auto patched = patch_controls(session, {
|
|
{"frequency_limit_enabled", true},
|
|
{"max_render_fps", 30.0},
|
|
{"feedback", {{"enabled", false}}}
|
|
});
|
|
ASSERT_EQ(patched.at("type"), "case_state");
|
|
const auto& patched_observer = patched.at("telemetry").at("kernel_observer");
|
|
EXPECT_TRUE(patched_observer.at("frequency_limit_enabled").get<bool>());
|
|
EXPECT_FALSE(patched_observer.at("consumer_feedback_enabled").get<bool>());
|
|
EXPECT_EQ(patched_observer.at("target_interval_ns"), 33'333'333);
|
|
ASSERT_TRUE(wait_for_condition([&session] {
|
|
return successful_render_count(session) >= 3;
|
|
}));
|
|
const auto pointer = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"pointer_move","x":320,"y":180,"button":"none","buttons":0,"modifiers":0})");
|
|
ASSERT_TRUE(pointer.has_value());
|
|
const auto before = successful_render_count(session);
|
|
const auto started = std::chrono::steady_clock::now();
|
|
const auto deadline = started + std::chrono::milliseconds(450);
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
EXPECT_FALSE(session.handle(*pointer).has_value());
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
|
}
|
|
const auto elapsed = std::chrono::steady_clock::now() - started;
|
|
const auto after = successful_render_count(session);
|
|
const auto rendered = after - before;
|
|
EXPECT_GT(rendered, 0U);
|
|
EXPECT_LE(rendered, 16U);
|
|
const auto telemetry = observe_telemetry(session);
|
|
const auto& observer = telemetry.at("kernel_observer");
|
|
EXPECT_EQ(observer.at("target_interval_ns"), 33'333'333);
|
|
EXPECT_GE(observer.at("next_refresh_interval_ns").get<std::uint64_t>(), 33'333'333U);
|
|
EXPECT_GE(elapsed, std::chrono::milliseconds(450));
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ConsumerFeedbackCannotBeBypassedByForegroundRescheduleEvents) {
|
|
Gallery_Plot_Session session(true);
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))).at("type"),
|
|
"case_state");
|
|
ASSERT_EQ(patch_controls(session, {
|
|
{"frequency_limit_enabled", false},
|
|
{"feedback", {{"enabled", true}}}
|
|
}).at("type"), "case_state");
|
|
auto observed = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":25,"presentation_fps":25,"websocket_buffered_bytes":0,"changed_pixel_frames":1,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":5,"display_interval_ms":40,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})")));
|
|
auto observer = observed.at("telemetry").at("kernel_observer");
|
|
EXPECT_FALSE(observer.at("frequency_limit_enabled").get<bool>());
|
|
EXPECT_TRUE(observer.at("consumer_feedback_enabled").get<bool>());
|
|
EXPECT_EQ(observer.at("consumer_interval_ns"), 40'000'000);
|
|
ASSERT_TRUE(wait_for_condition([&session] {
|
|
return successful_render_count(session) >= 3;
|
|
}));
|
|
const auto pointer = Web_Event_Adapter::decode(
|
|
R"({"category":"event","type":"pointer_move","x":320,"y":180,"button":"none","buttons":0,"modifiers":0})");
|
|
ASSERT_TRUE(pointer.has_value());
|
|
const auto before = successful_render_count(session);
|
|
const auto started = std::chrono::steady_clock::now();
|
|
const auto deadline = started + std::chrono::milliseconds(450);
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
EXPECT_FALSE(session.handle(*pointer).has_value());
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
|
}
|
|
const auto after = successful_render_count(session);
|
|
const auto rendered = after - before;
|
|
EXPECT_GT(rendered, 0U);
|
|
EXPECT_LE(rendered, 14U);
|
|
observed = observe_telemetry(session);
|
|
observer = observed.at("kernel_observer");
|
|
EXPECT_EQ(observer.at("consumer_interval_ns"), 40'000'000);
|
|
EXPECT_GE(observer.at("next_refresh_interval_ns").get<std::uint64_t>(), 40'000'000U);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, WebRuntimeMetricsPublishKernelSmoothedConsumerFeedbackWithoutTransportFpsCap) {
|
|
Gallery_Plot_Session session(true);
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))).at("type"),
|
|
"case_state");
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"scene","patch":{"max_render_fps":1000000000}})"))).at("type"),
|
|
"case_state");
|
|
auto observed = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":50,"presentation_fps":50,"websocket_buffered_bytes":0,"changed_pixel_frames":10,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":80,"display_interval_ms":20,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})")));
|
|
auto observer = observed.at("telemetry").at("kernel_observer");
|
|
auto feedback = observed.at("telemetry").at("consumer_feedback");
|
|
EXPECT_TRUE(observer.at("frequency_limit_enabled").get<bool>());
|
|
EXPECT_TRUE(observer.at("consumer_feedback_enabled").get<bool>());
|
|
EXPECT_EQ(observer.at("consumer_sample_interval_ns"), 20'000'000);
|
|
EXPECT_EQ(observer.at("consumer_smoothed_interval_ns"), 20'000'000);
|
|
EXPECT_EQ(observer.at("consumer_variation_ns"), 0);
|
|
EXPECT_EQ(observer.at("consumer_safety_interval_ns"), 20'000'000);
|
|
EXPECT_EQ(observer.at("consumer_interval_ns"), 20'000'000);
|
|
EXPECT_EQ(observer.at("limit_state"), "consumer_limited");
|
|
EXPECT_EQ(feedback.at("source"), "presentation");
|
|
observed = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":45,"presentation_fps":45,"websocket_buffered_bytes":0,"changed_pixel_frames":20,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":4,"display_interval_ms":22,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})")));
|
|
observer = observed.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("consumer_sample_interval_ns"), 22'000'000);
|
|
EXPECT_EQ(observer.at("consumer_smoothed_interval_ns"), 21'000'000);
|
|
EXPECT_EQ(observer.at("consumer_variation_ns"), 125'000);
|
|
EXPECT_EQ(observer.at("consumer_safety_interval_ns"), 22'000'000);
|
|
EXPECT_EQ(observer.at("consumer_interval_ns"), 22'000'000);
|
|
observed = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"scene","patch":{"feedback":{"enabled":false},"frequency_limit_enabled":false}})")));
|
|
observer = observed.at("telemetry").at("kernel_observer");
|
|
EXPECT_FALSE(observer.at("frequency_limit_enabled").get<bool>());
|
|
EXPECT_FALSE(observer.at("consumer_feedback_enabled").get<bool>());
|
|
EXPECT_EQ(observer.at("target_interval_ns"), 0);
|
|
EXPECT_EQ(observer.at("consumer_interval_ns"), 0);
|
|
EXPECT_TRUE(observer.at("limit_state") == "paint_limited" ||
|
|
observer.at("limit_state") == "render_limited" ||
|
|
observer.at("limit_state") == "unlimited");
|
|
const auto rejected = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"scene","patch":{"pixel_stream_fps":30}})")));
|
|
EXPECT_EQ(rejected.at("type"), "error");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ConsumerFeedbackSourcesCanBeSelectedIndependentlyAndManually) {
|
|
Gallery_Plot_Session session(true);
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))).at("type"),
|
|
"case_state");
|
|
ASSERT_EQ(patch_controls(session, {
|
|
{"frequency_limit_enabled", false},
|
|
{"feedback", {{"enabled", true}, {"pixel", true},
|
|
{"presentation", false}, {"manual", false}}}
|
|
}).at("type"), "case_state");
|
|
auto observed = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":25,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":1,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":5,"display_interval_ms":16,"display_interval_latest_ms":16,"display_interval_p95_ms":17,"display_jitter_ms":1,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})")));
|
|
auto observer = observed.at("telemetry").at("kernel_observer");
|
|
auto feedback = observed.at("telemetry").at("consumer_feedback");
|
|
EXPECT_EQ(feedback.at("source"), "pixel");
|
|
EXPECT_EQ(feedback.at("pixel_interval_ns"), 40'000'000);
|
|
EXPECT_EQ(feedback.at("presentation_interval_ns"), 16'000'000);
|
|
EXPECT_EQ(observer.at("consumer_sample_interval_ns"), 40'000'000);
|
|
observed = patch_controls(session, {
|
|
{"feedback", {{"pixel", false}, {"presentation", true}}}
|
|
});
|
|
observer = observed.at("telemetry").at("kernel_observer");
|
|
feedback = observed.at("telemetry").at("consumer_feedback");
|
|
EXPECT_EQ(feedback.at("source"), "presentation");
|
|
EXPECT_EQ(observer.at("consumer_sample_interval_ns"), 16'000'000);
|
|
observed = patch_controls(session, {
|
|
{"feedback", {{"presentation", false}, {"manual", true},
|
|
{"manual_fps", 20.0}}}
|
|
});
|
|
observer = observed.at("telemetry").at("kernel_observer");
|
|
feedback = observed.at("telemetry").at("consumer_feedback");
|
|
EXPECT_EQ(feedback.at("source"), "manual");
|
|
EXPECT_EQ(feedback.at("manual_interval_ns"), 50'000'000);
|
|
EXPECT_EQ(observer.at("consumer_sample_interval_ns"), 50'000'000);
|
|
observed = patch_controls(session, {
|
|
{"feedback", {{"manual", false}}}
|
|
});
|
|
observer = observed.at("telemetry").at("kernel_observer");
|
|
feedback = observed.at("telemetry").at("consumer_feedback");
|
|
EXPECT_EQ(feedback.at("source"), "none");
|
|
EXPECT_FALSE(observer.at("consumer_feedback_enabled").get<bool>());
|
|
EXPECT_TRUE(feedback.at("master_enabled").get<bool>());
|
|
EXPECT_EQ(observer.at("consumer_interval_ns"), 0);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, PlaybackStrategyQueuesWithoutAutomaticConsumptionAndReportsEmptyQueue) {
|
|
Gallery_Plot_Session session(true);
|
|
const auto opened = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "playback"))));
|
|
ASSERT_EQ(opened.at("type"), "case_state");
|
|
const auto baseline = opened.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(baseline.at("mode"), "playback");
|
|
EXPECT_EQ(baseline.at("limit_state"), "not_applicable");
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(80));
|
|
auto observer = observe_telemetry(session).at("kernel_observer");
|
|
EXPECT_EQ(observer.at("latest_sequence"), baseline.at("latest_sequence"));
|
|
EXPECT_EQ(observer.at("consumed_frame_count"), baseline.at("consumed_frame_count"));
|
|
|
|
auto state = invoke_action(session, "mode_enqueue_burst", 5);
|
|
observer = state.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("last_event"), "enqueued");
|
|
EXPECT_EQ(observer.at("pending_frame_count"), 5);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
|
EXPECT_EQ(observe_telemetry(session).at("kernel_observer").at("pending_frame_count"), 5);
|
|
|
|
state = invoke_action(session, "mode_dequeue");
|
|
observer = state.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("last_event"), "rendered");
|
|
EXPECT_EQ(observer.at("pending_frame_count"), 4);
|
|
EXPECT_GT(observer.at("queue_wait_ns").get<std::uint64_t>(), 0U);
|
|
|
|
state = invoke_action(session, "mode_cycle");
|
|
observer = state.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("last_event"), "rendered");
|
|
EXPECT_EQ(observer.at("pending_frame_count"), 4);
|
|
|
|
for (int remaining = 3; remaining >= 0; --remaining) {
|
|
state = invoke_action(session, "mode_dequeue");
|
|
observer = state.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("pending_frame_count"), remaining);
|
|
}
|
|
const auto failed_before = observer.at("failed_operation_count").get<std::uint64_t>();
|
|
state = invoke_action(session, "mode_dequeue");
|
|
observer = state.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("last_event"), "queue_empty");
|
|
EXPECT_EQ(observer.at("failed_operation_count").get<std::uint64_t>(), failed_before + 1);
|
|
EXPECT_EQ(observer.at("pending_frame_count"), 0);
|
|
|
|
const auto sequence_after_actions = observer.at("latest_sequence");
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(80));
|
|
EXPECT_EQ(observe_telemetry(session).at("kernel_observer").at("latest_sequence"),
|
|
sequence_after_actions);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, BackendRejectsActionsHiddenByCurrentCaseOrFrameMode) {
|
|
struct Rejected_Action {
|
|
std::string_view mode;
|
|
std::string_view action;
|
|
};
|
|
constexpr std::array rejected{
|
|
Rejected_Action{"manual", "mode_enqueue"},
|
|
Rejected_Action{"low_latency", "mode_refresh"},
|
|
Rejected_Action{"playback", "mode_discard"},
|
|
Rejected_Action{"manual", "append_row"}
|
|
};
|
|
for (const auto& item : rejected) {
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", item.mode)))).at("type"),
|
|
"case_state") << item.mode;
|
|
const auto response = invoke_action(session, item.action);
|
|
EXPECT_EQ(response.at("type"), "error") << item.mode << '/' << item.action;
|
|
EXPECT_TRUE(response.at("field_errors").contains("action"))
|
|
<< item.mode << '/' << item.action;
|
|
}
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ProductionLowLatencySessionRendersWithoutPixelPulls) {
|
|
Gallery_Plot_Session session(true);
|
|
ASSERT_TRUE(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "low_latency"))));
|
|
ASSERT_TRUE(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"scene","patch":{"max_render_fps":120}})")));
|
|
ASSERT_TRUE(wait_for_condition([&session] {
|
|
return successful_render_count(session) >= 4;
|
|
}));
|
|
const auto observed = session.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":120,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":17,"duplicate_pixel_frames":3,"frame_request_timeout_count":2,"frame_round_trip_ms":4.5,"display_interval_ms":16.7,"display_interval_latest_ms":16.9,"display_interval_p95_ms":17.4,"display_jitter_ms":0.7,"overwritten_pixel_frames":5,"last_pixel_receive_age_ms":8.5,"last_pixel_change_age_ms":12.5}})"));
|
|
ASSERT_TRUE(observed.has_value());
|
|
const auto telemetry = parse_json(observed->payload).at("telemetry");
|
|
const auto& performance = telemetry.at("performance");
|
|
EXPECT_TRUE(performance.at("automatic_low_latency_scheduler").get<bool>());
|
|
EXPECT_GE(performance.at("successful_render_count").get<std::uint64_t>(), 4U);
|
|
EXPECT_GT(performance.at("measured_fps").get<double>(), 0.0);
|
|
EXPECT_DOUBLE_EQ(telemetry.at("kernel_observer").at("frequency_hz"), 120.0);
|
|
EXPECT_EQ(telemetry.at("kernel_observer").at("target_interval_ns"), 8'333'333);
|
|
EXPECT_TRUE(telemetry.at("kernel_observer").at("limit_state") == "frequency_limited" ||
|
|
telemetry.at("kernel_observer").at("limit_state") == "paint_limited" ||
|
|
telemetry.at("kernel_observer").at("limit_state") == "render_limited" ||
|
|
telemetry.at("kernel_observer").at("limit_state") == "consumer_limited");
|
|
EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("transport_fps"), 120.0);
|
|
EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("presentation_fps"), 60.0);
|
|
EXPECT_EQ(telemetry.at("client_performance").at("changed_pixel_frames"), 17);
|
|
EXPECT_EQ(telemetry.at("client_performance").at("duplicate_pixel_frames"), 3);
|
|
EXPECT_EQ(telemetry.at("client_performance").at("frame_request_timeout_count"), 2);
|
|
EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("frame_round_trip_ms"), 4.5);
|
|
EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_interval_ms"), 16.7);
|
|
EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_interval_latest_ms"), 16.9);
|
|
EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_interval_p95_ms"), 17.4);
|
|
EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("display_jitter_ms"), 0.7);
|
|
EXPECT_EQ(telemetry.at("client_performance").at("overwritten_pixel_frames"), 5);
|
|
EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("last_pixel_receive_age_ms"), 8.5);
|
|
EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("last_pixel_change_age_ms"), 12.5);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ProductionLowLatencyStopsWhileHiddenAndResumes) {
|
|
Gallery_Plot_Session session(true);
|
|
ASSERT_TRUE(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "low_latency"))));
|
|
ASSERT_TRUE(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"scene","patch":{"max_render_fps":120}})")));
|
|
ASSERT_TRUE(wait_for_condition([&session] {
|
|
return successful_render_count(session) >= 3;
|
|
}));
|
|
set_gallery_view_active(session, false);
|
|
const auto stopped_count = successful_render_count(session);
|
|
EXPECT_FALSE(session.handle(Frame_Request{}).has_value());
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(80));
|
|
EXPECT_EQ(successful_render_count(session), stopped_count);
|
|
set_gallery_view_active(session, true);
|
|
EXPECT_TRUE(wait_for_condition([&session, stopped_count] {
|
|
return successful_render_count(session) > stopped_count;
|
|
}));
|
|
EXPECT_TRUE(session.handle(Frame_Request{}).has_value());
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ProductionLowLatencyOnlyRendersActiveSessions) {
|
|
constexpr std::size_t session_count = 8;
|
|
std::vector<std::unique_ptr<Gallery_Plot_Session>> sessions;
|
|
sessions.reserve(session_count);
|
|
for (std::size_t index = 0; index < session_count; ++index) {
|
|
auto session = std::make_unique<Gallery_Plot_Session>(true);
|
|
ASSERT_TRUE(session->handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message("spectrum", "low_latency"))));
|
|
ASSERT_TRUE(session->handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"scene","patch":{"max_render_fps":60}})")));
|
|
if (index >= 2)
|
|
set_gallery_view_active(*session, false);
|
|
sessions.push_back(std::move(session));
|
|
}
|
|
for (std::size_t index = 0; index < 2; ++index) {
|
|
ASSERT_TRUE(wait_for_condition([&sessions, index] {
|
|
return successful_render_count(*sessions[index]) >= 2;
|
|
}));
|
|
}
|
|
std::array<std::uint64_t, session_count> hidden_counts{};
|
|
for (std::size_t index = 2; index < session_count; ++index)
|
|
hidden_counts[index] = successful_render_count(*sessions[index]);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(80));
|
|
for (std::size_t index = 2; index < session_count; ++index)
|
|
EXPECT_EQ(successful_render_count(*sessions[index]), hidden_counts[index]) << index;
|
|
set_gallery_view_active(*sessions[0], false);
|
|
set_gallery_view_active(*sessions[1], false);
|
|
const auto first_stopped = successful_render_count(*sessions[0]);
|
|
const auto second_stopped = successful_render_count(*sessions[1]);
|
|
set_gallery_view_active(*sessions[2], true);
|
|
set_gallery_view_active(*sessions[3], true);
|
|
EXPECT_TRUE(wait_for_condition([&sessions, baseline = hidden_counts[2]] {
|
|
return successful_render_count(*sessions[2]) > baseline;
|
|
}));
|
|
EXPECT_TRUE(wait_for_condition([&sessions, baseline = hidden_counts[3]] {
|
|
return successful_render_count(*sessions[3]) > baseline;
|
|
}));
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(80));
|
|
EXPECT_EQ(successful_render_count(*sessions[0]), first_stopped);
|
|
EXPECT_EQ(successful_render_count(*sessions[1]), second_stopped);
|
|
}
|
|
|
|
void expect_low_latency_canvas_to_change(std::string_view case_id) {
|
|
SCOPED_TRACE(std::string(case_id));
|
|
Gallery_Plot_Session session(true);
|
|
ASSERT_TRUE(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message(case_id, "low_latency"))));
|
|
ASSERT_FALSE(session.handle(Viewport_Resize{{240, 180}}).has_value());
|
|
ASSERT_TRUE(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"scene","patch":{"max_render_fps":120}})")));
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(90));
|
|
const auto first = session.handle(Frame_Request{});
|
|
ASSERT_TRUE(first.has_value());
|
|
ASSERT_EQ(first->type, Web_Response_Type::Pixels);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(90));
|
|
const auto second = session.handle(Frame_Request{});
|
|
ASSERT_TRUE(second.has_value());
|
|
ASSERT_EQ(second->type, Web_Response_Type::Pixels);
|
|
EXPECT_NE(first->payload, second->payload);
|
|
|
|
const auto observed = session.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe"})"));
|
|
ASSERT_TRUE(observed.has_value());
|
|
const auto telemetry = parse_json(observed->payload).at("telemetry");
|
|
EXPECT_GT(telemetry.at("frame_index").get<std::uint64_t>(), 2U);
|
|
EXPECT_GT(telemetry.at("kernel_observer").at("latest_sequence").get<std::uint64_t>(), 1U);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, AxisLabLowLatencyPixelsChange) {
|
|
expect_low_latency_canvas_to_change("axis_lab");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, SpectrumLowLatencyPixelsChange) {
|
|
expect_low_latency_canvas_to_change("spectrum");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, AfterglowLowLatencyPixelsChange) {
|
|
expect_low_latency_canvas_to_change("afterglow");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, SweepSpectrumLowLatencyPixelsChange) {
|
|
expect_low_latency_canvas_to_change("sweep_spectrum");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, WaterfallLowLatencyPixelsChange) {
|
|
expect_low_latency_canvas_to_change("waterfall");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, FrequencyTraceLowLatencyPixelsChange) {
|
|
expect_low_latency_canvas_to_change("frequency_trace");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, SelectionOverlayLowLatencyPixelsChange) {
|
|
expect_low_latency_canvas_to_change("selection_overlay");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, ConstellationLowLatencyPixelsChange) {
|
|
expect_low_latency_canvas_to_change("constellation");
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, DirectPropertyChangesAndAllImageModesRender) {
|
|
Gallery_Plot_Session constellation;
|
|
ASSERT_TRUE(constellation.handle(gallery_request(
|
|
Gallery_Request_Kind::Open,
|
|
open_message("constellation", "low_latency"))));
|
|
const auto patched = constellation.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"constellation","patch":{"type":"Psk16","phase_offset_radians":0.75}})"));
|
|
ASSERT_TRUE(patched.has_value());
|
|
const auto patched_json = parse_json(patched->payload);
|
|
EXPECT_EQ(patched_json.at("controls").at("resources").at(3).at("data").at("type"),
|
|
"Psk16");
|
|
ASSERT_TRUE(constellation.handle(Frame_Request{}).has_value());
|
|
|
|
Gallery_Plot_Session waterfall;
|
|
ASSERT_TRUE(waterfall.handle(gallery_request(
|
|
Gallery_Request_Kind::Open,
|
|
open_message("waterfall", "low_latency"))));
|
|
for (const std::string_view mode : {"Nearest", "Bilinear", "Bicubic"}) {
|
|
const std::string patch =
|
|
std::string(R"({"category":"event","type":"gallery_patch","target":"waterfall","patch":{"interpolation_mode":")") +
|
|
std::string(mode) + R"("}})";
|
|
ASSERT_TRUE(waterfall.handle(gallery_request(Gallery_Request_Kind::Patch, patch)).has_value())
|
|
<< mode;
|
|
ASSERT_TRUE(waterfall.handle(Frame_Request{}).has_value()) << mode;
|
|
}
|
|
const auto palette = waterfall.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","target":"waterfall","patch":{"color_map":"Ember"}})"));
|
|
ASSERT_TRUE(palette.has_value());
|
|
ASSERT_TRUE(waterfall.handle(Frame_Request{}).has_value());
|
|
const auto waterfall_observer = waterfall.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe"})"));
|
|
ASSERT_TRUE(waterfall_observer.has_value());
|
|
const auto waterfall_telemetry = parse_json(waterfall_observer->payload).at("telemetry");
|
|
EXPECT_GT(waterfall_telemetry.at("waterfall").at("row_count").get<std::size_t>(), 0U);
|
|
EXPECT_GT(waterfall_telemetry.at("waterfall").at("stored_point_count").get<std::size_t>(), 0U);
|
|
EXPECT_GT(waterfall_telemetry.at("waterfall").at("rendered_cell_count").get<std::size_t>(), 0U);
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace renderive::web
|