Files
Renderive/web_server/tests/Datoviz_Gallery_Tests.cpp
T
2026-08-13 23:10:04 +08:00

341 lines
14 KiB
C++

#include "../app/Gallery_Plot_Session.h"
#include "../app/Gallery_Protocol.h"
#include "../app/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 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"), "3D");
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_sphere", "move_sphere", "remove_sphere", "reset_camera",
"sphere_churn", "toggle_projection"};
auto expected = common;
expected.insert({"mode_prepare", "mode_refresh", "mode_render", "mode_discard"});
EXPECT_EQ(action_ids(state_for(Gallery_Frame_Mode::Manual)), expected);
expected = common;
expected.insert({"mode_prepare", "mode_render", "mode_discard"});
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);
}
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);
}
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_sphere").at("type"), "case_state");
ASSERT_EQ(invoke_action(session, "mode_prepare").at("type"), "case_state");
ASSERT_EQ(invoke_action(session, "move_sphere").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");
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_sphere").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_sphere").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, "sphere_churn");
ASSERT_EQ(churned.at("type"), "case_state");
EXPECT_EQ(churned.at("telemetry").at("kernel_observer").at("last_event"),
"sphere_churned");
const std::string after_churn = request_pixels(session);
expect_actual_datoviz_pixels(after_churn, 560, 320);
EXPECT_EQ(after_churn, baseline);
}
} // namespace
} // namespace renderive::web