1484 lines
80 KiB
C++
1484 lines
80 KiB
C++
#include "../app/Gallery_Plot_Session.h"
|
|
#include "../app/Gallery_Protocol.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 <variant>
|
|
#include <vector>
|
|
|
|
namespace renderive::web {
|
|
namespace {
|
|
|
|
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) {
|
|
const nlohmann::json request{{"category", "event"}, {"type", "gallery_patch"},
|
|
{"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(), 15U);
|
|
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"));
|
|
}
|
|
const auto state = Gallery_Protocol::default_state("spectrum", Gallery_Frame_Mode::Manual);
|
|
const auto contract = parse_json(Gallery_Protocol::case_json(
|
|
"spectrum", state, "{}", {}, Gallery_Frame_Mode::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, BackendMenuMapsRetainedControlApis) {
|
|
constexpr std::array<std::string_view, 8> cases{
|
|
"axis_lab", "spectrum", "afterglow", "sweep_spectrum",
|
|
"waterfall", "frequency_trace", "selection_overlay", "constellation"
|
|
};
|
|
std::string api_text;
|
|
constexpr Gallery_Frame_Mode modes[]{Gallery_Frame_Mode::Manual,
|
|
Gallery_Frame_Mode::Low_Latency,
|
|
Gallery_Frame_Mode::Playback};
|
|
for (const auto case_id : cases) {
|
|
for (const auto mode : modes) {
|
|
const auto contract = parse_json(Gallery_Protocol::case_json(
|
|
case_id, Gallery_Protocol::default_state(case_id, mode), "{}", {}, mode));
|
|
for (const auto& control : contract.at("controls").at("descriptor").at("fields"))
|
|
api_text += control.at("presentation").at("description").get<std::string>() + '\n';
|
|
for (const auto& action : contract.at("actions").at("data"))
|
|
api_text += action.at("api").get<std::string>() + '\n';
|
|
}
|
|
}
|
|
constexpr std::array<std::string_view, 41> required{
|
|
"Plot_Core::set_background_color", "Plot_Core::set_max_render_fps",
|
|
"Plot_Core::activate_view", "Plot_Core::remove_renderable",
|
|
"Renderable::set_visible", "Renderable::set_cache_mode", "Renderable::set_object_name",
|
|
"Abs_Axis::set_x", "Abs_Axis::set_y", "Abs_Axis::set_orientation",
|
|
"Abs_Axis::set_pixel_length", "Abs_Axis::set_locale", "Abs_Axis::set_unit_text_font",
|
|
"Axis::set_coord_range", "Axis::set_coord_start", "Axis::set_coord_length",
|
|
"Axis::set_use_wheel", "Axis::set_use_drag", "Time_Axis::set_time_format",
|
|
"Time_Axis::append_time", "Spectrum::set<&Properties::frequency_point_size>",
|
|
"Spectrum::set<&Properties::interpolation_mode>", "Spectrum::set<&Properties::current_pen>", "Spectrum::set<&Properties::max_brush>",
|
|
"Spectrum::update_samples", "Spectrum::power_at", "Spectrum::add_custom_marker",
|
|
"Spectrum::set_marker_frequency", "Waterfall::set<&Properties::frequency_range>",
|
|
"Waterfall::set<&Properties::interpolation_mode>", "Waterfall::append_row", "Waterfall::set<&Properties::color_map>",
|
|
"Afterglow::set<&Properties::attenuation_rate>", "Afterglow::append_spectrum",
|
|
"Sweep_Spectrum::set<&Properties::bins_per_block>", "Sweep_Spectrum::append_block",
|
|
"Frequency_Trace::set<&Properties::pen>", "Selection_Rectangle_Overlay::set<&Properties::selection_brush>",
|
|
"Selection_Rectangle_Overlay::clear_selected_regions",
|
|
"Constellation_Diagram::set<&Properties::type>", "Constellation_Diagram::fit_square_to_axes"
|
|
};
|
|
for (const auto api : required)
|
|
EXPECT_NE(api_text.find(api), std::string::npos) << api;
|
|
EXPECT_NE(api_text.find("Plot_Core::refresh_manual_frame"), std::string::npos);
|
|
EXPECT_NE(api_text.find("Plot_Core::prepare_frame"), std::string::npos);
|
|
EXPECT_NE(api_text.find("Plot_Core::render_prepared_frame"), std::string::npos);
|
|
EXPECT_NE(api_text.find("Flow_Refresh_Strategy"), std::string::npos);
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, BackendControlSchemaValidatesEveryPatch) {
|
|
const Gallery_State current = Gallery_Protocol::default_state("spectrum");
|
|
const auto accepted = Gallery_Protocol::apply_patch(
|
|
"spectrum", current,
|
|
R"({"category":"event","type":"gallery_patch","patch":{"current_pen":"#ff8844","frequency_point_size":1024,"line_interpolation":"Cubic_Value","max_render_fps":1000}})");
|
|
ASSERT_TRUE(accepted.candidate.has_value());
|
|
EXPECT_EQ(std::get<std::string>(gallery_property_value(*accepted.candidate, "current_pen")), "#ff8844");
|
|
EXPECT_DOUBLE_EQ(std::get<double>(gallery_property_value(*accepted.candidate, "frequency_point_size")), 1024);
|
|
EXPECT_DOUBLE_EQ(std::get<double>(gallery_property_value(*accepted.candidate, "max_render_fps")), 1000);
|
|
|
|
const auto excessive_fps = Gallery_Protocol::apply_patch(
|
|
"spectrum", current,
|
|
R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":1000000001}})");
|
|
EXPECT_FALSE(excessive_fps.candidate.has_value());
|
|
EXPECT_TRUE(parse_json(excessive_fps.response_json).at("field_errors").contains("max_render_fps"));
|
|
|
|
const auto invalid_color = Gallery_Protocol::apply_patch(
|
|
"spectrum", current,
|
|
R"({"category":"event","type":"gallery_patch","patch":{"current_pen":"red"}})");
|
|
EXPECT_FALSE(invalid_color.candidate.has_value());
|
|
EXPECT_TRUE(parse_json(invalid_color.response_json).at("field_errors").contains("current_pen"));
|
|
|
|
const auto foreign_control = Gallery_Protocol::apply_patch(
|
|
"spectrum", current,
|
|
R"({"category":"event","type":"gallery_patch","patch":{"image_interpolation":"Bicubic"}})");
|
|
EXPECT_FALSE(foreign_control.candidate.has_value());
|
|
EXPECT_TRUE(parse_json(foreign_control.response_json).at("field_errors").contains("image_interpolation"));
|
|
}
|
|
|
|
TEST(RenderiveWebGallery, EveryPublishedControlValidatesItsCompleteInputContract) {
|
|
constexpr std::array<std::string_view, 8> cases{
|
|
"axis_lab", "spectrum", "afterglow", "sweep_spectrum",
|
|
"waterfall", "frequency_trace", "selection_overlay", "constellation"
|
|
};
|
|
struct Mode {
|
|
std::string_view id;
|
|
Gallery_Frame_Mode value;
|
|
};
|
|
constexpr std::array modes{
|
|
Mode{"manual", Gallery_Frame_Mode::Manual},
|
|
Mode{"low_latency", Gallery_Frame_Mode::Low_Latency},
|
|
Mode{"playback", Gallery_Frame_Mode::Playback}
|
|
};
|
|
|
|
std::size_t validated_control_instances{};
|
|
for (const auto case_id : cases) {
|
|
for (const auto mode : modes) {
|
|
const auto defaults = Gallery_Protocol::default_state(case_id, mode.value);
|
|
const auto contract = parse_json(Gallery_Protocol::case_json(
|
|
case_id, defaults, "{}", {}, mode.value));
|
|
const auto& resource = contract.at("controls");
|
|
const auto& controls = resource.at("descriptor").at("fields");
|
|
const auto& data = resource.at("data");
|
|
EXPECT_EQ(controls.size(), gallery_property_count(defaults)) << case_id << '/' << mode.id;
|
|
std::set<std::string> ids;
|
|
for (const auto& control : controls) {
|
|
++validated_control_instances;
|
|
const std::string id = control.at("name");
|
|
const auto& presentation = control.at("presentation");
|
|
SCOPED_TRACE(std::string(case_id) + "/" + std::string(mode.id) + "/" + id);
|
|
EXPECT_TRUE(ids.insert(id).second);
|
|
EXPECT_TRUE(control.at("editable").get<bool>());
|
|
EXPECT_FALSE(presentation.at("description").get<std::string>().empty());
|
|
|
|
const std::string input = presentation.at("control");
|
|
const auto& current_value = data.at(id);
|
|
const auto apply = [&](const nlohmann::json& value) {
|
|
nlohmann::json request{{"category", "event"}, {"type", "gallery_patch"},
|
|
{"patch", {{id, value}}}};
|
|
return Gallery_Protocol::apply_patch(case_id, defaults, request.dump(),
|
|
mode.value);
|
|
};
|
|
const auto expect_rejected = [&](const nlohmann::json& value) {
|
|
const auto result = apply(value);
|
|
EXPECT_FALSE(result.candidate.has_value());
|
|
EXPECT_TRUE(parse_json(result.response_json).at("field_errors").contains(id));
|
|
};
|
|
|
|
if (input == "boolean") {
|
|
const bool alternative = !current_value.get<bool>();
|
|
const auto accepted = apply(alternative);
|
|
ASSERT_TRUE(accepted.candidate.has_value());
|
|
EXPECT_EQ(std::get<bool>(gallery_property_value(*accepted.candidate, id)), alternative);
|
|
expect_rejected(1);
|
|
} else if (input == "number") {
|
|
const double minimum = control.at("minimum");
|
|
const double maximum = control.at("maximum");
|
|
const double current = current_value;
|
|
const double step = std::max(control.at("multiple_of").get<double>(), 1e-9);
|
|
double alternative = current + step;
|
|
if (alternative > maximum)
|
|
alternative = current - step;
|
|
if (alternative < minimum || alternative == current)
|
|
alternative = current == minimum ? maximum : minimum;
|
|
const bool integer = control.at("value_type") == "integer";
|
|
if (integer)
|
|
alternative = std::round(alternative);
|
|
|
|
const nlohmann::json alternative_value = integer ?
|
|
nlohmann::json(static_cast<std::int64_t>(std::llround(alternative))) :
|
|
nlohmann::json(alternative);
|
|
const auto accepted = apply(alternative_value);
|
|
ASSERT_TRUE(accepted.candidate.has_value());
|
|
EXPECT_DOUBLE_EQ(std::get<double>(gallery_property_value(*accepted.candidate, id)),
|
|
alternative);
|
|
EXPECT_TRUE(apply(integer ? nlohmann::json(static_cast<std::int64_t>(minimum)) : nlohmann::json(minimum)).candidate.has_value());
|
|
EXPECT_TRUE(apply(integer ? nlohmann::json(static_cast<std::int64_t>(maximum)) : nlohmann::json(maximum)).candidate.has_value());
|
|
expect_rejected(minimum - std::max(1.0, std::abs(minimum) * 0.1 + 1.0));
|
|
expect_rejected(maximum + std::max(1.0, std::abs(maximum) * 0.1 + 1.0));
|
|
expect_rejected("not-a-number");
|
|
if (integer && maximum - minimum >= 1.0)
|
|
expect_rejected(std::clamp(std::floor(current) + 0.5,
|
|
minimum + 0.5, maximum - 0.5));
|
|
} else if (input == "select") {
|
|
const auto& options = presentation.at("options");
|
|
ASSERT_FALSE(options.empty());
|
|
const std::string current = current_value;
|
|
const auto alternative = std::find_if(
|
|
options.begin(), options.end(), [¤t](const auto& option) {
|
|
return option.at("value").template get<std::string>() != current;
|
|
});
|
|
ASSERT_NE(alternative, options.end());
|
|
const std::string value = alternative->at("value").get<std::string>();
|
|
const auto accepted = apply(value);
|
|
ASSERT_TRUE(accepted.candidate.has_value());
|
|
EXPECT_EQ(std::get<std::string>(gallery_property_value(*accepted.candidate, id)), value);
|
|
expect_rejected("__invalid_gallery_option__");
|
|
expect_rejected(7);
|
|
} else if (input == "color") {
|
|
const std::string value = current_value == "#123456" ?
|
|
"#654321" : "#123456";
|
|
const auto accepted = apply(value);
|
|
ASSERT_TRUE(accepted.candidate.has_value());
|
|
EXPECT_EQ(std::get<std::string>(gallery_property_value(*accepted.candidate, id)), value);
|
|
expect_rejected("red");
|
|
expect_rejected(7);
|
|
} else {
|
|
ASSERT_EQ(input, "text");
|
|
std::string value = current_value.get<std::string>() + "_qa";
|
|
const auto accepted = apply(value);
|
|
ASSERT_TRUE(accepted.candidate.has_value());
|
|
EXPECT_EQ(std::get<std::string>(gallery_property_value(*accepted.candidate, id)), value);
|
|
expect_rejected(std::string(161, 'x'));
|
|
expect_rejected(7);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
EXPECT_GT(validated_control_instances, 180U);
|
|
}
|
|
|
|
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, EveryPublishedControlAppliesToTheLiveCore2Scene) {
|
|
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 applied_control_instances{};
|
|
for (const auto case_id : cases) {
|
|
for (const auto mode : modes) {
|
|
Gallery_Plot_Session session;
|
|
auto state = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message(case_id, mode))));
|
|
ASSERT_EQ(state.at("type"), "case_state");
|
|
const auto published_controls = state.at("controls").at("descriptor").at("fields");
|
|
for (const auto& published : published_controls) {
|
|
++applied_control_instances;
|
|
const std::string id = published.at("name");
|
|
SCOPED_TRACE(std::string(case_id) + "/" + std::string(mode) + "/" + id);
|
|
const std::string input = published.at("presentation").at("control");
|
|
const auto& current_value = state.at("controls").at("data").at(id);
|
|
nlohmann::json alternative;
|
|
if (input == "boolean") {
|
|
alternative = !current_value.get<bool>();
|
|
} else if (input == "number") {
|
|
const double minimum = published.at("minimum");
|
|
const double maximum = published.at("maximum");
|
|
const double current = current_value;
|
|
const double step = std::max(published.at("multiple_of").get<double>(), 1e-9);
|
|
double value = current + step;
|
|
if (value > maximum)
|
|
value = current - step;
|
|
if (value < minimum || value == current)
|
|
value = current == minimum ? maximum : minimum;
|
|
const bool integer = published.at("value_type") == "integer";
|
|
if (integer)
|
|
value = std::round(value);
|
|
alternative = integer ? nlohmann::json(static_cast<std::int64_t>(std::llround(value))) : nlohmann::json(value);
|
|
} else if (input == "select") {
|
|
const auto& options = published.at("presentation").at("options");
|
|
const std::string current = current_value;
|
|
const auto selected = std::find_if(
|
|
options.begin(), options.end(), [¤t](const auto& option) {
|
|
return option.at("value").template get<std::string>() != current;
|
|
});
|
|
ASSERT_NE(selected, options.end());
|
|
alternative = selected->at("value");
|
|
} else if (input == "color") {
|
|
alternative = current_value == "#123456" ? "#654321" : "#123456";
|
|
} else {
|
|
ASSERT_EQ(input, "text");
|
|
alternative = current_value.get<std::string>() + "_live";
|
|
}
|
|
|
|
nlohmann::json request{{"category", "event"}, {"type", "gallery_patch"},
|
|
{"patch", {{id, alternative}}}};
|
|
state = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch, request.dump())));
|
|
ASSERT_EQ(state.at("type"), "case_state") << state.dump();
|
|
EXPECT_EQ(state.at("frame_mode").at("id").get<std::string>(), mode);
|
|
EXPECT_EQ(state.at("controls").at("data").at(id), alternative);
|
|
EXPECT_TRUE(state.at("telemetry").contains("kernel_observer"));
|
|
}
|
|
}
|
|
}
|
|
EXPECT_GT(applied_control_instances, 180U);
|
|
}
|
|
|
|
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"}});
|
|
EXPECT_EQ(background_state.at("controls").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;
|
|
EXPECT_EQ(state.at("controls").at("descriptor").at("protocol"),
|
|
"adminive.resource") << case_id;
|
|
EXPECT_EQ(state.at("controls").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","patch":{"line_interpolation":"Step_Left","max_hold_visible":true}})"));
|
|
ASSERT_TRUE(patch.has_value());
|
|
EXPECT_EQ(parse_json(patch->payload).at("type"), "case_state");
|
|
|
|
const auto rebuild = session.handle(gallery_request(
|
|
Gallery_Request_Kind::Action,
|
|
R"({"category":"event","type":"gallery_action","action":"rebuild_renderable"})"));
|
|
ASSERT_TRUE(rebuild.has_value());
|
|
EXPECT_EQ(parse_json(rebuild->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","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");
|
|
EXPECT_GT(low_telemetry.at("kernel_observer").at("observation_count").get<std::uint64_t>(), 0U);
|
|
EXPECT_TRUE(low_telemetry.contains("render_fps"));
|
|
ASSERT_TRUE(low_telemetry.contains("low_latency_limit"));
|
|
const auto& limit = low_telemetry.at("low_latency_limit");
|
|
EXPECT_FALSE(limit.at("consumer_limited").get<bool>());
|
|
EXPECT_FALSE(limit.at("frequency_limited").get<bool>());
|
|
EXPECT_TRUE(limit.at("paint_limited").get<bool>() || limit.at("render_limited").get<bool>());
|
|
EXPECT_EQ(limit.at("target_interval_ns"), 1);
|
|
EXPECT_EQ(limit.at("consumer_interval_ns"), 0);
|
|
EXPECT_GT(limit.at("bottleneck_duration_ns").get<std::uint64_t>(), 1U);
|
|
EXPECT_TRUE(low_telemetry.at("kernel_observer").contains("paint_lease_wait_ns"));
|
|
EXPECT_TRUE(low_telemetry.at("kernel_observer").contains("render_finish_state_wait_ns"));
|
|
constexpr std::array<std::string_view, 33> observer_fields{
|
|
"mode", "last_event", "limit_state", "configured_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_telemetry.at("kernel_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","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("configured_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},
|
|
{"consumer_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},
|
|
{"consumer_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","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 limit = observed.at("telemetry").at("low_latency_limit");
|
|
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(limit.at("consumer_feedback_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","patch":{"consumer_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","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},
|
|
{"consumer_feedback_enabled", true},
|
|
{"consumer_pixel_feedback_enabled", true},
|
|
{"consumer_presentation_feedback_enabled", false},
|
|
{"consumer_manual_feedback_enabled", 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");
|
|
EXPECT_EQ(observer.at("consumer_feedback_source"), "pixel");
|
|
EXPECT_EQ(observer.at("consumer_pixel_interval_ns"), 40'000'000);
|
|
EXPECT_EQ(observer.at("consumer_presentation_interval_ns"), 16'000'000);
|
|
EXPECT_EQ(observer.at("consumer_sample_interval_ns"), 40'000'000);
|
|
observed = patch_controls(session, {
|
|
{"consumer_pixel_feedback_enabled", false},
|
|
{"consumer_presentation_feedback_enabled", true}
|
|
});
|
|
observer = observed.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("consumer_feedback_source"), "presentation");
|
|
EXPECT_EQ(observer.at("consumer_sample_interval_ns"), 16'000'000);
|
|
observed = patch_controls(session, {
|
|
{"consumer_presentation_feedback_enabled", false},
|
|
{"consumer_manual_feedback_enabled", true},
|
|
{"consumer_manual_fps", 20.0}
|
|
});
|
|
observer = observed.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("consumer_feedback_source"), "manual");
|
|
EXPECT_EQ(observer.at("consumer_manual_interval_ns"), 50'000'000);
|
|
EXPECT_EQ(observer.at("consumer_sample_interval_ns"), 50'000'000);
|
|
observed = patch_controls(session, {
|
|
{"consumer_manual_feedback_enabled", false}
|
|
});
|
|
observer = observed.at("telemetry").at("kernel_observer");
|
|
EXPECT_EQ(observer.at("consumer_feedback_source"), "none");
|
|
EXPECT_FALSE(observer.at("consumer_feedback_enabled").get<bool>());
|
|
EXPECT_TRUE(observer.at("consumer_feedback_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","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("configured_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","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","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","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, PropertyChangesRebuildAndAllImageModesRender) {
|
|
Gallery_Plot_Session constellation;
|
|
ASSERT_TRUE(constellation.handle(gallery_request(
|
|
Gallery_Request_Kind::Open,
|
|
open_message("constellation", "low_latency"))));
|
|
const auto rebuilt = constellation.handle(gallery_request(
|
|
Gallery_Request_Kind::Patch,
|
|
R"({"category":"event","type":"gallery_patch","patch":{"constellation_type":"PSK16","phase_offset":0.75}})"));
|
|
ASSERT_TRUE(rebuilt.has_value());
|
|
const auto rebuilt_json = parse_json(rebuilt->payload);
|
|
EXPECT_EQ(rebuilt_json.at("controls").at("data").at("constellation_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","patch":{"image_interpolation":")") +
|
|
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","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
|