390 lines
17 KiB
C++
390 lines
17 KiB
C++
#include "../app/Gallery_Plot_Session.h"
|
|
#include "../app/Gallery_Protocol.h"
|
|
#include "../app/common/Pixel_Frame.h"
|
|
|
|
#include <gtest/gtest.h>
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <optional>
|
|
#include <set>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <thread>
|
|
#include <utility>
|
|
|
|
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);
|
|
}
|
|
|
|
Gallery_Request gallery_request(Gallery_Request_Kind kind, std::string message) {
|
|
return {kind, std::move(message)};
|
|
}
|
|
|
|
std::string open_message(std::string_view mode) {
|
|
return std::string(
|
|
R"({"category":"event","type":"gallery_open","case":"datoviz_3d","frame_mode":")") +
|
|
std::string(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 nlohmann::json::parse(response->payload);
|
|
}
|
|
|
|
nlohmann::json open_scene(Gallery_Plot_Session& session, std::string_view mode) {
|
|
return response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Open, open_message(mode))));
|
|
}
|
|
|
|
nlohmann::json invoke_action(Gallery_Plot_Session& session,
|
|
std::string_view action_id) {
|
|
const nlohmann::json request{{"category", "event"},
|
|
{"type", "gallery_action"},
|
|
{"action", action_id}};
|
|
return response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Action, request.dump())));
|
|
}
|
|
|
|
std::string request_pixels(Gallery_Plot_Session& session) {
|
|
const auto response = session.handle(Frame_Request{});
|
|
if (!response) {
|
|
ADD_FAILURE() << "expected a pixel response";
|
|
return {};
|
|
}
|
|
EXPECT_EQ(response->type, Web_Response_Type::Pixels);
|
|
return response->payload;
|
|
}
|
|
|
|
void expect_actual_datoviz_pixels(const std::string& frame,
|
|
std::uint32_t expected_width,
|
|
std::uint32_t expected_height) {
|
|
ASSERT_GE(frame.size(), pixel_frame_header_size);
|
|
ASSERT_EQ(frame.substr(0, 4), "RVP1");
|
|
ASSERT_EQ(read_u32_le(frame, 4), expected_width);
|
|
ASSERT_EQ(read_u32_le(frame, 8), expected_height);
|
|
ASSERT_EQ(read_u32_le(frame, 12), expected_width * 4U);
|
|
ASSERT_EQ(frame.size(), pixel_frame_header_size +
|
|
static_cast<std::size_t>(expected_width) *
|
|
expected_height * 4U);
|
|
|
|
const auto* pixels = reinterpret_cast<const std::uint8_t*>(
|
|
frame.data() + pixel_frame_header_size);
|
|
const std::array<std::uint8_t, 4> background{
|
|
pixels[0], pixels[1], pixels[2], pixels[3]};
|
|
std::size_t non_background{};
|
|
std::size_t red_dominant{};
|
|
std::size_t green_dominant{};
|
|
std::size_t blue_dominant{};
|
|
const std::size_t pixel_count =
|
|
static_cast<std::size_t>(expected_width) * expected_height;
|
|
for (std::size_t index = 0; index < pixel_count; ++index) {
|
|
const auto* pixel = pixels + index * 4U;
|
|
non_background += pixel[0] != background[0] ||
|
|
pixel[1] != background[1] ||
|
|
pixel[2] != background[2] ||
|
|
pixel[3] != background[3];
|
|
red_dominant += pixel[0] > static_cast<unsigned>(pixel[1]) + 20U &&
|
|
pixel[0] > static_cast<unsigned>(pixel[2]) + 20U;
|
|
green_dominant += pixel[1] > static_cast<unsigned>(pixel[0]) + 20U &&
|
|
pixel[1] > static_cast<unsigned>(pixel[2]) + 20U;
|
|
blue_dominant += pixel[2] > static_cast<unsigned>(pixel[0]) + 20U &&
|
|
pixel[2] > static_cast<unsigned>(pixel[1]) + 20U;
|
|
}
|
|
EXPECT_GT(non_background, 0U);
|
|
EXPECT_GT(red_dominant, 32U);
|
|
EXPECT_GT(green_dominant, 32U);
|
|
EXPECT_GT(blue_dominant, 32U);
|
|
}
|
|
|
|
std::optional<double> blue_centroid_x(const std::string& frame) {
|
|
if (frame.size() < pixel_frame_header_size)
|
|
return std::nullopt;
|
|
const std::uint32_t width = read_u32_le(frame, 4);
|
|
const std::uint32_t height = read_u32_le(frame, 8);
|
|
if (frame.size() != pixel_frame_header_size +
|
|
static_cast<std::size_t>(width) * height * 4U)
|
|
return std::nullopt;
|
|
const auto* pixels = reinterpret_cast<const std::uint8_t*>(
|
|
frame.data() + pixel_frame_header_size);
|
|
double x_sum{};
|
|
std::size_t count{};
|
|
for (std::uint32_t y = 0; y < height; ++y) {
|
|
for (std::uint32_t x = 0; x < width; ++x) {
|
|
const auto* pixel = pixels +
|
|
(static_cast<std::size_t>(y) * width + x) * 4U;
|
|
if (pixel[2] > static_cast<unsigned>(pixel[0]) + 20U &&
|
|
pixel[2] > static_cast<unsigned>(pixel[1]) + 20U) {
|
|
x_sum += x;
|
|
++count;
|
|
}
|
|
}
|
|
}
|
|
return count == 0 ? std::nullopt
|
|
: std::optional<double>(x_sum / static_cast<double>(count));
|
|
}
|
|
|
|
void manual_render_cycle(Gallery_Plot_Session& session) {
|
|
ASSERT_EQ(invoke_action(session, "mode_prepare").at("type"), "case_state");
|
|
ASSERT_EQ(invoke_action(session, "mode_refresh").at("type"), "case_state");
|
|
ASSERT_EQ(invoke_action(session, "mode_render").at("type"), "case_state");
|
|
}
|
|
|
|
std::set<std::string> action_ids(const nlohmann::json& state) {
|
|
std::set<std::string> result;
|
|
for (const auto& action : state.at("actions").at("data"))
|
|
result.insert(action.at("id").get<std::string>());
|
|
return result;
|
|
}
|
|
|
|
TEST(RenderiveWebDatovizGallery, RgbaEncoderPreservesRowsAndRemovesPadding) {
|
|
std::array<std::uint8_t, 24> source{};
|
|
const std::array<std::uint8_t, 16> expected{
|
|
255, 1, 2, 255, 3, 254, 4, 255,
|
|
5, 6, 253, 255, 252, 7, 8, 255};
|
|
std::copy_n(expected.begin(), 8, source.begin());
|
|
std::copy_n(expected.begin() + 8, 8, source.begin() + 12);
|
|
source[8] = source[9] = source[10] = source[11] = 99;
|
|
|
|
const std::string frame = encode_rgba8_pixel_frame(
|
|
reinterpret_cast<const std::byte*>(source.data()), 2, 2, 12);
|
|
ASSERT_EQ(frame.size(), pixel_frame_header_size + expected.size());
|
|
EXPECT_EQ(frame.substr(0, 4), "RVP1");
|
|
EXPECT_EQ(read_u32_le(frame, 4), 2U);
|
|
EXPECT_EQ(read_u32_le(frame, 8), 2U);
|
|
EXPECT_EQ(read_u32_le(frame, 12), 8U);
|
|
EXPECT_TRUE(std::equal(
|
|
expected.begin(), expected.end(),
|
|
reinterpret_cast<const std::uint8_t*>(
|
|
frame.data() + pixel_frame_header_size)));
|
|
EXPECT_TRUE(encode_rgba8_pixel_frame(
|
|
reinterpret_cast<const std::byte*>(source.data()), 2, 2, 7)
|
|
.empty());
|
|
}
|
|
|
|
TEST(RenderiveWebDatovizGallery, CatalogUsesOnlyThreeDimensionalActions) {
|
|
const auto catalog = nlohmann::json::parse(Gallery_Protocol::catalog_json());
|
|
const std::set<std::string> visual_cases{
|
|
"datoviz_3d", "datoviz_splat", "datoviz_pixel", "datoviz_marker",
|
|
"datoviz_sphere", "datoviz_segment", "datoviz_vector",
|
|
"datoviz_primitive", "datoviz_mesh", "datoviz_path", "datoviz_image",
|
|
"datoviz_labels", "datoviz_glyph", "datoviz_text", "datoviz_volume"};
|
|
for (const auto& id : visual_cases) {
|
|
EXPECT_NE(std::find_if(
|
|
catalog.at("cases").begin(), catalog.at("cases").end(),
|
|
[&id](const nlohmann::json& value) {
|
|
return value.at("id") == id;
|
|
}),
|
|
catalog.at("cases").end())
|
|
<< id;
|
|
}
|
|
const auto item = std::find_if(
|
|
catalog.at("cases").begin(), catalog.at("cases").end(),
|
|
[](const nlohmann::json& value) { return value.at("id") == "datoviz_3d"; });
|
|
ASSERT_NE(item, catalog.at("cases").end());
|
|
EXPECT_EQ(item->at("category"), "Datoviz Visuals");
|
|
EXPECT_EQ(item->at("control_count"), 0);
|
|
EXPECT_EQ(item->at("control_count_by_mode").at("manual"), 0);
|
|
EXPECT_EQ(item->at("control_count_by_mode").at("low_latency"), 0);
|
|
EXPECT_EQ(item->at("control_count_by_mode").at("playback"), 0);
|
|
|
|
const std::string empty_controls =
|
|
R"({"resources":[],"observers":[],"render_plan":null,"performance_capture":null})";
|
|
const auto state_for = [&](Gallery_Frame_Mode mode) {
|
|
return nlohmann::json::parse(Gallery_Protocol::case_json_from_controls(
|
|
"datoviz_3d", empty_controls, "{}", {}, mode, false));
|
|
};
|
|
const std::set<std::string> common{
|
|
"add_mesh", "change_text", "move_mesh", "remove_mesh", "reset",
|
|
"reset_camera", "mesh_churn", "toggle_projection", "toggle_texture"};
|
|
auto expected = common;
|
|
expected.insert({"mode_prepare", "mode_refresh", "mode_render"});
|
|
EXPECT_EQ(action_ids(state_for(Gallery_Frame_Mode::Manual)), expected);
|
|
expected = common;
|
|
expected.insert({"mode_prepare", "mode_render"});
|
|
EXPECT_EQ(action_ids(state_for(Gallery_Frame_Mode::Low_Latency)), expected);
|
|
expected = common;
|
|
expected.insert({"mode_enqueue", "mode_dequeue"});
|
|
EXPECT_EQ(action_ids(state_for(Gallery_Frame_Mode::Playback)), expected);
|
|
EXPECT_FALSE(action_ids(state_for(Gallery_Frame_Mode::Manual)).contains("mode_discard"));
|
|
EXPECT_FALSE(action_ids(state_for(Gallery_Frame_Mode::Low_Latency)).contains("mode_discard"));
|
|
}
|
|
|
|
TEST(RenderiveWebDatovizGallery, PublishesRealRgbaAndRendersAfterResize) {
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(open_scene(session, "low_latency").at("type"), "case_state");
|
|
const std::string initial = request_pixels(session);
|
|
expect_actual_datoviz_pixels(initial, 560, 320);
|
|
|
|
EXPECT_FALSE(session.handle(Viewport_Resize{{900, 500}}).has_value());
|
|
ASSERT_EQ(invoke_action(session, "mode_prepare").at("type"), "case_state");
|
|
ASSERT_EQ(invoke_action(session, "mode_render").at("type"), "case_state");
|
|
const std::string resized = request_pixels(session);
|
|
expect_actual_datoviz_pixels(resized, 900, 500);
|
|
|
|
const auto observed = response_json(session.handle(gallery_request(
|
|
Gallery_Request_Kind::Observe,
|
|
R"({"category":"event","type":"gallery_observe","client_metrics":{}})")));
|
|
EXPECT_TRUE(observed.at("telemetry").contains("scheduler"));
|
|
const auto& performance = observed.at("telemetry").at("performance");
|
|
EXPECT_GT(performance.at("measured_fps").get<double>(), 0.0);
|
|
EXPECT_GT(performance.at("pixel_response_fps").get<double>(), 0.0);
|
|
EXPECT_GT(performance.at("last_render_ms").get<double>(), 0.0);
|
|
EXPECT_GT(performance.at("last_pixel_encode_ms").get<double>(), 0.0);
|
|
}
|
|
|
|
TEST(RenderiveWebDatovizGallery, AutomaticLowLatencyFramesCarryAnimatedState) {
|
|
Gallery_Plot_Session session(true);
|
|
ASSERT_EQ(open_scene(session, "low_latency").at("type"), "case_state");
|
|
const std::string first = request_pixels(session);
|
|
expect_actual_datoviz_pixels(first, 560, 320);
|
|
|
|
std::string later;
|
|
const auto deadline = std::chrono::steady_clock::now() +
|
|
std::chrono::seconds(5);
|
|
do {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(40));
|
|
later = request_pixels(session);
|
|
} while (later == first && std::chrono::steady_clock::now() < deadline);
|
|
ASSERT_NE(later, first);
|
|
expect_actual_datoviz_pixels(later, 560, 320);
|
|
}
|
|
|
|
TEST(RenderiveWebDatovizGallery, ManualRenderUsesThePreparedLogicalState) {
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(open_scene(session, "manual").at("type"), "case_state");
|
|
ASSERT_EQ(invoke_action(session, "move_mesh").at("type"), "case_state");
|
|
ASSERT_EQ(invoke_action(session, "mode_prepare").at("type"), "case_state");
|
|
ASSERT_EQ(invoke_action(session, "move_mesh").at("type"), "case_state");
|
|
ASSERT_EQ(invoke_action(session, "mode_refresh").at("type"), "case_state");
|
|
ASSERT_EQ(invoke_action(session, "mode_render").at("type"), "case_state");
|
|
const std::string prepared = request_pixels(session);
|
|
expect_actual_datoviz_pixels(prepared, 560, 320);
|
|
|
|
manual_render_cycle(session);
|
|
const std::string current = request_pixels(session);
|
|
expect_actual_datoviz_pixels(current, 560, 320);
|
|
EXPECT_NE(current, prepared);
|
|
}
|
|
|
|
TEST(RenderiveWebDatovizGallery, PlaybackDequeuesHistoricalStatesInOrder) {
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(open_scene(session, "playback").at("type"), "case_state");
|
|
for (int index = 0; index < 20; ++index)
|
|
ASSERT_EQ(invoke_action(session, "mode_enqueue").at("type"), "case_state");
|
|
|
|
ASSERT_EQ(invoke_action(session, "mode_dequeue").at("type"), "case_state");
|
|
const std::string first = request_pixels(session);
|
|
const auto first_x = blue_centroid_x(first);
|
|
ASSERT_TRUE(first_x.has_value());
|
|
|
|
ASSERT_EQ(invoke_action(session, "mode_dequeue").at("type"), "case_state");
|
|
const std::string second = request_pixels(session);
|
|
const auto second_x = blue_centroid_x(second);
|
|
ASSERT_TRUE(second_x.has_value());
|
|
EXPECT_GT(*second_x, *first_x);
|
|
|
|
std::string last = second;
|
|
for (int index = 2; index < 20; ++index) {
|
|
ASSERT_EQ(invoke_action(session, "mode_dequeue").at("type"), "case_state");
|
|
last = request_pixels(session);
|
|
}
|
|
const auto last_x = blue_centroid_x(last);
|
|
ASSERT_TRUE(last_x.has_value());
|
|
EXPECT_GT(*last_x, *second_x);
|
|
EXPECT_NE(last, second);
|
|
}
|
|
|
|
TEST(RenderiveWebDatovizGallery, AddedAndChurnedVisualsAreActuallyRemoved) {
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(open_scene(session, "manual").at("type"), "case_state");
|
|
const std::string baseline = request_pixels(session);
|
|
|
|
ASSERT_EQ(invoke_action(session, "add_mesh").at("type"), "case_state");
|
|
manual_render_cycle(session);
|
|
const std::string added = request_pixels(session);
|
|
EXPECT_NE(added, baseline);
|
|
const auto centroid_y = [&](const auto& matches) -> std::optional<double> {
|
|
const auto* pixels = reinterpret_cast<const std::uint8_t*>(
|
|
added.data() + pixel_frame_header_size);
|
|
double y_sum{};
|
|
std::size_t count{};
|
|
for (std::uint32_t y = 0; y < 320; ++y) {
|
|
for (std::uint32_t x = 0; x < 560; ++x) {
|
|
const auto* pixel = pixels +
|
|
(static_cast<std::size_t>(y) * 560U + x) * 4U;
|
|
if (matches(pixel[0], pixel[1], pixel[2])) {
|
|
y_sum += y;
|
|
++count;
|
|
}
|
|
}
|
|
}
|
|
return count == 0
|
|
? std::nullopt
|
|
: std::optional<double>(y_sum / static_cast<double>(count));
|
|
};
|
|
const auto green_y = centroid_y([](unsigned red, unsigned green, unsigned blue) {
|
|
return green > red + 20U && green > blue + 20U;
|
|
});
|
|
const auto yellow_y = centroid_y([](unsigned red, unsigned green, unsigned blue) {
|
|
return red > 100U && green > 100U && blue < 100U;
|
|
});
|
|
ASSERT_TRUE(green_y.has_value());
|
|
ASSERT_TRUE(yellow_y.has_value());
|
|
EXPECT_LT(*yellow_y, *green_y)
|
|
<< "Frame_3D_View must expose top-left-origin RGBA rows";
|
|
|
|
ASSERT_EQ(invoke_action(session, "remove_mesh").at("type"), "case_state");
|
|
manual_render_cycle(session);
|
|
const std::string removed = request_pixels(session);
|
|
EXPECT_EQ(removed, baseline);
|
|
|
|
const auto churned = invoke_action(session, "mesh_churn");
|
|
ASSERT_EQ(churned.at("type"), "case_state");
|
|
EXPECT_EQ(churned.at("telemetry").at("kernel_observer").at("last_event"),
|
|
"mesh_churned");
|
|
const std::string after_churn = request_pixels(session);
|
|
expect_actual_datoviz_pixels(after_churn, 560, 320);
|
|
EXPECT_EQ(after_churn, baseline);
|
|
}
|
|
|
|
TEST(RenderiveWebDatovizGallery, TextureAndTextActionsReachTheThreeDimensionalBackend) {
|
|
Gallery_Plot_Session session;
|
|
ASSERT_EQ(open_scene(session, "manual").at("type"), "case_state");
|
|
const std::string baseline = request_pixels(session);
|
|
expect_actual_datoviz_pixels(baseline, 560, 320);
|
|
|
|
ASSERT_EQ(invoke_action(session, "toggle_texture").at("type"), "case_state");
|
|
manual_render_cycle(session);
|
|
const std::string without_texture = request_pixels(session);
|
|
EXPECT_NE(without_texture, baseline);
|
|
|
|
ASSERT_EQ(invoke_action(session, "toggle_texture").at("type"), "case_state");
|
|
manual_render_cycle(session);
|
|
const std::string restored_texture = request_pixels(session);
|
|
EXPECT_EQ(restored_texture, baseline);
|
|
|
|
ASSERT_EQ(invoke_action(session, "change_text").at("type"), "case_state");
|
|
manual_render_cycle(session);
|
|
const std::string changed_text = request_pixels(session);
|
|
EXPECT_NE(changed_text, restored_texture);
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace renderive::web
|