merge(upgrade): port particle extensions onto official native 13.6.1 (unverified)
This commit is contained in:
@@ -18,10 +18,21 @@ add_library(
|
||||
${PROJECT_SOURCE_DIR}/test/math/minmax.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/math/wrap.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/platform/settings.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/particle/field_runtime.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/particle/particle_gpu_math.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/particle/field_binding.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/particle/field_profile_contract_json.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/particle/qbin_v2_decoder.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/particle/qbin_v3_decoder.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/particle/encoded_field_transport.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/plugin/plugin.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/image_manager.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/pattern_atlas.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/renderable.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/particle_viewport_reseed.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/weather_color_texture_lifecycle.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/retained_underlay_cache.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/render_orchestrator_error.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/renderer/shader_registry.test.cpp
|
||||
$<$<BOOL:${MLN_WITH_WEBGPU}>:${PROJECT_SOURCE_DIR}/test/renderer/wgsl_preprocessor.test.cpp>
|
||||
${PROJECT_SOURCE_DIR}/test/sprite/sprite_loader.test.cpp
|
||||
@@ -64,6 +75,7 @@ add_library(
|
||||
${PROJECT_SOURCE_DIR}/test/style/properties.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/style/property_expression.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/style/source.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/source/raster_array_v4_host_api.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/style/style.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/style/style_image.test.cpp
|
||||
${PROJECT_SOURCE_DIR}/test/style/style_layer.test.cpp
|
||||
@@ -153,6 +165,10 @@ if(MLN_WITH_OPENGL)
|
||||
)
|
||||
endif()
|
||||
|
||||
if(MLN_WITH_METAL)
|
||||
target_sources(mbgl-test PRIVATE ${PROJECT_SOURCE_DIR}/test/renderer/mtl_retained_attachments.test.cpp)
|
||||
endif()
|
||||
|
||||
|
||||
if(MLN_WITH_VULKAN)
|
||||
target_sources(
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
#include <mln/platform/gl_functions.hpp>
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
|
||||
using namespace mln::platform;
|
||||
|
||||
TEST(GLFunctions, OpenGLES) {
|
||||
@@ -253,8 +257,15 @@ TEST(GLFunctions, OpenGLES) {
|
||||
EXPECT_NE(glGetProgramBinary, nullptr);
|
||||
EXPECT_NE(glProgramBinary, nullptr);
|
||||
EXPECT_NE(glProgramParameteri, nullptr);
|
||||
#if defined(__APPLE__)
|
||||
#if TARGET_OS_IPHONE || TARGET_OS_SIMULATOR
|
||||
EXPECT_NE(glInvalidateFramebuffer, nullptr);
|
||||
EXPECT_NE(glInvalidateSubFramebuffer, nullptr);
|
||||
#endif
|
||||
#else
|
||||
EXPECT_NE(glInvalidateFramebuffer, nullptr);
|
||||
EXPECT_NE(glInvalidateSubFramebuffer, nullptr);
|
||||
#endif
|
||||
EXPECT_NE(glTexStorage2D, nullptr);
|
||||
EXPECT_NE(glTexStorage3D, nullptr);
|
||||
EXPECT_NE(glGetInternalformativ, nullptr);
|
||||
|
||||
13
test/particle/encoded_field_transport.test.cpp
Normal file
13
test/particle/encoded_field_transport.test.cpp
Normal file
@@ -0,0 +1,13 @@
|
||||
#include <mln/particle/ingress/encoded_field_transport.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace mln::particle::ingress {
|
||||
|
||||
TEST(ParticleEncodedFieldTransport, RejectsEmptyAndOversizedBeforeCopy) {
|
||||
EXPECT_FALSE(isValidEncodedFieldTransportSize(0));
|
||||
EXPECT_TRUE(isValidEncodedFieldTransportSize(MaxEncodedFieldTransportBytes));
|
||||
EXPECT_FALSE(isValidEncodedFieldTransportSize(MaxEncodedFieldTransportBytes + 1u));
|
||||
}
|
||||
|
||||
} // namespace mln::particle::ingress
|
||||
313
test/particle/field_binding.test.cpp
Normal file
313
test/particle/field_binding.test.cpp
Normal file
@@ -0,0 +1,313 @@
|
||||
#include <mln/particle/field/field_binding.hpp>
|
||||
#include <mln/particle/field/field_projection_materialization.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace mln;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint64_t Hour = 3'600'000;
|
||||
constexpr uint64_t Base = 1'700'000'000'000ULL;
|
||||
|
||||
particle::DecodedFieldPlane makePlane(uint16_t semantic, uint16_t group, particle::FieldValueClass valueClass,
|
||||
particle::FieldComponent component, uint16_t unit, std::string identity,
|
||||
std::shared_ptr<const std::vector<uint8_t>> bytes) {
|
||||
return {{semantic, group, component}, particle::FieldDType::UInt8, valueClass, unit, false, 4, 1.0f, 0.0f,
|
||||
std::move(identity), std::move(bytes)};
|
||||
}
|
||||
|
||||
std::shared_ptr<const particle::DecodedFieldFrame> makeFrame(std::string field, uint64_t time, uint64_t generation,
|
||||
std::string suffix, bool vector = true,
|
||||
bool scalar = true) {
|
||||
const auto bytes = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{1, 2, 3, 4});
|
||||
const auto bits = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{15});
|
||||
particle::DecodedFieldFrame frame;
|
||||
frame.frameContentIdentity = {std::move(field), generation, generation, time};
|
||||
// All frames in these series represent one forecast reference cycle.
|
||||
frame.referenceTimeMs = Base;
|
||||
frame.grid = {.layout = 1, .projection = 1, .sampleOrder = 1, .gridRegistration = 1, .width = 2, .height = 2,
|
||||
.west = 0, .south = 0, .east = 1, .north = 1};
|
||||
frame.validityContentIdentity = "valid-" + suffix;
|
||||
frame.validityBits = bits;
|
||||
frame.validityPlane = makePlane(900, 0, particle::FieldValueClass::Validity, particle::FieldComponent::None,
|
||||
particle::FieldUnitNone, frame.validityContentIdentity, bits);
|
||||
frame.validityPlane.dtype = particle::FieldDType::Bitset;
|
||||
frame.validityPlane.requiredFlag = true;
|
||||
if (vector) {
|
||||
frame.planes.push_back(makePlane(1, 7, particle::FieldValueClass::VelocityComponent, particle::FieldComponent::UEast,
|
||||
particle::FieldUnitMetersPerSecond, "u-" + suffix, bytes));
|
||||
frame.planes.push_back(makePlane(2, 7, particle::FieldValueClass::VelocityComponent, particle::FieldComponent::VNorth,
|
||||
particle::FieldUnitMetersPerSecond, "v-" + suffix, bytes));
|
||||
}
|
||||
if (scalar) frame.planes.push_back(makePlane(50, 0, particle::FieldValueClass::Scalar, particle::FieldComponent::None,
|
||||
42, "s-" + suffix, bytes));
|
||||
return std::make_shared<const particle::DecodedFieldFrame>(std::move(frame));
|
||||
}
|
||||
|
||||
void submit(particle::EncodedFieldStore& store, const std::string& key,
|
||||
std::shared_ptr<const particle::DecodedFieldFrame> frame) {
|
||||
ASSERT_EQ(store.submit(key, std::move(frame)), particle::FieldSeriesSubmitResult::Inserted);
|
||||
}
|
||||
|
||||
particle::ColorBinding color(std::string key = "weather.wave") {
|
||||
return {std::move(key), {50, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 42};
|
||||
}
|
||||
particle::ParticleBinding wind() {
|
||||
particle::ParticleBinding binding;
|
||||
binding.fieldKey = "weather.wind"; binding.uPlane = {1, 7, particle::FieldComponent::None};
|
||||
binding.vPlane = {2, 7, particle::FieldComponent::None}; binding.expectedUnitID = particle::FieldUnitMetersPerSecond;
|
||||
return binding;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ParticleFieldBinding, ResolvesIndependentFieldKeyCadence) {
|
||||
particle::EncodedFieldStore store;
|
||||
for (uint64_t hour : {0ULL, 1ULL, 2ULL}) submit(store, "weather.wind", makeFrame("wind", Base + hour * Hour, 1, "w" + std::to_string(hour)));
|
||||
for (uint64_t hour : {0ULL, 3ULL, 6ULL}) submit(store, "weather.wave", makeFrame("wave", Base + hour * Hour, 1, "a" + std::to_string(hour)));
|
||||
const auto c = particle::BindingProjectionResolver::resolve(store, color(), Base + Hour + Hour / 2);
|
||||
const auto p = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour + Hour / 2);
|
||||
ASSERT_TRUE(c.accepted()); ASSERT_TRUE(p.accepted());
|
||||
EXPECT_FLOAT_EQ(c.projection->sampleState.mix, 0.5f);
|
||||
EXPECT_FLOAT_EQ(p.projection->sampleState.mix, 0.5f);
|
||||
EXPECT_EQ(c.projection->contentIdentity.t0Coordinate.validTimeMs, Base);
|
||||
EXPECT_EQ(c.projection->contentIdentity.t1Coordinate.validTimeMs, Base + 3 * Hour);
|
||||
EXPECT_EQ(p.projection->contentIdentity.t0Coordinate.validTimeMs, Base + Hour);
|
||||
EXPECT_EQ(p.projection->contentIdentity.t1Coordinate.validTimeMs, Base + 2 * Hour);
|
||||
EXPECT_NE(c.projection->t0Frame, p.projection->t0Frame);
|
||||
EXPECT_NE(c.projection->t0ValidityStorage, p.projection->t0ValidityStorage);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldBinding, SingleFrameProjectsAtItsExactTimeAndUpgradesToInterpolation) {
|
||||
particle::EncodedFieldStore store;
|
||||
submit(store, "weather.wind", makeFrame("wind", Base, 1, "single"));
|
||||
|
||||
const auto colorAtFrame = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base);
|
||||
const auto particleAtFrame = particle::BindingProjectionResolver::resolve(store, wind(), Base);
|
||||
ASSERT_TRUE(colorAtFrame.accepted());
|
||||
ASSERT_TRUE(particleAtFrame.accepted());
|
||||
EXPECT_EQ(colorAtFrame.projection->t0Frame, colorAtFrame.projection->t1Frame);
|
||||
EXPECT_EQ(particleAtFrame.projection->t0Frame, particleAtFrame.projection->t1Frame);
|
||||
EXPECT_FLOAT_EQ(colorAtFrame.projection->sampleState.mix, 0.0f);
|
||||
EXPECT_FLOAT_EQ(particleAtFrame.projection->sampleState.mix, 0.0f);
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + 1).error,
|
||||
particle::BindingProjectionError::WindowUnavailable);
|
||||
|
||||
const auto colorSnapshot = particle::materializeColorProjection(*colorAtFrame.projection);
|
||||
const auto particleSnapshot = particle::materializeParticleProjection(*particleAtFrame.projection);
|
||||
ASSERT_TRUE(colorSnapshot);
|
||||
ASSERT_TRUE(particleSnapshot);
|
||||
EXPECT_EQ(colorSnapshot->t0, colorSnapshot->t1);
|
||||
EXPECT_EQ(particleSnapshot->t0, particleSnapshot->t1);
|
||||
EXPECT_EQ(colorSnapshot->t0->scalar, (std::vector<float>{1, 2, 3, 4}));
|
||||
EXPECT_TRUE(particleSnapshot->t0->hasVector());
|
||||
|
||||
particle::EncodedFieldStore scalarOnly;
|
||||
submit(scalarOnly, "weather.rain", makeFrame("rain", Base, 1, "scalar-only", false));
|
||||
const auto scalarProjection = particle::BindingProjectionResolver::resolve(
|
||||
scalarOnly, color("weather.rain"), Base);
|
||||
ASSERT_TRUE(scalarProjection.accepted());
|
||||
const auto scalarSnapshot = particle::materializeColorProjection(*scalarProjection.projection);
|
||||
ASSERT_TRUE(scalarSnapshot);
|
||||
EXPECT_TRUE(scalarSnapshot->t0->hasScalar());
|
||||
EXPECT_FALSE(scalarSnapshot->t0->hasVector());
|
||||
auto invalidParticleBinding = wind();
|
||||
invalidParticleBinding.fieldKey = "weather.rain";
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(scalarOnly, invalidParticleBinding, Base).error,
|
||||
particle::BindingProjectionError::PlaneMissing);
|
||||
|
||||
submit(store, "weather.wind", makeFrame("wind", Base + 4 * Hour, 1, "later"));
|
||||
const auto interpolated = particle::BindingProjectionResolver::resolve(store, wind(), Base + 2 * Hour);
|
||||
ASSERT_TRUE(interpolated.accepted());
|
||||
EXPECT_NE(interpolated.projection->t0Frame, interpolated.projection->t1Frame);
|
||||
EXPECT_FLOAT_EQ(interpolated.projection->sampleState.mix, 0.5f);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldBinding, MixChangesSampleStateButNotContentIdentityOrStorage) {
|
||||
particle::EncodedFieldStore store;
|
||||
submit(store, "weather.wave", makeFrame("wave", Base, 1, "0"));
|
||||
submit(store, "weather.wave", makeFrame("wave", Base + 4 * Hour, 1, "4"));
|
||||
const auto at0 = particle::BindingProjectionResolver::resolve(store, color(), Base);
|
||||
const auto at1 = particle::BindingProjectionResolver::resolve(store, color(), Base + Hour);
|
||||
const auto at2 = particle::BindingProjectionResolver::resolve(store, color(), Base + 2 * Hour);
|
||||
const auto at3 = particle::BindingProjectionResolver::resolve(store, color(), Base + 14'256'000);
|
||||
ASSERT_TRUE(at0.accepted()); ASSERT_TRUE(at1.accepted()); ASSERT_TRUE(at2.accepted()); ASSERT_TRUE(at3.accepted());
|
||||
EXPECT_FLOAT_EQ(at0.projection->sampleState.mix, 0.0f); EXPECT_FLOAT_EQ(at1.projection->sampleState.mix, 0.25f);
|
||||
EXPECT_FLOAT_EQ(at2.projection->sampleState.mix, 0.5f); EXPECT_FLOAT_EQ(at3.projection->sampleState.mix, 0.99f);
|
||||
EXPECT_EQ(at0.projection->t0Frame, at0.projection->t1Frame);
|
||||
EXPECT_EQ(at1.projection->contentIdentity, at2.projection->contentIdentity);
|
||||
EXPECT_EQ(at1.projection->contentIdentity, at3.projection->contentIdentity);
|
||||
EXPECT_EQ(at0.projection->t0PlaneStorage, at3.projection->t0PlaneStorage);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldBinding, ProjectionIdentityIsolatedBySelectedRevision) {
|
||||
particle::EncodedFieldStore store;
|
||||
submit(store, "weather.wind", makeFrame("wind", Base, 1, "0"));
|
||||
submit(store, "weather.wind", makeFrame("wind", Base + Hour, 1, "1"));
|
||||
const auto beforeColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2);
|
||||
const auto beforeParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2);
|
||||
ASSERT_TRUE(beforeColor.accepted()); ASSERT_TRUE(beforeParticle.accepted());
|
||||
auto revision = *beforeParticle.projection->t0Frame;
|
||||
revision.frameContentIdentity.generation = 2;
|
||||
revision.frameContentIdentity.dataVersion = 2;
|
||||
revision.planes.back().contentIdentity = "s-revised";
|
||||
ASSERT_EQ(store.submit("weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(revision))),
|
||||
particle::FieldSeriesSubmitResult::ReplacedRevision);
|
||||
const auto afterColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2);
|
||||
const auto afterParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2);
|
||||
ASSERT_TRUE(afterColor.accepted()); ASSERT_TRUE(afterParticle.accepted());
|
||||
EXPECT_NE(beforeColor.projection->contentIdentity, afterColor.projection->contentIdentity);
|
||||
EXPECT_EQ(beforeParticle.projection->contentIdentity, afterParticle.projection->contentIdentity);
|
||||
EXPECT_EQ(beforeParticle.projection->t0FirstPlaneStorage, afterParticle.projection->t0FirstPlaneStorage);
|
||||
EXPECT_EQ(beforeParticle.projection->t0ValidityStorage, afterParticle.projection->t0ValidityStorage);
|
||||
|
||||
auto vectorRevision = *afterParticle.projection->t0Frame;
|
||||
vectorRevision.frameContentIdentity.generation = 3;
|
||||
vectorRevision.frameContentIdentity.dataVersion = 3;
|
||||
vectorRevision.planes[0].contentIdentity = "u-revised";
|
||||
vectorRevision.planes[1].contentIdentity = "v-revised";
|
||||
ASSERT_EQ(store.submit("weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(vectorRevision))),
|
||||
particle::FieldSeriesSubmitResult::ReplacedRevision);
|
||||
const auto afterVectorColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2);
|
||||
const auto afterVectorParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2);
|
||||
ASSERT_TRUE(afterVectorColor.accepted()); ASSERT_TRUE(afterVectorParticle.accepted());
|
||||
EXPECT_EQ(afterColor.projection->contentIdentity, afterVectorColor.projection->contentIdentity);
|
||||
EXPECT_NE(afterParticle.projection->contentIdentity, afterVectorParticle.projection->contentIdentity);
|
||||
|
||||
auto validityRevision = *afterVectorParticle.projection->t0Frame;
|
||||
validityRevision.frameContentIdentity.generation = 4;
|
||||
validityRevision.frameContentIdentity.dataVersion = 4;
|
||||
validityRevision.validityContentIdentity = "valid-revised";
|
||||
validityRevision.validityBits = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{14});
|
||||
validityRevision.validityPlane.contentIdentity = validityRevision.validityContentIdentity;
|
||||
validityRevision.validityPlane.storage = validityRevision.validityBits;
|
||||
ASSERT_EQ(store.submit("weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(validityRevision))),
|
||||
particle::FieldSeriesSubmitResult::ReplacedRevision);
|
||||
const auto afterValidityColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2);
|
||||
const auto afterValidityParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2);
|
||||
ASSERT_TRUE(afterValidityColor.accepted()); ASSERT_TRUE(afterValidityParticle.accepted());
|
||||
EXPECT_NE(afterVectorColor.projection->contentIdentity, afterValidityColor.projection->contentIdentity);
|
||||
EXPECT_NE(afterVectorParticle.projection->contentIdentity, afterValidityParticle.projection->contentIdentity);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldBinding, VectorComponentsIgnoresInactiveDirectionSelectorInIdentity) {
|
||||
particle::EncodedFieldStore store;
|
||||
submit(store, "weather.wind", makeFrame("wind", Base, 1, "0"));
|
||||
submit(store, "weather.wind", makeFrame("wind", Base + Hour, 1, "1"));
|
||||
|
||||
const auto first = wind();
|
||||
auto inactiveDirectionChanged = first;
|
||||
inactiveDirectionChanged.directionPlane = {99, 4, particle::FieldComponent::WUp};
|
||||
EXPECT_EQ(first, inactiveDirectionChanged);
|
||||
const auto firstProjection = particle::BindingProjectionResolver::resolve(store, first, Base);
|
||||
const auto inactiveDirectionProjection =
|
||||
particle::BindingProjectionResolver::resolve(store, inactiveDirectionChanged, Base);
|
||||
ASSERT_TRUE(firstProjection.accepted());
|
||||
ASSERT_TRUE(inactiveDirectionProjection.accepted());
|
||||
EXPECT_EQ(firstProjection.projection->contentIdentity, inactiveDirectionProjection.projection->contentIdentity);
|
||||
|
||||
auto activeUChanged = first;
|
||||
activeUChanged.uPlane.semanticID = 99;
|
||||
EXPECT_NE(first, activeUChanged);
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, activeUChanged, Base).error,
|
||||
particle::BindingProjectionError::PlaneMissing);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldBinding, DirectionAngleIgnoresInactiveVectorSelectorsInIdentity) {
|
||||
particle::EncodedFieldStore store;
|
||||
auto d0 = *makeFrame("direction", Base, 1, "d0", false, false);
|
||||
auto d1 = *makeFrame("direction", Base + Hour, 1, "d1", false, false);
|
||||
const auto directionBytes = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{1, 2, 3, 4});
|
||||
for (auto* frame : {&d0, &d1}) {
|
||||
frame->planes.push_back(makePlane(10, 0, particle::FieldValueClass::DirectionAngle, particle::FieldComponent::None,
|
||||
particle::FieldUnitDegrees, "direction", directionBytes));
|
||||
}
|
||||
submit(store, "weather.direction", std::make_shared<const particle::DecodedFieldFrame>(std::move(d0)));
|
||||
submit(store, "weather.direction", std::make_shared<const particle::DecodedFieldFrame>(std::move(d1)));
|
||||
|
||||
particle::ParticleBinding first;
|
||||
first.mode = particle::ParticleBindingMode::DirectionAngle;
|
||||
first.fieldKey = "weather.direction";
|
||||
first.directionPlane = {10, 0, particle::FieldComponent::None};
|
||||
first.expectedUnitID = particle::FieldUnitDegrees;
|
||||
auto inactiveVectorsChanged = first;
|
||||
inactiveVectorsChanged.uPlane = {1, 7, particle::FieldComponent::UEast};
|
||||
inactiveVectorsChanged.vPlane = {2, 7, particle::FieldComponent::VNorth};
|
||||
EXPECT_EQ(first, inactiveVectorsChanged);
|
||||
const auto firstProjection = particle::BindingProjectionResolver::resolve(store, first, Base);
|
||||
const auto inactiveVectorsProjection = particle::BindingProjectionResolver::resolve(store, inactiveVectorsChanged, Base);
|
||||
ASSERT_TRUE(firstProjection.accepted());
|
||||
ASSERT_TRUE(inactiveVectorsProjection.accepted());
|
||||
EXPECT_EQ(firstProjection.projection->contentIdentity, inactiveVectorsProjection.projection->contentIdentity);
|
||||
|
||||
auto activeDirectionChanged = first;
|
||||
activeDirectionChanged.directionPlane.semanticID = 99;
|
||||
EXPECT_NE(first, activeDirectionChanged);
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, activeDirectionChanged, Base).error,
|
||||
particle::BindingProjectionError::PlaneMissing);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldBinding, FailsClosedAndSupportsDirectionAngle) {
|
||||
particle::EncodedFieldStore store;
|
||||
submit(store, "weather.wind", makeFrame("wind", Base, 1, "0"));
|
||||
submit(store, "weather.wind", makeFrame("wind", Base + Hour, 1, "1"));
|
||||
const particle::ColorBinding velocityComponent{"weather.wind", {1, 7, particle::FieldComponent::UEast},
|
||||
particle::FieldValueClass::VelocityComponent,
|
||||
particle::FieldUnitMetersPerSecond};
|
||||
const auto velocityProjection = particle::BindingProjectionResolver::resolve(store, velocityComponent, Base);
|
||||
ASSERT_TRUE(velocityProjection.accepted());
|
||||
EXPECT_EQ(velocityProjection.projection->t0PlaneStorage,
|
||||
velocityProjection.projection->t0Frame->planes.front().storage);
|
||||
const particle::ColorBinding validity{"weather.wind", {900, 0, particle::FieldComponent::None},
|
||||
particle::FieldValueClass::Validity, particle::FieldUnitNone};
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, validity, Base).error,
|
||||
particle::BindingProjectionError::InvalidBinding);
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color(""), Base).error, particle::BindingProjectionError::InvalidBinding);
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("missing"), Base).error, particle::BindingProjectionError::FieldSeriesMissing);
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + 2 * Hour).error, particle::BindingProjectionError::WindowUnavailable);
|
||||
auto missing = color("weather.wind"); missing.plane.semanticID = 99;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, missing, Base).error, particle::BindingProjectionError::PlaneMissing);
|
||||
auto classMismatch = color("weather.wind"); classMismatch.plane = {1, 7, particle::FieldComponent::None};
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, classMismatch, Base).error, particle::BindingProjectionError::PlaneClassMismatch);
|
||||
auto unitMismatch = wind(); unitMismatch.expectedUnitID = 99;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, unitMismatch, Base).error, particle::BindingProjectionError::PlaneUnitMismatch);
|
||||
auto componentMismatch = wind(); componentMismatch.uPlane.semanticID = 2; componentMismatch.vPlane.semanticID = 1;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, componentMismatch, Base).error, particle::BindingProjectionError::PlaneComponentMismatch);
|
||||
particle::EncodedFieldStore unequalGroups;
|
||||
auto g0 = *makeFrame("wind", Base, 1, "g0"), g1 = *makeFrame("wind", Base + Hour, 1, "g1");
|
||||
g0.planes[1].key.groupID = 8; g1.planes[1].key.groupID = 8;
|
||||
submit(unequalGroups, "weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(g0)));
|
||||
submit(unequalGroups, "weather.wind", std::make_shared<const particle::DecodedFieldFrame>(std::move(g1)));
|
||||
auto groupMismatch = wind(); groupMismatch.vPlane.groupID = 8;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(unequalGroups, groupMismatch, Base).error,
|
||||
particle::BindingProjectionError::PlaneGroupMismatch);
|
||||
|
||||
particle::EncodedFieldStore directions;
|
||||
auto d0 = *makeFrame("direction", Base, 1, "d0", false, false);
|
||||
auto d1 = *makeFrame("direction", Base + Hour, 1, "d1", false, false);
|
||||
const auto directionBytes = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{1, 2, 3, 4});
|
||||
for (auto* frame : {&d0, &d1}) {
|
||||
frame->planes.push_back(makePlane(10, 0, particle::FieldValueClass::DirectionAngle, particle::FieldComponent::None,
|
||||
particle::FieldUnitDegrees, "direction", directionBytes));
|
||||
frame->planes.back().dtype = particle::FieldDType::UInt16;
|
||||
frame->planes.back().scale = 360.0f / 65536.0f;
|
||||
}
|
||||
submit(directions, "weather.direction", std::make_shared<const particle::DecodedFieldFrame>(std::move(d0)));
|
||||
submit(directions, "weather.direction", std::make_shared<const particle::DecodedFieldFrame>(std::move(d1)));
|
||||
particle::ParticleBinding direction;
|
||||
direction.mode = particle::ParticleBindingMode::DirectionAngle; direction.fieldKey = "weather.direction";
|
||||
direction.directionPlane = {10, 0, particle::FieldComponent::None}; direction.expectedUnitID = particle::FieldUnitDegrees;
|
||||
EXPECT_TRUE(particle::BindingProjectionResolver::resolve(directions, direction, Base).accepted());
|
||||
direction.expectedUnitID = particle::FieldUnitMetersPerSecond;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(directions, direction, Base).error,
|
||||
particle::BindingProjectionError::PlaneUnitMismatch);
|
||||
direction.expectedUnitID = particle::FieldUnitDegrees;
|
||||
direction.directionPlane.semanticID = 99;
|
||||
EXPECT_EQ(particle::BindingProjectionResolver::resolve(directions, direction, Base).error,
|
||||
particle::BindingProjectionError::PlaneMissing);
|
||||
}
|
||||
36
test/particle/field_profile_contract_json.test.cpp
Normal file
36
test/particle/field_profile_contract_json.test.cpp
Normal file
@@ -0,0 +1,36 @@
|
||||
#include <mln/particle/field/field_profile_contract_json.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace mln::particle {
|
||||
namespace {
|
||||
std::string valid() {
|
||||
return R"({"contractSchemaVersion":1,"profileID":"test.wind","profileVersion":1,"allowAdditionalPlanes":false,"sharedValidityRequired":true,"timeRelation":"ANY","requiredPlanes":[{"semanticID":1,"valueClass":"VELOCITY_COMPONENT","unitID":1,"groupID":1,"component":"U_EAST","requiredFlag":true},{"semanticID":2,"valueClass":"VELOCITY_COMPONENT","unitID":1,"groupID":1,"component":"V_NORTH","requiredFlag":true},{"semanticID":3,"valueClass":"VALIDITY","unitID":0,"groupID":0,"component":"NONE","requiredFlag":true}]})";
|
||||
}
|
||||
TEST(ParticleFieldProfileContractJson, ParsesFrozenSchema) {
|
||||
const auto contract = parseResolvedFieldProfileContractJson(valid());
|
||||
ASSERT_TRUE(contract); EXPECT_EQ(contract->contractSchemaVersion, 1); EXPECT_EQ(contract->profileID, "test.wind");
|
||||
EXPECT_EQ(contract->requiredPlanes.size(), 3u); EXPECT_FALSE(contract->allowAdditionalPlanes);
|
||||
}
|
||||
TEST(ParticleFieldProfileContractJson, RejectsMissingFrozenFieldsAndUnknownSchema) {
|
||||
EXPECT_FALSE(parseResolvedFieldProfileContractJson(R"({"contractSchemaVersion":1})"));
|
||||
auto json = valid(); json.replace(json.find("\"contractSchemaVersion\":1"), 25, "\"contractSchemaVersion\":2");
|
||||
EXPECT_FALSE(parseResolvedFieldProfileContractJson(json));
|
||||
}
|
||||
TEST(ParticleFieldProfileContractJson, RejectsOutOfRangeDuplicateAndInvalidEnums) {
|
||||
auto overflow = valid(); overflow.replace(overflow.find("\"semanticID\":1"), 14, "\"semanticID\":65536");
|
||||
EXPECT_FALSE(parseResolvedFieldProfileContractJson(overflow));
|
||||
auto duplicate = valid(); duplicate.replace(duplicate.find("\"semanticID\":2"), 14, "\"semanticID\":1");
|
||||
EXPECT_FALSE(parseResolvedFieldProfileContractJson(duplicate));
|
||||
auto invalidEnum = valid(); invalidEnum.replace(invalidEnum.find("\"timeRelation\":\"ANY\""), 20, "\"timeRelation\":\"LATER\"");
|
||||
EXPECT_FALSE(parseResolvedFieldProfileContractJson(invalidEnum));
|
||||
}
|
||||
TEST(ParticleFieldProfileContractJson, RejectsPlaneLimitAndInvalidProfileID) {
|
||||
auto invalidID = valid(); invalidID.replace(invalidID.find("test.wind"), 9, "");
|
||||
EXPECT_FALSE(parseResolvedFieldProfileContractJson(invalidID));
|
||||
std::string planes; for (int i = 0; i != 65; ++i) { if (!planes.empty()) planes += ','; planes += R"({"semanticID":99,"valueClass":"SCALAR","unitID":0,"groupID":)" + std::to_string(i) + R"(,"component":"NONE","requiredFlag":true})"; }
|
||||
const auto tooMany = std::string(R"({"contractSchemaVersion":1,"profileID":"x","profileVersion":1,"allowAdditionalPlanes":true,"sharedValidityRequired":true,"timeRelation":"ANY","requiredPlanes":[)") + planes + "]}";
|
||||
EXPECT_FALSE(parseResolvedFieldProfileContractJson(tooMany));
|
||||
}
|
||||
} // namespace
|
||||
} // namespace mln::particle
|
||||
473
test/particle/field_runtime.test.cpp
Normal file
473
test/particle/field_runtime.test.cpp
Normal file
@@ -0,0 +1,473 @@
|
||||
#include <mln/particle/field/field_profile_validator.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace mln;
|
||||
|
||||
namespace {
|
||||
|
||||
particle::DecodedFieldPlane plane(uint16_t semantic, particle::FieldValueClass valueClass, uint16_t unit,
|
||||
uint16_t group, particle::FieldComponent component, particle::FieldDType dtype,
|
||||
bool required, std::shared_ptr<const std::vector<uint8_t>> storage,
|
||||
std::string identity = "plane") {
|
||||
return {.key = {semantic, group, component}, .dtype = dtype, .valueClass = valueClass, .unitID = unit,
|
||||
.requiredFlag = required, .count = 4, .scale = 0.1f, .offset = 0.0f,
|
||||
.contentIdentity = std::move(identity), .storage = std::move(storage)};
|
||||
}
|
||||
|
||||
std::shared_ptr<const std::vector<uint8_t>> fourLittleEndian16BitSamples() {
|
||||
return std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{1, 0, 2, 0, 3, 0, 4, 0});
|
||||
}
|
||||
|
||||
particle::DecodedFieldFrame frame() {
|
||||
const auto numbers = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{1, 2, 3, 4});
|
||||
const auto bits = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{0x0F});
|
||||
particle::DecodedFieldFrame value;
|
||||
value.frameContentIdentity = {"future-product", 1, 1, 1'000};
|
||||
value.referenceTimeMs = 1'000;
|
||||
value.grid = {.layout = 1, .projection = 1, .sampleOrder = 1, .gridRegistration = 1, .width = 2, .height = 2,
|
||||
.west = 130.0, .south = 30.0, .east = 131.0, .north = 31.0};
|
||||
value.validityContentIdentity = "validity";
|
||||
value.validityBits = bits;
|
||||
value.validityPlane = plane(900, particle::FieldValueClass::Validity, particle::FieldUnitNone, 0,
|
||||
particle::FieldComponent::None, particle::FieldDType::Bitset, true, bits, "validity");
|
||||
value.planes.push_back(plane(777, particle::FieldValueClass::Scalar, 65432, 0, particle::FieldComponent::None,
|
||||
particle::FieldDType::UInt8, true, numbers, "scalar"));
|
||||
return value;
|
||||
}
|
||||
|
||||
particle::ResolvedFieldProfileContract contract() {
|
||||
particle::ResolvedFieldProfileContract value;
|
||||
value.profileID = "future.product-42";
|
||||
value.profileVersion = 1;
|
||||
value.allowAdditionalPlanes = true;
|
||||
value.requiredPlanes = {{777, particle::FieldValueClass::Scalar, 65432, 0, particle::FieldComponent::None, true},
|
||||
{900, particle::FieldValueClass::Validity, particle::FieldUnitNone, 0,
|
||||
particle::FieldComponent::None, true}};
|
||||
return value;
|
||||
}
|
||||
|
||||
particle::FieldProfileValidationCode validate(const particle::ResolvedFieldProfileContract& c,
|
||||
const particle::DecodedFieldFrame& f) {
|
||||
return particle::FieldProfileValidator::validate(c, f).code;
|
||||
}
|
||||
|
||||
std::shared_ptr<const particle::DecodedFieldFrame> runtimeFrame(uint64_t time, uint64_t generation,
|
||||
uint64_t version, std::string content,
|
||||
std::string id = "field") {
|
||||
auto value = frame();
|
||||
value.frameContentIdentity = {std::move(id), generation, version, time};
|
||||
value.planes.front().contentIdentity = std::move(content);
|
||||
return std::make_shared<const particle::DecodedFieldFrame>(std::move(value));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ParticleFieldProfileValidator, AcceptsGenericProfileAndOpaqueScalarUnit) {
|
||||
EXPECT_EQ(validate(contract(), frame()), particle::FieldProfileValidationCode::Accepted);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldProfileValidator, EnforcesRequiredPlanesAndAdditionalPlanePolicy) {
|
||||
auto c = contract(); auto f = frame();
|
||||
f.planes.clear();
|
||||
EXPECT_TRUE(f.isStructurallyValid());
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::MissingRequiredPlane);
|
||||
f = frame(); f.planes.front().unitID = 99;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::PlaneDescriptorMismatch);
|
||||
f = frame(); f.planes.front().requiredFlag = false;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::PlaneDescriptorMismatch);
|
||||
f = frame(); f.planes.push_back(plane(778, particle::FieldValueClass::Scalar, 123, 0, particle::FieldComponent::None,
|
||||
particle::FieldDType::UInt8, true, f.planes.front().storage, "required-extra"));
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::UndeclaredRequiredPlane);
|
||||
f.planes.back().requiredFlag = false; c.allowAdditionalPlanes = false;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::AdditionalPlaneNotAllowed);
|
||||
c.allowAdditionalPlanes = true;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldProfileValidator, EnforcesSingleSharedValidityStructuralInvariant) {
|
||||
auto f = frame();
|
||||
const auto numbers = f.planes.front().storage;
|
||||
|
||||
f.planes.push_back(plane(901, particle::FieldValueClass::Validity, particle::FieldUnitNone, 0,
|
||||
particle::FieldComponent::None, particle::FieldDType::Bitset, false, numbers, "second-validity"));
|
||||
EXPECT_FALSE(f.isStructurallyValid());
|
||||
EXPECT_EQ(validate(contract(), f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
|
||||
f = frame();
|
||||
f.validityPlane.requiredFlag = false;
|
||||
EXPECT_FALSE(f.isStructurallyValid());
|
||||
EXPECT_EQ(validate(contract(), f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
|
||||
f = frame(); f.validityPlane.valueClass = particle::FieldValueClass::Scalar;
|
||||
EXPECT_FALSE(f.isStructurallyValid());
|
||||
EXPECT_EQ(validate(contract(), f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
f = frame(); f.validityPlane.dtype = particle::FieldDType::UInt8;
|
||||
EXPECT_FALSE(f.isStructurallyValid());
|
||||
EXPECT_EQ(validate(contract(), f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
f = frame(); f.validityPlane.unitID = particle::FieldUnitMetersPerSecond;
|
||||
EXPECT_FALSE(f.isStructurallyValid());
|
||||
EXPECT_EQ(validate(contract(), f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
f = frame(); f.validityPlane.key.groupID = 1;
|
||||
EXPECT_FALSE(f.isStructurallyValid());
|
||||
EXPECT_EQ(validate(contract(), f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
f = frame(); f.validityPlane.key.component = particle::FieldComponent::UEast;
|
||||
EXPECT_FALSE(f.isStructurallyValid());
|
||||
EXPECT_EQ(validate(contract(), f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
|
||||
f = frame();
|
||||
f.validityPlane.storage = std::make_shared<const std::vector<uint8_t>>(*f.validityBits);
|
||||
EXPECT_FALSE(f.isStructurallyValid());
|
||||
EXPECT_EQ(validate(contract(), f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldProfileValidator, EnforcesVelocityDirectionAndValidityCoreRules) {
|
||||
EXPECT_EQ(particle::FieldUnitNone, 0);
|
||||
EXPECT_EQ(particle::FieldUnitMetersPerSecond, 1);
|
||||
EXPECT_EQ(particle::FieldUnitDegrees, 4);
|
||||
auto c = contract(); auto f = frame(); const auto bytes = fourLittleEndian16BitSamples();
|
||||
f.planes.push_back(plane(100, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
|
||||
particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes));
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup);
|
||||
f.planes.push_back(plane(101, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
|
||||
particle::FieldComponent::VNorth, particle::FieldDType::Int16, false, bytes));
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
|
||||
f.planes.back().unitID = 99;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit);
|
||||
f = frame();
|
||||
f.planes.push_back(plane(100, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 0,
|
||||
particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes));
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup);
|
||||
f = frame();
|
||||
f.planes.push_back(plane(100, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
|
||||
particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes));
|
||||
f.planes.push_back(plane(101, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
|
||||
particle::FieldComponent::UEast, particle::FieldDType::Int16, false, bytes));
|
||||
f.planes.push_back(plane(102, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 3,
|
||||
particle::FieldComponent::VNorth, particle::FieldDType::Int16, false, bytes));
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidVelocityGroup);
|
||||
f = frame(); f.planes.front().key.semanticID = 10; f.planes.front().valueClass = particle::FieldValueClass::DirectionAngle;
|
||||
f.planes.front().unitID = particle::FieldUnitDegrees;
|
||||
f.planes.front().dtype = particle::FieldDType::UInt16;
|
||||
f.planes.front().scale = 360.0f / 65536.0f;
|
||||
c.requiredPlanes.front().semanticID = 10; c.requiredPlanes.front().valueClass = particle::FieldValueClass::DirectionAngle;
|
||||
c.requiredPlanes.front().unitID = particle::FieldUnitDegrees;
|
||||
f.planes.front().storage = bytes;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
|
||||
f.planes.front().unitID = 2;
|
||||
c.requiredPlanes.front().unitID = 2;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::SemanticContractMismatch);
|
||||
c = contract(); f = frame(); f.validityPlane.dtype = particle::FieldDType::UInt8;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
f = frame(); f.validityPlane.unitID = 1;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
f = frame(); f.validityPlane.valueClass = particle::FieldValueClass::Scalar;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldProfileValidator, EnforcesReservedCoreSemanticContractsFromProtocolTable) {
|
||||
auto c = contract(); auto f = frame();
|
||||
// Contract and frame agree on the spoof, but reserved semantic 1 remains
|
||||
// VELOCITY_COMPONENT/MPS/U_EAST and cannot become an opaque scalar.
|
||||
c.requiredPlanes.front().semanticID = 1;
|
||||
f.planes.front().key.semanticID = 1;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::SemanticContractMismatch);
|
||||
|
||||
c = contract(); f = frame();
|
||||
c.requiredPlanes.front() = {2, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 4,
|
||||
particle::FieldComponent::UEast, true};
|
||||
f.planes.front() = plane(2, particle::FieldValueClass::VelocityComponent, particle::FieldUnitMetersPerSecond, 4,
|
||||
particle::FieldComponent::UEast, particle::FieldDType::Int16, true,
|
||||
fourLittleEndian16BitSamples(), "u-spoof");
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::SemanticContractMismatch);
|
||||
|
||||
c = contract(); f = frame();
|
||||
c.requiredPlanes.back().semanticID = 3;
|
||||
c.requiredPlanes.back().valueClass = particle::FieldValueClass::Scalar;
|
||||
f.validityPlane.key.semanticID = 3;
|
||||
f.validityPlane.valueClass = particle::FieldValueClass::Scalar;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidFrame);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldProfileValidator, EnforcesDirectionCircularQuantizationContract) {
|
||||
auto c = contract(); auto f = frame();
|
||||
c.requiredPlanes.front() = {10, particle::FieldValueClass::DirectionAngle, particle::FieldUnitDegrees, 0,
|
||||
particle::FieldComponent::None, true};
|
||||
f.planes.front() = plane(10, particle::FieldValueClass::DirectionAngle, particle::FieldUnitDegrees, 0,
|
||||
particle::FieldComponent::None, particle::FieldDType::UInt16, true,
|
||||
fourLittleEndian16BitSamples(), "direction");
|
||||
f.planes.front().scale = 360.0f / 65536.0f;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
|
||||
|
||||
f.planes.front().unitID = particle::FieldUnitMetersPerSecond;
|
||||
c.requiredPlanes.front().unitID = particle::FieldUnitMetersPerSecond;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::SemanticContractMismatch);
|
||||
f.planes.front().unitID = particle::FieldUnitDegrees;
|
||||
c.requiredPlanes.front().unitID = particle::FieldUnitDegrees;
|
||||
f.planes.front().key.component = particle::FieldComponent::UEast;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::SemanticContractMismatch);
|
||||
f.planes.front().key.component = particle::FieldComponent::None;
|
||||
f.planes.front().key.groupID = 1;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::SemanticContractMismatch);
|
||||
f.planes.front().key.groupID = 0;
|
||||
f.planes.front().dtype = particle::FieldDType::UInt8;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit);
|
||||
f.planes.front().dtype = particle::FieldDType::UInt16;
|
||||
f.planes.front().scale = 0.1f;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit);
|
||||
f.planes.front().scale = 360.0f / 65536.0f;
|
||||
f.planes.front().offset = 1.0f;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::InvalidCoreUnit);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldProfileValidator, UsesSemanticAndGroupAsTheOnlyPlaneIdentity) {
|
||||
auto f = frame();
|
||||
auto duplicate = f.planes.front();
|
||||
duplicate.key.component = particle::FieldComponent::UEast;
|
||||
f.planes.push_back(duplicate);
|
||||
EXPECT_FALSE(f.isStructurallyValid());
|
||||
|
||||
auto c = contract();
|
||||
auto duplicateRequirement = c.requiredPlanes.front();
|
||||
duplicateRequirement.component = particle::FieldComponent::UEast;
|
||||
c.requiredPlanes.push_back(duplicateRequirement);
|
||||
EXPECT_FALSE(c.isStructurallyValid());
|
||||
|
||||
c = contract();
|
||||
f = frame();
|
||||
f.planes.front().key.component = particle::FieldComponent::UEast;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::PlaneDescriptorMismatch);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldProfileValidator, AppliesSchemaSharedValidityAndTimeRules) {
|
||||
auto c = contract(); auto f = frame(); c.contractSchemaVersion = 2;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::UnsupportedContractSchema);
|
||||
c = contract(); c.sharedValidityRequired = false;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::UnsupportedSharedValidityMode);
|
||||
c = contract(); f.frameContentIdentity.validTimeMs = 999;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
|
||||
c.timeRelation = particle::FieldTimeRelation::ValidGteReference;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::TimeRelationMismatch);
|
||||
f.frameContentIdentity.validTimeMs = 1'000;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::Accepted);
|
||||
c.timeRelation = particle::FieldTimeRelation::ValidEqReference; f.frameContentIdentity.validTimeMs = 1'001;
|
||||
EXPECT_EQ(validate(c, f), particle::FieldProfileValidationCode::TimeRelationMismatch);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldRuntime, RevisionIdentityAndIndependentCadence) {
|
||||
particle::FieldSeries series; const uint64_t base = 1'700'000'000'000ULL, hour = 3'600'000ULL;
|
||||
EXPECT_EQ(series.submit(runtimeFrame(base, 1, 1, "a")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(series.submit(runtimeFrame(base, 1, 1, "a")), particle::FieldSeriesSubmitResult::Duplicate);
|
||||
EXPECT_EQ(series.submit(runtimeFrame(base, 1, 1, "changed")), particle::FieldSeriesSubmitResult::RejectedIdentityConflict);
|
||||
EXPECT_EQ(series.submit(runtimeFrame(base, 2, 2, "b")), particle::FieldSeriesSubmitResult::ReplacedRevision);
|
||||
particle::EncodedFieldStore store;
|
||||
EXPECT_EQ(store.submit("wind-key", runtimeFrame(base, 1, 1, "w0", "wind")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(store.submit("wind-key", runtimeFrame(base + hour, 2, 1, "w1", "wind")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(store.submit("wave-key", runtimeFrame(base, 1, 1, "a0", "wave")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(store.submit("wave-key", runtimeFrame(base + 3 * hour, 2, 1, "a3", "wave")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
const auto wind = store.resolve("wind-key", base + hour / 2), wave = store.resolve("wave-key", base + hour / 2);
|
||||
ASSERT_TRUE(wind); ASSERT_TRUE(wave); EXPECT_FLOAT_EQ(wind->mix, 0.5f); EXPECT_FLOAT_EQ(wave->mix, 1.0f / 6.0f);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldRuntime, ResolvesExactSingleFrameAndRejectsUncoveredTimes) {
|
||||
constexpr uint64_t base = 1'700'000'000'000ULL;
|
||||
particle::FieldSeries series;
|
||||
ASSERT_EQ(series.submit(runtimeFrame(base, 1, 1, "only")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(series.decodedByteSize(), 5u);
|
||||
const auto exact = series.resolve(base);
|
||||
ASSERT_TRUE(exact);
|
||||
EXPECT_EQ(exact->t0, exact->t1);
|
||||
EXPECT_FLOAT_EQ(exact->mix, 0.0f);
|
||||
EXPECT_EQ(exact->requestedForecastTimeMs, base);
|
||||
EXPECT_FALSE(series.resolve(base + 1));
|
||||
|
||||
ASSERT_EQ(series.submit(runtimeFrame(base + 10'000, 1, 2, "next")),
|
||||
particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(series.decodedByteSize(), 10u);
|
||||
const auto middle = series.resolve(base + 5'000);
|
||||
ASSERT_TRUE(middle);
|
||||
EXPECT_NE(middle->t0, middle->t1);
|
||||
EXPECT_FLOAT_EQ(middle->mix, 0.5f);
|
||||
EXPECT_FALSE(series.resolve(base - 1));
|
||||
EXPECT_FALSE(series.resolve(base + 10'001));
|
||||
}
|
||||
|
||||
TEST(ParticleFieldRuntime, FullWindowRollsBothDirectionsAndBreaksTiesAtEarliest) {
|
||||
constexpr uint64_t base = 1'700'000'000'000ULL;
|
||||
constexpr uint64_t hour = 3'600'000ULL;
|
||||
const auto fill = [&](particle::FieldSeries& series) {
|
||||
for (uint64_t i = 0; i < 8; ++i)
|
||||
ASSERT_EQ(series.submit(runtimeFrame(base + i * hour, 1, i + 1, "frame-" + std::to_string(i))),
|
||||
particle::FieldSeriesSubmitResult::Inserted);
|
||||
};
|
||||
const auto times = [](const particle::FieldSeries& series) {
|
||||
std::vector<uint64_t> values;
|
||||
for (const auto& entry : series.entries()) values.push_back(entry->frameContentIdentity.validTimeMs);
|
||||
return values;
|
||||
};
|
||||
|
||||
particle::FieldSeries future;
|
||||
fill(future);
|
||||
ASSERT_EQ(future.submit(runtimeFrame(base + 8 * hour, 1, 9, "future")),
|
||||
particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(future.size(), 8u);
|
||||
EXPECT_EQ(times(future).front(), base + hour);
|
||||
EXPECT_EQ(times(future).back(), base + 8 * hour);
|
||||
|
||||
particle::FieldSeries past;
|
||||
fill(past);
|
||||
ASSERT_EQ(past.submit(runtimeFrame(base - hour, 1, 9, "past")), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(past.size(), 8u);
|
||||
EXPECT_EQ(times(past).front(), base - hour);
|
||||
EXPECT_EQ(times(past).back(), base + 6 * hour);
|
||||
|
||||
particle::FieldSeries internal;
|
||||
fill(internal);
|
||||
ASSERT_EQ(internal.submit(runtimeFrame(base + 3 * hour + hour / 2, 1, 9, "internal")),
|
||||
particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(internal.size(), 8u);
|
||||
EXPECT_EQ(times(internal), (std::vector<uint64_t>{base + hour, base + 2 * hour, base + 3 * hour,
|
||||
base + 3 * hour + hour / 2, base + 4 * hour,
|
||||
base + 5 * hour, base + 6 * hour, base + 7 * hour}));
|
||||
}
|
||||
|
||||
TEST(ParticleFieldRuntime, UnorderedSubmissionsAreSortedAndReferenceCyclesStaySeparate) {
|
||||
constexpr uint64_t base = 1'700'000'000'000ULL;
|
||||
constexpr uint64_t hour = 3'600'000ULL;
|
||||
particle::EncodedFieldStore store;
|
||||
for (const uint64_t index : {4ULL, 1ULL, 3ULL, 0ULL, 2ULL})
|
||||
ASSERT_EQ(store.submit("wind", runtimeFrame(base + index * hour, 1, index + 1,
|
||||
"wind-" + std::to_string(index))),
|
||||
particle::FieldSeriesSubmitResult::Inserted);
|
||||
const auto* series = store.find("wind");
|
||||
ASSERT_TRUE(series);
|
||||
ASSERT_EQ(series->size(), 5u);
|
||||
for (size_t i = 0; i < series->size(); ++i)
|
||||
EXPECT_EQ(series->entries()[i]->frameContentIdentity.validTimeMs, base + i * hour);
|
||||
|
||||
auto differentReference = std::make_shared<particle::DecodedFieldFrame>(*runtimeFrame(
|
||||
base + 5 * hour, 1, 6, "new-reference"));
|
||||
differentReference->referenceTimeMs = 2'000;
|
||||
differentReference->resolvedProfileID = "wind-profile-v2";
|
||||
differentReference->resolvedProfileVersion = 2;
|
||||
EXPECT_EQ(store.submit("wind", differentReference), particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(store.find("wind")->size(), 6u);
|
||||
EXPECT_FALSE(store.resolve("wind", base + 4 * hour + hour / 2));
|
||||
const auto exactNewBatch = store.resolve("wind", base + 5 * hour);
|
||||
ASSERT_TRUE(exactNewBatch);
|
||||
EXPECT_EQ(exactNewBatch->t0, exactNewBatch->t1);
|
||||
|
||||
auto incompatibleGrid = std::make_shared<particle::DecodedFieldFrame>(*runtimeFrame(
|
||||
base + 6 * hour, 1, 7, "wrong-grid"));
|
||||
incompatibleGrid->grid.east = 132.0; // valid grid, incompatible with 131.0 bounds
|
||||
EXPECT_EQ(store.submit("wind", incompatibleGrid), particle::FieldSeriesSubmitResult::RejectedIncompatibleFrame);
|
||||
EXPECT_EQ(store.find("wind")->size(), 6u);
|
||||
EXPECT_EQ(store.submit("wave", runtimeFrame(base, 1, 1, "wave", "wave")),
|
||||
particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(store.find("wind")->size(), 6u);
|
||||
EXPECT_EQ(store.find("wave")->size(), 1u);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldRuntime, RejectedFullWindowInsertDoesNotEvictAndKeysRemainIndependent) {
|
||||
constexpr uint64_t base = 1'700'000'000'000ULL;
|
||||
constexpr uint64_t hour = 3'600'000ULL;
|
||||
particle::EncodedFieldStore store;
|
||||
for (uint64_t i = 0; i < 8; ++i)
|
||||
ASSERT_EQ(store.submit("wind", runtimeFrame(base + i * hour, 1, i + 1, "wind-" + std::to_string(i))),
|
||||
particle::FieldSeriesSubmitResult::Inserted);
|
||||
const auto bytesBeforeRejectedFrame = store.decodedByteSize();
|
||||
auto incompatible = std::make_shared<particle::DecodedFieldFrame>(*runtimeFrame(
|
||||
base + 8 * hour, 1, 9, "wrong-grid"));
|
||||
incompatible->grid.east = 132.0; // valid grid, incompatible with 131.0 bounds
|
||||
ASSERT_EQ(store.submit("wind", incompatible), particle::FieldSeriesSubmitResult::RejectedIncompatibleFrame);
|
||||
ASSERT_EQ(store.find("wind")->size(), 8u);
|
||||
EXPECT_EQ(store.decodedByteSize(), bytesBeforeRejectedFrame);
|
||||
EXPECT_EQ(store.find("wind")->entries().front()->frameContentIdentity.validTimeMs, base);
|
||||
for (uint64_t i = 0; i < 8; ++i)
|
||||
ASSERT_EQ(store.submit("wave", runtimeFrame(base + (i + 20) * hour, 1, i + 1,
|
||||
"wave-" + std::to_string(i), "wave-dataset")),
|
||||
particle::FieldSeriesSubmitResult::Inserted);
|
||||
EXPECT_EQ(store.find("wind")->size(), 8u);
|
||||
EXPECT_EQ(store.find("wave")->size(), 8u);
|
||||
EXPECT_EQ(store.find("wave")->entries().front()->frameContentIdentity.validTimeMs, base + 20 * hour);
|
||||
EXPECT_FALSE(store.resolve("wave", base + 1));
|
||||
}
|
||||
|
||||
TEST(ParticleFieldRuntime, RepeatedBidirectionalRollingAndClearStayBounded) {
|
||||
constexpr uint64_t base = 1'700'000'000'000ULL;
|
||||
constexpr uint64_t hour = 3'600'000ULL;
|
||||
particle::EncodedFieldStore store;
|
||||
auto fill = [&] {
|
||||
for (uint64_t i = 0; i < 8; ++i)
|
||||
ASSERT_EQ(store.submit("wind", runtimeFrame(base + i * hour, 1, i + 1,
|
||||
"seed-" + std::to_string(i))),
|
||||
particle::FieldSeriesSubmitResult::Inserted);
|
||||
};
|
||||
fill();
|
||||
for (uint64_t iteration = 0; iteration < 120; ++iteration) {
|
||||
const uint64_t time = iteration % 2 == 0 ? base + (8 + iteration) * hour
|
||||
: base - (iteration + 1) * hour;
|
||||
ASSERT_EQ(store.submit("wind", runtimeFrame(time, 1, iteration + 20,
|
||||
"rolling-" + std::to_string(iteration))),
|
||||
particle::FieldSeriesSubmitResult::Inserted);
|
||||
ASSERT_EQ(store.find("wind")->size(), 8u);
|
||||
EXPECT_EQ(store.decodedByteSize(), 40u);
|
||||
if (iteration % 10 == 9) {
|
||||
EXPECT_TRUE(store.clear("wind"));
|
||||
EXPECT_EQ(store.decodedByteSize(), 0u);
|
||||
fill();
|
||||
}
|
||||
}
|
||||
store.clearAll();
|
||||
EXPECT_EQ(store.fieldCount(), 0u);
|
||||
EXPECT_EQ(store.decodedByteSize(), 0u);
|
||||
}
|
||||
|
||||
TEST(ParticleFieldRuntime, ForecastClockOwnsRequestedTimeAndPlayback) {
|
||||
particle::ForecastClock clock;
|
||||
EXPECT_TRUE(clock.setRequestedForecastTime(1'700'000'000'000ULL)); EXPECT_FALSE(clock.advance(1'000));
|
||||
EXPECT_TRUE(clock.setPlaybackSpeed(2.0f)); clock.play(); EXPECT_TRUE(clock.advance(500));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), 1'700'000'001'000ULL); clock.pause();
|
||||
EXPECT_FALSE(clock.advance(500)); EXPECT_FALSE(clock.setPlaybackSpeed(3.0f));
|
||||
}
|
||||
|
||||
TEST(ParticleFieldRuntime, ForecastClockPreservesHalfMillisecondRemainder) {
|
||||
particle::ForecastClock clock;
|
||||
ASSERT_TRUE(clock.setRequestedForecastTime(1'000));
|
||||
ASSERT_TRUE(clock.setPlaybackSpeed(0.5f));
|
||||
clock.play();
|
||||
EXPECT_FALSE(clock.advance(1));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), 1'000);
|
||||
ASSERT_TRUE(clock.setRequestedForecastTime(2'000));
|
||||
EXPECT_FALSE(clock.advance(1));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), 2'000);
|
||||
EXPECT_TRUE(clock.advance(1));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), 2'001);
|
||||
|
||||
ASSERT_TRUE(clock.setRequestedForecastTime(1'000));
|
||||
EXPECT_FALSE(clock.advance(1));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), 1'000);
|
||||
for (size_t i = 0; i < 8; ++i) EXPECT_EQ(clock.advance(1), i % 2 == 0);
|
||||
EXPECT_EQ(clock.requestedForecastTime(), 1'004);
|
||||
clock.pause();
|
||||
EXPECT_FALSE(clock.advance(10));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), 1'004);
|
||||
|
||||
ASSERT_TRUE(clock.setRequestedForecastTime(3'000));
|
||||
ASSERT_TRUE(clock.setPlaybackSpeed(1.0f));
|
||||
clock.play();
|
||||
EXPECT_TRUE(clock.advance(3));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), 3'003);
|
||||
ASSERT_TRUE(clock.setPlaybackSpeed(2.0f));
|
||||
EXPECT_TRUE(clock.advance(3));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), 3'009);
|
||||
|
||||
ASSERT_TRUE(clock.setRequestedForecastTime(std::numeric_limits<uint64_t>::max()));
|
||||
EXPECT_FALSE(clock.advance(1));
|
||||
EXPECT_EQ(clock.requestedForecastTime(), std::numeric_limits<uint64_t>::max());
|
||||
}
|
||||
88
test/particle/particle_gpu_math.test.cpp
Normal file
88
test/particle/particle_gpu_math.test.cpp
Normal file
@@ -0,0 +1,88 @@
|
||||
#include <mln/particle/field/field_data_adapter.hpp>
|
||||
#include <mln/particle/field/particle_gpu_math.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
using namespace mln;
|
||||
|
||||
TEST(ParticleGpuField, EightBitSignedZeroAndCancellation) {
|
||||
constexpr float scale = 1.0f;
|
||||
const auto zero = particle::encodeSignedVelocity8(0.0f, scale, true);
|
||||
const auto east = particle::encodeSignedVelocity8(0.25f, scale, true);
|
||||
const auto west = particle::encodeSignedVelocity8(-0.25f, scale, true);
|
||||
EXPECT_EQ(zero, 128);
|
||||
EXPECT_FLOAT_EQ(particle::decodeSignedVelocity8(zero, scale), 0.0f);
|
||||
EXPECT_EQ(static_cast<int>(east) + static_cast<int>(west), 256);
|
||||
EXPECT_FLOAT_EQ((particle::decodeSignedVelocity8(east, scale) +
|
||||
particle::decodeSignedVelocity8(west, scale)) * 0.5f, 0.0f);
|
||||
EXPECT_LE(std::abs(particle::decodeSignedVelocity8(east, scale) - 0.25f), scale / 254.0f);
|
||||
EXPECT_EQ(particle::encodeSignedVelocity8(std::numeric_limits<float>::quiet_NaN(), scale, false), 128);
|
||||
EXPECT_EQ(particle::encodeSignedVelocity8(1.0f, scale, false), 128);
|
||||
// RGBA8 cannot preserve values below half a quantum; this explicit bound
|
||||
// prevents a silent claim of arbitrary micro-wind accuracy.
|
||||
EXPECT_EQ(particle::encodeSignedVelocity8(0.001f, scale, true), 128);
|
||||
}
|
||||
|
||||
TEST(ParticleGpuField, EndpointIdentityCoversBothPlanesValidityGridEncodingAndRevision) {
|
||||
particle::ParticleFieldSnapshot a;
|
||||
a.fieldID = "wind"; a.fieldType = "qbin-v4"; a.displayDomain = "GLOBAL";
|
||||
a.generation = 7; a.dataVersion = 3; a.validTime = 1000;
|
||||
a.width = 1; a.height = 1; a.vector = {1.0f, 2.0f}; a.valid = {1};
|
||||
a.vectorSemantics = particle::VectorSemantics::Velocity;
|
||||
a.gpuEncodingIdentity = "float32-le";
|
||||
auto b = a;
|
||||
EXPECT_TRUE(particle::sameFieldGpuEndpoint(a, b));
|
||||
b.vector[1] = 3.0f; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b));
|
||||
b = a; b.valid[0] = 0; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b));
|
||||
b = a; b.west = 1.0; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b));
|
||||
b = a; b.gpuEncodingIdentity = "int16-scaled"; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b));
|
||||
b = a; ++b.dataVersion; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b));
|
||||
b = a; ++b.validTime; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b));
|
||||
b = a; ++b.generation; EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b));
|
||||
b = a; b.vectorSemantics = particle::VectorSemantics::Direction;
|
||||
EXPECT_FALSE(particle::sameFieldGpuEndpoint(a, b));
|
||||
}
|
||||
|
||||
TEST(ParticleGpuField, BoundaryReusesOldT1AndMixOnlyRequiresNoEndpointUpload) {
|
||||
particle::ParticleFieldSnapshot oldT0;
|
||||
oldT0.fieldID = "wind"; oldT0.generation = 1; oldT0.validTime = 1000;
|
||||
oldT0.width = 1; oldT0.height = 1; oldT0.vector = {1.0f, 2.0f}; oldT0.valid = {1};
|
||||
auto oldT1 = oldT0; oldT1.validTime = 2000;
|
||||
auto newT1 = oldT0; newT1.validTime = 3000;
|
||||
EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, oldT0), particle::GpuEndpointSource::OldT0);
|
||||
EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, oldT1), particle::GpuEndpointSource::OldT1);
|
||||
EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, newT1), particle::GpuEndpointSource::Upload);
|
||||
// Changing MIX alone leaves the two immutable endpoint decisions intact.
|
||||
const float mixA = 0.25f, mixB = 0.75f;
|
||||
EXPECT_NE(mixA, mixB);
|
||||
EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, oldT0), particle::GpuEndpointSource::OldT0);
|
||||
EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, oldT1), particle::GpuEndpointSource::OldT1);
|
||||
auto onlyV = oldT1; onlyV.vector[1] = -2.0f;
|
||||
EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, onlyV), particle::GpuEndpointSource::Upload);
|
||||
auto onlyValidity = oldT1; onlyValidity.valid[0] = 0;
|
||||
EXPECT_EQ(particle::gpuEndpointSource(oldT0, oldT1, onlyValidity), particle::GpuEndpointSource::Upload);
|
||||
}
|
||||
|
||||
TEST(ParticleGpuField, MercatorVelocityUsesSameLatitudeMetricForFourDirections) {
|
||||
constexpr double circumference = 40075016.68557849;
|
||||
for (const double latitude : {0.0, 45.0, 60.0}) {
|
||||
const double expected = 10.0 / (circumference * std::cos(latitude * 3.14159265358979323846 / 180.0));
|
||||
const auto east = particle::mercatorVelocityDelta(10, 0, latitude, 1);
|
||||
const auto west = particle::mercatorVelocityDelta(-10, 0, latitude, 1);
|
||||
const auto north = particle::mercatorVelocityDelta(0, 10, latitude, 1);
|
||||
const auto south = particle::mercatorVelocityDelta(0, -10, latitude, 1);
|
||||
EXPECT_NEAR(east[0], expected, 1e-15);
|
||||
EXPECT_NEAR(west[0], -expected, 1e-15);
|
||||
EXPECT_NEAR(north[1], -expected, 1e-15);
|
||||
EXPECT_NEAR(south[1], expected, 1e-15);
|
||||
EXPECT_DOUBLE_EQ(east[1], 0);
|
||||
EXPECT_DOUBLE_EQ(north[0], 0);
|
||||
}
|
||||
const auto polar = particle::mercatorVelocityDelta(10, 10, 90, 2);
|
||||
EXPECT_TRUE(std::isfinite(polar[0]));
|
||||
EXPECT_TRUE(std::isfinite(polar[1]));
|
||||
EXPECT_DOUBLE_EQ(polar[0], -polar[1]);
|
||||
}
|
||||
102
test/particle/qbin_v2_decoder.test.cpp
Normal file
102
test/particle/qbin_v2_decoder.test.cpp
Normal file
@@ -0,0 +1,102 @@
|
||||
#include <mln/particle/ingress/qbin_v2_decoder.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
using namespace mln;
|
||||
namespace {
|
||||
constexpr size_t Header = 112, Descriptor = 32;
|
||||
void u16(std::vector<uint8_t>& b, size_t o, uint16_t v) { b[o] = uint8_t(v); b[o + 1] = uint8_t(v >> 8); }
|
||||
void u32(std::vector<uint8_t>& b, size_t o, uint32_t v) { for (size_t i = 0; i < 4; ++i) b[o + i] = uint8_t(v >> (8 * i)); }
|
||||
void u64(std::vector<uint8_t>& b, size_t o, uint64_t v) { for (size_t i = 0; i < 8; ++i) b[o + i] = uint8_t(v >> (8 * i)); }
|
||||
void i64(std::vector<uint8_t>& b, size_t o, int64_t v) { u64(b, o, static_cast<uint64_t>(v)); }
|
||||
void f32(std::vector<uint8_t>& b, size_t o, float v) { uint32_t bits; std::memcpy(&bits, &v, sizeof(bits)); u32(b, o, bits); }
|
||||
void f64(std::vector<uint8_t>& b, size_t o, double v) { uint64_t bits; std::memcpy(&bits, &v, sizeof(bits)); u64(b, o, bits); }
|
||||
size_t a16(size_t x) { return (x + 15) & ~size_t(15); }
|
||||
uint32_t crc(const std::vector<uint8_t>& b) { uint32_t c = ~0u; for (size_t i = 0; i < b.size(); ++i) { uint8_t x = i >= 76 && i < 80 ? 0 : b[i]; c ^= x; for (int j = 0; j < 8; ++j) c = (c >> 1) ^ ((c & 1) ? 0xedb88320u : 0); } return ~c; }
|
||||
void recalc(std::vector<uint8_t>& b) { u32(b, 76, crc(b)); }
|
||||
struct P { uint16_t semantic = 1, unit = 1, group = 1; uint8_t type = 1, flags = 1, vc = 1, component = 1; float scale = 1, offset = 0; std::vector<uint8_t> raw; };
|
||||
std::vector<P> basePlanes() { return {{1,1,1,1,1,1,1,1,0,{1,0,2,0,3,0,4,0}}, {2,1,1,1,1,1,2,1,0,{5,0,6,0,7,0,8,0}}, {3,0,0,6,1,3,0,1,0,{15}}}; }
|
||||
size_t rawLength(uint8_t type, uint64_t cells) { return type == 6 ? (cells + 7) / 8 : (type == 1 || type == 2 ? cells * 2 : type == 5 ? cells * 4 : cells); }
|
||||
std::vector<uint8_t> fixture(uint32_t w = 2, uint32_t h = 2, std::vector<P> ps = {}) {
|
||||
if (ps.empty()) ps = basePlanes(); const uint64_t cells = uint64_t(w) * h;
|
||||
for (auto& p : ps) if (p.raw.empty() || cells != 4) { p.raw.assign(rawLength(p.type, cells), 0); if (p.type == 6) { std::fill(p.raw.begin(), p.raw.end(), 0xff); if (cells % 8) p.raw.back() = uint8_t((1u << (cells % 8)) - 1); } }
|
||||
size_t payload = 0; for (const auto& p : ps) payload = a16(payload + p.raw.size()); const size_t po = Header + ps.size() * Descriptor; std::vector<uint8_t> b(po + payload);
|
||||
std::memcpy(b.data(), "QBV2", 4); b[4] = 2; b[5] = 1; u16(b, 6, 999); b[8] = b[9] = b[10] = 1; u16(b, 12, ps.size()); u32(b, 16, w); u32(b, 20, h); i64(b, 24, 100); i64(b, 32, 90); f64(b, 40, -10); f64(b, 48, -10); f64(b, 56, 10); f64(b, 64, 10); u32(b, 72, payload); u16(b, 80, 0xffff);
|
||||
size_t off = 0; for (size_t i = 0; i < ps.size(); ++i) { const auto& p = ps[i]; const size_t d = Header + i * Descriptor; u16(b,d,p.semantic); b[d+2]=p.type; b[d+3]=p.flags; f32(b,d+4,p.scale); f32(b,d+8,p.offset); u32(b,d+12,cells); u32(b,d+16,off); u32(b,d+20,p.raw.size()); u16(b,d+24,p.unit); u16(b,d+26,p.group); b[d+28]=p.vc; b[d+29]=p.component; std::copy(p.raw.begin(),p.raw.end(),b.begin()+po+off); off=a16(off+p.raw.size()); }
|
||||
recalc(b); return b;
|
||||
}
|
||||
particle::ingress::EncodedFieldRuntimeMetadata meta() { return {"field", 1, 7}; }
|
||||
particle::ResolvedFieldProfileContract contract(std::vector<particle::FieldPlaneRequirement> required) { particle::ResolvedFieldProfileContract c; c.profileID="generic.test"; c.profileVersion=1; c.requiredPlanes=std::move(required); return c; }
|
||||
particle::ResolvedFieldProfileContract wind() { return contract({{1,particle::FieldValueClass::VelocityComponent,1,1,particle::FieldComponent::UEast,true},{2,particle::FieldValueClass::VelocityComponent,1,1,particle::FieldComponent::VNorth,true},{3,particle::FieldValueClass::Validity,0,0,particle::FieldComponent::None,true}}); }
|
||||
using Code = particle::ingress::QbinV2DecodeErrorCode;
|
||||
Code decodeCode(const std::vector<uint8_t>& b) { return particle::ingress::QbinV2Decoder::decode(meta(), b).code; }
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV2Decoder, DecodesGenericFrameAndSharesValidity) { auto r=particle::ingress::QbinV2Decoder::decode(meta(),fixture()); ASSERT_TRUE(r.accepted())<<r.detail; EXPECT_EQ(r.frame->referenceTimeMs,100000); EXPECT_EQ(r.frame->frameContentIdentity.validTimeMs,90000); EXPECT_EQ(r.frame->planes.size(),2); EXPECT_EQ(r.frame->validityPlane.storage,r.frame->validityBits); EXPECT_TRUE(r.frame->isStructurallyValid()); }
|
||||
TEST(ParticleQbinV2Decoder, AllowsUnknownScalarOpaqueUnitAndPastValidTime) { auto ps=basePlanes(); ps.push_back({99,65535,0,3,1,0,0,1,0,{1,2,3,4}}); auto r=particle::ingress::QbinV2Decoder::decode(meta(),fixture(2,2,ps)); EXPECT_TRUE(r.accepted())<<r.detail; }
|
||||
TEST(ParticleQbinV2Decoder, RejectsTooSmallInputBeforeMagic) { for (const size_t size : {size_t(0), size_t(1), Header - 1}) { std::vector<uint8_t> b(size); EXPECT_EQ(particle::ingress::QbinV2Decoder::decode(meta(), b).code, Code::InputTooSmall) << size; } }
|
||||
TEST(ParticleQbinV2Decoder, RejectsBadMagicAfterCompleteHeader) { std::vector<uint8_t> b(Header, 0); EXPECT_EQ(particle::ingress::QbinV2Decoder::decode(meta(), b).code, Code::BadMagic); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsInvalidRuntimeMetadataButAllowsZeroDataVersion) { auto b=fixture(); auto empty=meta(); empty.fieldID.clear(); EXPECT_EQ(particle::ingress::QbinV2Decoder::decode(empty, b).code, Code::InvalidRuntimeMetadata); auto zeroGeneration=meta(); zeroGeneration.generation=0; EXPECT_EQ(particle::ingress::QbinV2Decoder::decode(zeroGeneration, b).code, Code::InvalidRuntimeMetadata); auto zeroDataVersion=meta(); zeroDataVersion.dataVersion=0; EXPECT_TRUE(particle::ingress::QbinV2Decoder::decode(zeroDataVersion, b).accepted()); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsBigEndianHeaderWidthWithValidCrc) { auto b=fixture(); b[16]=0; b[17]=0; b[18]=0; b[19]=2; recalc(b); EXPECT_EQ(decodeCode(b), Code::LimitExceeded); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsMixedEndianDescriptorCountWithValidCrc) { auto b=fixture(); b[124]=0; b[125]=0; b[126]=0; b[127]=4; recalc(b); EXPECT_EQ(decodeCode(b), Code::InvalidDescriptor); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsHeaderVariants) { auto b=fixture(); b[4]=3; recalc(b); EXPECT_EQ(decodeCode(b),Code::UnsupportedVersion); b=fixture();b[5]=2;recalc(b);EXPECT_EQ(decodeCode(b),Code::UnsupportedLayout); b=fixture();b[8]=2;recalc(b);EXPECT_EQ(decodeCode(b),Code::UnsupportedProjection); b=fixture();b[9]=2;recalc(b);EXPECT_EQ(decodeCode(b),Code::UnsupportedSampleOrder); b=fixture();b[10]=2;recalc(b);EXPECT_EQ(decodeCode(b),Code::UnsupportedGridRegistration); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsFrameAndReservedHeader) { auto b=fixture();u16(b,80,0);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidHeader); b=fixture();b[11]=1;recalc(b);EXPECT_EQ(decodeCode(b),Code::ReservedNonZero); b=fixture();u32(b,92,1);recalc(b);EXPECT_EQ(decodeCode(b),Code::ReservedNonZero); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsGridTimeAndBounds) { auto b=fixture();u32(b,16,1);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidHeader); b=fixture();u32(b,16,2049);u32(b,20,1024);recalc(b);EXPECT_EQ(decodeCode(b),Code::LimitExceeded); b=fixture();i64(b,24,0);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidTime); b=fixture();i64(b,32,0);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidTime); b=fixture();i64(b,24,std::numeric_limits<int64_t>::max());recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidTime); b=fixture();f64(b,40,std::numeric_limits<double>::quiet_NaN());recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidBounds); b=fixture();f64(b,40,11);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidBounds); b=fixture();f64(b,48,86);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidBounds); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsLengthAndFileLimits) { auto b=fixture();b.pop_back();EXPECT_EQ(decodeCode(b),Code::LengthMismatch); b=fixture();b.push_back(0);EXPECT_EQ(decodeCode(b),Code::LengthMismatch); std::vector<uint8_t> huge(64u*1024u*1024u+1);EXPECT_EQ(decodeCode(huge),Code::LimitExceeded); }
|
||||
TEST(ParticleQbinV2Decoder, LimitsDecodedBytesWithoutChargingCanonicalPadding) { std::vector<P> ps={{3,0,0,6,1,3,0,1,0,{}}}; for(uint16_t semantic=4;semantic<13;++semantic) ps.push_back({semantic,77,0,5,0,0,0,1,0,{}}); EXPECT_EQ(decodeCode(fixture(1024,1024,ps)),Code::LimitExceeded); }
|
||||
TEST(ParticleQbinV2Decoder, AcceptsRawLimitWithCanonicalPaddingExcluded) {
|
||||
constexpr uint64_t limit = 32ull * 1024ull * 1024ull;
|
||||
const uint32_t width = 1101, height = 1890;
|
||||
const uint64_t cells = uint64_t(width) * height;
|
||||
ASSERT_LE(cells, 2'097'152u);
|
||||
|
||||
std::vector<P> ps = {{3, 0, 0, 6, 1, 3, 0, 1, 0, {}}};
|
||||
for (uint16_t semantic = 4; semantic < 20; ++semantic) {
|
||||
ps.push_back({semantic, 77, 0, 3, 0, 0, 0, 1, 0, {}});
|
||||
}
|
||||
|
||||
uint64_t rawSum = 0;
|
||||
uint64_t payloadLength = 0;
|
||||
for (const auto& p : ps) {
|
||||
const uint64_t bytes = rawLength(p.type, cells);
|
||||
rawSum += bytes;
|
||||
payloadLength = a16(payloadLength + bytes);
|
||||
}
|
||||
|
||||
auto b = fixture(width, height, ps);
|
||||
const uint64_t totalQbinBytes = b.size();
|
||||
EXPECT_LE(rawSum, limit);
|
||||
EXPECT_GT(payloadLength, limit);
|
||||
EXPECT_GT(payloadLength, rawSum);
|
||||
EXPECT_LE(totalQbinBytes, 64ull * 1024ull * 1024ull);
|
||||
ASSERT_EQ(totalQbinBytes, Header + ps.size() * Descriptor + payloadLength);
|
||||
|
||||
auto r = particle::ingress::QbinV2Decoder::decode(meta(), b);
|
||||
EXPECT_TRUE(r.accepted()) << r.detail;
|
||||
}
|
||||
TEST(ParticleQbinV2Decoder, RejectsPlaneCountAndDescriptorEnums) { auto b=fixture();u16(b,12,0);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidHeader); b=fixture();u16(b,12,33);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidHeader); b=fixture();b[114]=0;recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidDescriptor); b=fixture();b[140]=4;recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidDescriptor); b=fixture();b[141]=4;recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidDescriptor); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsDescriptorMetadataAndNumericParameters) { auto b=fixture();u16(b,142,1);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidDescriptor); b=fixture();b[115]=2;recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidDescriptor); b=fixture();u32(b,124,3);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidDescriptor); b=fixture();f32(b,116,0);recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidDescriptor); b=fixture();f32(b,116,std::numeric_limits<float>::infinity());recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidDescriptor); b=fixture();f32(b,120,std::numeric_limits<float>::quiet_NaN());recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidDescriptor); }
|
||||
TEST(ParticleQbinV2Decoder, EnforcesBitsetAndValidityPairing) { auto ps=basePlanes();ps[0].type=6;ps[0].raw={15};auto b=fixture(2,2,ps);EXPECT_EQ(decodeCode(b),Code::InvalidValidity); ps=basePlanes();ps[2].type=3;ps[2].raw={0,0,0,0};b=fixture(2,2,ps);EXPECT_EQ(decodeCode(b),Code::InvalidValidity); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsCanonicalOrderDuplicateAndPacking) { auto ps=basePlanes();std::swap(ps[0],ps[1]);auto b=fixture(2,2,ps);EXPECT_EQ(decodeCode(b),Code::NonCanonicalDescriptorOrder); ps=basePlanes();ps[1].semantic=1;b=fixture(2,2,ps);EXPECT_EQ(decodeCode(b),Code::DuplicatePlaneKey); b=fixture();u32(b,128,16);recalc(b);EXPECT_EQ(decodeCode(b),Code::NonCanonicalPacking); b=fixture();u32(b,160,32);recalc(b);EXPECT_EQ(decodeCode(b),Code::NonCanonicalPacking); b=fixture();u32(b,72,64);b.resize(Header+3*Descriptor+64);recalc(b);EXPECT_EQ(decodeCode(b),Code::NonCanonicalPacking); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsPaddingAndCrcCorruption) { auto b=fixture();b[216]=1;recalc(b);EXPECT_EQ(decodeCode(b),Code::NonZeroPadding); b=fixture();b.back()=1;recalc(b);EXPECT_EQ(decodeCode(b),Code::NonZeroPadding); b=fixture();b[6]^=1;EXPECT_EQ(decodeCode(b),Code::CrcMismatch); b=fixture();b[136]^=1;EXPECT_EQ(decodeCode(b),Code::CrcMismatch); b=fixture();b[208]^=1;EXPECT_EQ(decodeCode(b),Code::CrcMismatch); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsValidityIntegrityAndInvalidCells) { auto b=fixture();b[240]=0xf0;recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidValidity); b=fixture();b[240]=0x0e;recalc(b);EXPECT_EQ(decodeCode(b),Code::InvalidCellPayload); auto ps=basePlanes();ps.push_back({4,0,0,6,1,3,0,1,0,{15}});b=fixture(2,2,ps);EXPECT_EQ(decodeCode(b),Code::InvalidValidity); }
|
||||
TEST(ParticleQbinV2Decoder, RejectsInvalidCellPayloadForEveryNumericDType) { for (uint8_t type : {uint8_t(1),uint8_t(2),uint8_t(3),uint8_t(4),uint8_t(5)}) { auto ps=basePlanes();ps[0].type=type;ps[0].raw.assign(rawLength(type,4),0);ps[0].raw[0]=1;ps[2].raw={0x0e};EXPECT_EQ(decodeCode(fixture(2,2,ps)),Code::InvalidCellPayload)<<int(type); } }
|
||||
TEST(ParticleQbinV2Decoder, PlaneIdentityIsIndependentAndCanonical) { auto ps=basePlanes();ps.push_back({50,777,0,3,0,0,0,1,0,{1,2,3,4}});auto a=fixture(2,2,ps);ps[3].raw[0]=9;auto b=fixture(2,2,ps);auto ra=particle::ingress::QbinV2Decoder::decode(meta(),a),rb=particle::ingress::QbinV2Decoder::decode(meta(),b);ASSERT_TRUE(ra.accepted());ASSERT_TRUE(rb.accepted());EXPECT_NE(crc(a),crc(b));EXPECT_EQ(ra.frame->planes[0].contentIdentity,rb.frame->planes[0].contentIdentity);EXPECT_EQ(ra.frame->planes[1].contentIdentity,rb.frame->planes[1].contentIdentity);EXPECT_NE(ra.frame->planes[2].contentIdentity,rb.frame->planes[2].contentIdentity);EXPECT_EQ(ra.frame->validityContentIdentity,rb.frame->validityContentIdentity); EXPECT_EQ(ra.frame->planes[0].contentIdentity,"75930fed16c969a7"); }
|
||||
TEST(ParticleQbinV2Decoder, DecodesLargeFixtureByteForByte) { auto b=fixture(513,769);auto r=particle::ingress::QbinV2Decoder::decode(meta(),b);ASSERT_TRUE(r.accepted())<<r.detail;const uint64_t cells=513u*769u;const size_t uv=cells*2, validity=(cells+7)/8;EXPECT_EQ(b.size()-208,a16(a16(uv)+uv)+a16(validity));EXPECT_EQ(r.frame->planes[0].storage->size(),uv);EXPECT_EQ(r.frame->planes[1].storage->size(),uv);EXPECT_EQ(r.frame->validityBits->size(),validity);EXPECT_EQ(r.frame->validityPlane.storage,r.frame->validityBits);EXPECT_EQ(*r.frame->planes[0].storage,std::vector<uint8_t>(b.begin()+208,b.begin()+208+uv));EXPECT_EQ(*r.frame->planes[1].storage,std::vector<uint8_t>(b.begin()+208+a16(uv),b.begin()+208+a16(uv)+uv));EXPECT_EQ(*r.frame->validityBits,std::vector<uint8_t>(b.begin()+208+a16(a16(uv)+uv),b.begin()+208+a16(a16(uv)+uv)+validity)); }
|
||||
TEST(ParticleEncodedFieldIngress, AcceptsAndRejectsContracts) { auto good=wind();EXPECT_TRUE(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&good,fixture()).accepted());EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),nullptr,fixture()).code,particle::ingress::EncodedFieldIngressCode::ContractMissing);auto invalid=good;invalid.contractSchemaVersion=2;EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&invalid,fixture()).code,particle::ingress::EncodedFieldIngressCode::ContractRejected);auto missing=good;missing.requiredPlanes.pop_back();EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&missing,fixture()).code,particle::ingress::EncodedFieldIngressCode::ContractRejected); }
|
||||
TEST(ParticleEncodedFieldIngress, RejectsKnownCoreConflictAndAcceptsUnknownOpaqueScalar) { auto ps=basePlanes();ps[0]={1,65535,0,3,1,0,0,1,0,{1,2,3,4}};auto c=contract({{1,particle::FieldValueClass::Scalar,65535,0,particle::FieldComponent::None,true},{2,particle::FieldValueClass::VelocityComponent,1,1,particle::FieldComponent::VNorth,true},{3,particle::FieldValueClass::Validity,0,0,particle::FieldComponent::None,true}});EXPECT_EQ(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&c,fixture(2,2,ps)).code,particle::ingress::EncodedFieldIngressCode::ContractRejected);ps={{3,0,0,6,1,3,0,1,0,{15}},{99,65535,0,3,1,0,0,1,0,{1,2,3,4}}};c=contract({{3,particle::FieldValueClass::Validity,0,0,particle::FieldComponent::None,true},{99,particle::FieldValueClass::Scalar,65535,0,particle::FieldComponent::None,true}});EXPECT_TRUE(particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(),&c,fixture(2,2,ps)).accepted()); }
|
||||
TEST(ParticleEncodedFieldIngress, RetainsResolvedProfileIdentityForRuntimeWindowing) {
|
||||
auto resolved = wind();
|
||||
resolved.profileID = "wind.test.v2";
|
||||
resolved.profileVersion = 2;
|
||||
const auto ingress = particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(), &resolved, fixture());
|
||||
ASSERT_TRUE(ingress.accepted()) << ingress.detail;
|
||||
EXPECT_EQ(ingress.frame->resolvedProfileID, "wind.test.v2");
|
||||
EXPECT_EQ(ingress.frame->resolvedProfileVersion, 2u);
|
||||
}
|
||||
618
test/particle/qbin_v3_decoder.test.cpp
Normal file
618
test/particle/qbin_v3_decoder.test.cpp
Normal file
@@ -0,0 +1,618 @@
|
||||
#include <mln/particle/ingress/qbin_v3_decoder.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
using namespace mln;
|
||||
namespace {
|
||||
|
||||
constexpr size_t Header = 112;
|
||||
constexpr size_t Descriptor = 32;
|
||||
|
||||
void u16(std::vector<uint8_t>& b, size_t o, uint16_t v) {
|
||||
b[o] = uint8_t(v);
|
||||
b[o + 1] = uint8_t(v >> 8);
|
||||
}
|
||||
void u32(std::vector<uint8_t>& b, size_t o, uint32_t v) {
|
||||
for (size_t i = 0; i < 4; ++i) b[o + i] = uint8_t(v >> (8 * i));
|
||||
}
|
||||
uint32_t r32(const std::vector<uint8_t>& b, size_t o) {
|
||||
uint32_t v = 0;
|
||||
for (size_t i = 0; i < 4; ++i) v |= uint32_t(b[o + i]) << (8 * i);
|
||||
return v;
|
||||
}
|
||||
void u64(std::vector<uint8_t>& b, size_t o, uint64_t v) {
|
||||
for (size_t i = 0; i < 8; ++i) b[o + i] = uint8_t(v >> (8 * i));
|
||||
}
|
||||
void i64(std::vector<uint8_t>& b, size_t o, int64_t v) {
|
||||
u64(b, o, static_cast<uint64_t>(v));
|
||||
}
|
||||
void f32(std::vector<uint8_t>& b, size_t o, float v) {
|
||||
uint32_t bits = 0;
|
||||
std::memcpy(&bits, &v, sizeof(bits));
|
||||
u32(b, o, bits);
|
||||
}
|
||||
void f64(std::vector<uint8_t>& b, size_t o, double v) {
|
||||
uint64_t bits = 0;
|
||||
std::memcpy(&bits, &v, sizeof(bits));
|
||||
u64(b, o, bits);
|
||||
}
|
||||
size_t a16(size_t x) { return (x + 15u) & ~size_t(15u); }
|
||||
uint32_t crc(const std::vector<uint8_t>& b) {
|
||||
uint32_t c = ~0u;
|
||||
for (size_t i = 0; i < b.size(); ++i) {
|
||||
const uint8_t x = i >= 76 && i < 80 ? 0 : b[i];
|
||||
c ^= x;
|
||||
for (int j = 0; j < 8; ++j) c = (c >> 1) ^ ((c & 1u) ? 0xedb88320u : 0);
|
||||
}
|
||||
return ~c;
|
||||
}
|
||||
void recalc(std::vector<uint8_t>& b) { u32(b, 76, crc(b)); }
|
||||
bool validAt(const std::vector<uint8_t>& v, uint64_t i) {
|
||||
return ((v[i >> 3] >> (i & 7u)) & 1u) != 0;
|
||||
}
|
||||
int64_t normDir(int64_t x, uint32_t n) {
|
||||
if (x < 0) x += n;
|
||||
else if (x >= int64_t(n)) x -= n;
|
||||
return x;
|
||||
}
|
||||
uint64_t zigzag(int64_t r) {
|
||||
return r >= 0 ? uint64_t(2 * r) : uint64_t(-2 * r - 1);
|
||||
}
|
||||
|
||||
struct Bits {
|
||||
std::vector<uint8_t> bytes;
|
||||
size_t pos = 0;
|
||||
void bit(uint8_t value) {
|
||||
if ((pos & 7u) == 0) bytes.push_back(0);
|
||||
if (value) bytes.back() |= uint8_t(1u << (pos & 7u));
|
||||
++pos;
|
||||
}
|
||||
void bits(uint32_t value, uint8_t count) {
|
||||
for (uint8_t i = 0; i < count; ++i) bit((value >> i) & 1u);
|
||||
}
|
||||
};
|
||||
|
||||
struct PlaneSpec {
|
||||
uint16_t semantic = 1;
|
||||
uint8_t dtype = 1;
|
||||
uint8_t flags = 1;
|
||||
float scale = 1.0f;
|
||||
float offset = 0.0f;
|
||||
uint16_t unit = 0;
|
||||
uint16_t group = 0;
|
||||
uint8_t valueClass = 0;
|
||||
uint8_t component = 0;
|
||||
std::vector<int64_t> values;
|
||||
};
|
||||
|
||||
int64_t predict(const PlaneSpec& p,
|
||||
const std::vector<uint8_t>& validity,
|
||||
uint32_t width,
|
||||
uint32_t row,
|
||||
uint32_t col,
|
||||
uint32_t n) {
|
||||
const uint64_t i = uint64_t(row) * width + col;
|
||||
const bool left = col > 0 && validAt(validity, i - 1);
|
||||
const bool up = row > 0 && validAt(validity, i - width);
|
||||
const bool upperLeft = row > 0 && col > 0 && validAt(validity, i - width - 1);
|
||||
int64_t predicted = 0;
|
||||
if (left && up && upperLeft) {
|
||||
predicted = p.values[i - 1] + p.values[i - width] - p.values[i - width - 1];
|
||||
} else if (left) {
|
||||
predicted = p.values[i - 1];
|
||||
} else if (up) {
|
||||
predicted = p.values[i - width];
|
||||
}
|
||||
return n ? normDir(predicted, n) : predicted;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encodePlane(const PlaneSpec& p,
|
||||
const std::vector<uint8_t>& validity,
|
||||
uint32_t width,
|
||||
uint32_t height,
|
||||
std::optional<uint8_t> forcedK = std::nullopt) {
|
||||
const uint32_t n = p.valueClass == 2
|
||||
? uint32_t(std::floor(360.0 / double(p.scale) + 0.5))
|
||||
: 0;
|
||||
std::vector<uint8_t> out;
|
||||
for (uint32_t rb = 0; rb < height; rb += 16) {
|
||||
for (uint32_t cb = 0; cb < width; cb += 16) {
|
||||
std::vector<uint64_t> symbols;
|
||||
for (uint32_t row = rb; row < std::min(rb + 16u, height); ++row) {
|
||||
for (uint32_t col = cb; col < std::min(cb + 16u, width); ++col) {
|
||||
const uint64_t i = uint64_t(row) * width + col;
|
||||
if (!validAt(validity, i)) continue;
|
||||
const int64_t predicted = predict(p, validity, width, row, col, n);
|
||||
int64_t residual = p.values[i] - predicted;
|
||||
if (n) {
|
||||
int64_t m = residual;
|
||||
if (m < 0) m += n;
|
||||
residual = 2 * m > int64_t(n) ? m - n
|
||||
: 2 * m < int64_t(n) ? m
|
||||
: -int64_t(n / 2);
|
||||
}
|
||||
symbols.push_back(zigzag(residual));
|
||||
}
|
||||
}
|
||||
if (symbols.empty()) continue;
|
||||
|
||||
uint8_t bestK = forcedK.value_or(0);
|
||||
if (!forcedK) {
|
||||
uint64_t bestBits = ~uint64_t(0);
|
||||
for (uint8_t k = 0; k <= 31; ++k) {
|
||||
uint64_t bits = 0;
|
||||
for (const auto z : symbols) bits += (z >> k) + 1u + k;
|
||||
if (bits < bestBits) {
|
||||
bestBits = bits;
|
||||
bestK = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push_back(bestK);
|
||||
Bits writer;
|
||||
for (const auto z : symbols) {
|
||||
const uint64_t q = z >> bestK;
|
||||
for (uint64_t j = 0; j < q; ++j) writer.bit(0);
|
||||
writer.bit(1);
|
||||
if (bestK) writer.bits(uint32_t(z & ((uint64_t(1) << bestK) - 1u)), bestK);
|
||||
}
|
||||
out.insert(out.end(), writer.bytes.begin(), writer.bytes.end());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> rawBytes(const PlaneSpec& p, const std::vector<uint8_t>& validity) {
|
||||
const size_t stride = p.dtype == 1 || p.dtype == 2 ? 2u : 1u;
|
||||
std::vector<uint8_t> out(p.values.size() * stride, 0);
|
||||
for (size_t i = 0; i < p.values.size(); ++i) {
|
||||
if (!validAt(validity, i)) continue;
|
||||
if (stride == 2) {
|
||||
const uint16_t v = static_cast<uint16_t>(p.values[i]);
|
||||
out[i * 2] = uint8_t(v);
|
||||
out[i * 2 + 1] = uint8_t(v >> 8);
|
||||
} else {
|
||||
out[i] = static_cast<uint8_t>(p.values[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> fixture(uint32_t width,
|
||||
uint32_t height,
|
||||
std::vector<uint8_t> validity,
|
||||
std::vector<PlaneSpec> planes,
|
||||
std::optional<uint8_t> forcedK = std::nullopt,
|
||||
uint16_t fieldType = 999) {
|
||||
const uint64_t cells = uint64_t(width) * height;
|
||||
const uint16_t count = uint16_t(planes.size() + 1);
|
||||
const size_t payloadOffset = Header + size_t(count) * Descriptor;
|
||||
|
||||
struct Encoded { PlaneSpec p; std::vector<uint8_t> bytes; };
|
||||
std::vector<Encoded> encoded;
|
||||
for (auto& p : planes) encoded.push_back({p, encodePlane(p, validity, width, height, forcedK)});
|
||||
|
||||
size_t payloadLength = 0;
|
||||
for (const auto& e : encoded) payloadLength = a16(payloadLength + e.bytes.size());
|
||||
payloadLength = a16(payloadLength + validity.size());
|
||||
std::vector<uint8_t> b(payloadOffset + payloadLength, 0);
|
||||
std::memcpy(b.data(), "QBV3", 4);
|
||||
b[4] = 3;
|
||||
b[5] = 1;
|
||||
u16(b, 6, fieldType);
|
||||
b[8] = b[9] = b[10] = 1;
|
||||
u16(b, 12, count);
|
||||
u32(b, 16, width);
|
||||
u32(b, 20, height);
|
||||
i64(b, 24, 100);
|
||||
i64(b, 32, 90);
|
||||
f64(b, 40, -10);
|
||||
f64(b, 48, -10);
|
||||
f64(b, 56, 10);
|
||||
f64(b, 64, 10);
|
||||
u32(b, 72, payloadLength);
|
||||
u16(b, 80, 0xffff);
|
||||
|
||||
size_t off = 0;
|
||||
for (size_t i = 0; i < encoded.size(); ++i) {
|
||||
const auto& e = encoded[i];
|
||||
const size_t d = Header + i * Descriptor;
|
||||
u16(b, d, e.p.semantic);
|
||||
b[d + 2] = e.p.dtype;
|
||||
b[d + 3] = e.p.flags;
|
||||
f32(b, d + 4, e.p.scale);
|
||||
f32(b, d + 8, e.p.offset);
|
||||
u32(b, d + 12, cells);
|
||||
u32(b, d + 16, off);
|
||||
u32(b, d + 20, e.bytes.size());
|
||||
u16(b, d + 24, e.p.unit);
|
||||
u16(b, d + 26, e.p.group);
|
||||
b[d + 28] = e.p.valueClass;
|
||||
b[d + 29] = e.p.component;
|
||||
std::copy(e.bytes.begin(), e.bytes.end(), b.begin() + payloadOffset + off);
|
||||
off = a16(off + e.bytes.size());
|
||||
}
|
||||
|
||||
const size_t d = Header + encoded.size() * Descriptor;
|
||||
u16(b, d, 3);
|
||||
b[d + 2] = 6;
|
||||
b[d + 3] = 1;
|
||||
f32(b, d + 4, 1.0f);
|
||||
f32(b, d + 8, 0.0f);
|
||||
u32(b, d + 12, cells);
|
||||
u32(b, d + 16, off);
|
||||
u32(b, d + 20, validity.size());
|
||||
b[d + 28] = 3;
|
||||
std::copy(validity.begin(), validity.end(), b.begin() + payloadOffset + off);
|
||||
recalc(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> fixtureV2(uint32_t width,
|
||||
uint32_t height,
|
||||
const std::vector<uint8_t>& validity,
|
||||
const std::vector<PlaneSpec>& planes,
|
||||
uint16_t fieldType = 999) {
|
||||
const uint64_t cells = uint64_t(width) * height;
|
||||
const uint16_t count = uint16_t(planes.size() + 1);
|
||||
const size_t payloadOffset = Header + size_t(count) * Descriptor;
|
||||
|
||||
struct RawPlane {
|
||||
PlaneSpec p;
|
||||
std::vector<uint8_t> bytes;
|
||||
};
|
||||
std::vector<RawPlane> raws;
|
||||
for (const auto& p : planes) raws.push_back({p, rawBytes(p, validity)});
|
||||
|
||||
size_t payloadLength = 0;
|
||||
for (const auto& r : raws) payloadLength = a16(payloadLength + r.bytes.size());
|
||||
payloadLength = a16(payloadLength + validity.size());
|
||||
|
||||
std::vector<uint8_t> b(payloadOffset + payloadLength, 0);
|
||||
std::memcpy(b.data(), "QBV2", 4);
|
||||
b[4] = 2;
|
||||
b[5] = 1;
|
||||
u16(b, 6, fieldType);
|
||||
b[8] = b[9] = b[10] = 1;
|
||||
u16(b, 12, count);
|
||||
u32(b, 16, width);
|
||||
u32(b, 20, height);
|
||||
i64(b, 24, 100);
|
||||
i64(b, 32, 90);
|
||||
f64(b, 40, -10);
|
||||
f64(b, 48, -10);
|
||||
f64(b, 56, 10);
|
||||
f64(b, 64, 10);
|
||||
u32(b, 72, payloadLength);
|
||||
u16(b, 80, 0xffff);
|
||||
|
||||
size_t off = 0;
|
||||
for (size_t i = 0; i < raws.size(); ++i) {
|
||||
const auto& r = raws[i];
|
||||
const size_t d = Header + i * Descriptor;
|
||||
u16(b, d, r.p.semantic);
|
||||
b[d + 2] = r.p.dtype;
|
||||
b[d + 3] = r.p.flags;
|
||||
f32(b, d + 4, r.p.scale);
|
||||
f32(b, d + 8, r.p.offset);
|
||||
u32(b, d + 12, cells);
|
||||
u32(b, d + 16, off);
|
||||
u32(b, d + 20, r.bytes.size());
|
||||
u16(b, d + 24, r.p.unit);
|
||||
u16(b, d + 26, r.p.group);
|
||||
b[d + 28] = r.p.valueClass;
|
||||
b[d + 29] = r.p.component;
|
||||
std::copy(r.bytes.begin(), r.bytes.end(), b.begin() + payloadOffset + off);
|
||||
off = a16(off + r.bytes.size());
|
||||
}
|
||||
|
||||
const size_t d = Header + raws.size() * Descriptor;
|
||||
u16(b, d, 3);
|
||||
b[d + 2] = 6;
|
||||
b[d + 3] = 1;
|
||||
f32(b, d + 4, 1.0f);
|
||||
f32(b, d + 8, 0.0f);
|
||||
u32(b, d + 12, cells);
|
||||
u32(b, d + 16, off);
|
||||
u32(b, d + 20, validity.size());
|
||||
b[d + 28] = 3;
|
||||
std::copy(validity.begin(), validity.end(), b.begin() + payloadOffset + off);
|
||||
recalc(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> validityBits(uint32_t cells, bool allValid = true) {
|
||||
std::vector<uint8_t> v((cells + 7u) / 8u, allValid ? 0xffu : 0u);
|
||||
if (allValid && (cells & 7u)) v.back() = uint8_t((1u << (cells & 7u)) - 1u);
|
||||
return v;
|
||||
}
|
||||
|
||||
particle::ingress::EncodedFieldRuntimeMetadata meta() {
|
||||
return {"qbv3-test", 1, 1};
|
||||
}
|
||||
|
||||
using Code = particle::ingress::QbinDecodeErrorCode;
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ParticleQbinV3Decoder, DecodesAcrossBlockBoundaryAndDirectionWrap) {
|
||||
constexpr uint32_t width = 17, height = 17, cells = width * height;
|
||||
auto validity = validityBits(cells);
|
||||
validity[3] &= uint8_t(~(1u << 1)); // one invalid cell
|
||||
std::vector<int64_t> scalar(cells), direction(cells);
|
||||
for (uint32_t r = 0; r < height; ++r) for (uint32_t c = 0; c < width; ++c) {
|
||||
const size_t i = r * width + c;
|
||||
scalar[i] = int64_t(r) * 40 - int64_t(c) * 7;
|
||||
direction[i] = (350 + int64_t(c) * 3 + int64_t(r) * 11) % 360;
|
||||
}
|
||||
PlaneSpec a{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, scalar};
|
||||
PlaneSpec d{2, 2, 1, 1.0f, 0.0f, 4, 0, 2, 0, direction};
|
||||
auto bytes = fixture(width, height, validity, {a, d});
|
||||
auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
|
||||
ASSERT_TRUE(result.accepted()) << result.detail;
|
||||
ASSERT_EQ(result.frame->planes.size(), 2u);
|
||||
EXPECT_EQ(*result.frame->planes[0].storage, rawBytes(a, validity));
|
||||
EXPECT_EQ(*result.frame->planes[1].storage, rawBytes(d, validity));
|
||||
EXPECT_EQ(*result.frame->validityBits, validity);
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, AcceptsAllInvalidWithSharedZeroLengthOffsets) {
|
||||
constexpr uint32_t width = 2, height = 2, cells = 4;
|
||||
auto validity = validityBits(cells, false);
|
||||
PlaneSpec a{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1,2,3,4}};
|
||||
PlaneSpec d{2, 2, 1, 1.0f, 0.0f, 4, 0, 2, 0, {0,90,180,270}};
|
||||
auto bytes = fixture(width, height, validity, {a, d});
|
||||
const size_t d0 = Header, d1 = Header + Descriptor, dv = Header + 2 * Descriptor;
|
||||
EXPECT_EQ(r32(bytes, d0 + 16), 0u);
|
||||
EXPECT_EQ(r32(bytes, d1 + 16), 0u);
|
||||
EXPECT_EQ(r32(bytes, dv + 16), 0u);
|
||||
auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
|
||||
ASSERT_TRUE(result.accepted()) << result.detail;
|
||||
EXPECT_EQ(*result.frame->planes[0].storage, std::vector<uint8_t>(cells * 2, 0));
|
||||
EXPECT_EQ(*result.frame->planes[1].storage, std::vector<uint8_t>(cells * 2, 0));
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, AcceptsNonOptimalRiceK) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0,0,0,0}};
|
||||
auto bytes = fixture(2, 2, validity, {p}, uint8_t(5));
|
||||
auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
|
||||
EXPECT_TRUE(result.accepted()) << result.detail;
|
||||
}
|
||||
|
||||
|
||||
TEST(ParticleQbinV3Decoder, RejectsCrcDescriptorAndValidityCorruption) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1,2,3,4}};
|
||||
|
||||
auto bytes = fixture(2, 2, validity, {p});
|
||||
bytes[6] ^= 1;
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::CrcMismatch);
|
||||
|
||||
bytes = fixture(2, 2, validity, {p});
|
||||
bytes[Header + 2] = 5;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidDescriptor);
|
||||
|
||||
bytes = fixture(2, 2, validity, {p});
|
||||
const size_t validityDescriptor = Header + Descriptor;
|
||||
bytes[validityDescriptor + 2] = 3;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidValidity);
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, RejectsMalformedRiceAndNonzeroBlockPadding) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0,0,0,0}};
|
||||
|
||||
auto bytes = fixture(2, 2, validity, {p});
|
||||
const size_t payload = Header + 2 * Descriptor;
|
||||
bytes[payload] = 32;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice);
|
||||
|
||||
auto oneValid = validityBits(4, false);
|
||||
oneValid[0] = 0x01;
|
||||
bytes = fixture(2, 2, oneValid, {p}, uint8_t(0));
|
||||
bytes[payload + 1] |= 0x80;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice);
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, RejectsResidualOutsideLogicalRange) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec p{1, 3, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0,0,0,0}};
|
||||
auto bytes = fixture(2, 2, validity, {p}, uint8_t(31));
|
||||
const size_t payload = Header + 2 * Descriptor;
|
||||
ASSERT_GT(bytes.size(), payload + 4);
|
||||
bytes[payload + 4] |= 0x80;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::ResidualOutOfRange);
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, PreservesNodeRegisteredGridGeometry) {
|
||||
auto validity = validityBits(9);
|
||||
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, std::vector<int64_t>(9, 0)};
|
||||
auto bytes = fixture(3, 3, validity, {p});
|
||||
auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
|
||||
ASSERT_TRUE(result.accepted()) << result.detail;
|
||||
|
||||
const auto& g = result.frame->grid;
|
||||
EXPECT_EQ(g.width, 3u);
|
||||
EXPECT_EQ(g.height, 3u);
|
||||
EXPECT_DOUBLE_EQ(g.west, -10.0);
|
||||
EXPECT_DOUBLE_EQ(g.south, -10.0);
|
||||
EXPECT_DOUBLE_EQ(g.east, 10.0);
|
||||
EXPECT_DOUBLE_EQ(g.north, 10.0);
|
||||
EXPECT_DOUBLE_EQ(g.west + (g.east - g.west) * 1.0 / (g.width - 1), 0.0);
|
||||
EXPECT_DOUBLE_EQ(g.south + (g.north - g.south) * 1.0 / (g.height - 1), 0.0);
|
||||
}
|
||||
|
||||
TEST(ParticleEncodedFieldIngress, DispatchesQbinV3AndRejectsUnknownMagic) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec u{1, 1, 1, 1.0f, 0.0f, 1, 1, 1, 1, {1,2,3,4}};
|
||||
PlaneSpec v{2, 1, 1, 1.0f, 0.0f, 1, 1, 1, 2, {5,6,7,8}};
|
||||
auto bytes = fixture(2, 2, validity, {u, v});
|
||||
|
||||
particle::ResolvedFieldProfileContract contract;
|
||||
contract.profileID = "qbv3.wind.test";
|
||||
contract.profileVersion = 1;
|
||||
contract.requiredPlanes = {
|
||||
{1, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::UEast, true},
|
||||
{2, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::VNorth, true},
|
||||
{3, particle::FieldValueClass::Validity, 0, 0, particle::FieldComponent::None, true},
|
||||
};
|
||||
|
||||
auto accepted = particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(), &contract, bytes);
|
||||
EXPECT_TRUE(accepted.accepted()) << accepted.detail;
|
||||
|
||||
bytes[0] = 'X';
|
||||
recalc(bytes);
|
||||
auto rejected = particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(), &contract, bytes);
|
||||
EXPECT_EQ(rejected.code, particle::ingress::EncodedFieldIngressCode::DecodeRejected);
|
||||
EXPECT_EQ(rejected.decodeCode, Code::BadMagic);
|
||||
}
|
||||
|
||||
|
||||
TEST(ParticleQbinV3Decoder, CoversAllCompressedIntegerDtypes) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec u8{1, 3, 1, 0.5f, 1.0f, 77, 0, 0, 0, {0, 255, 128, 1}};
|
||||
PlaneSpec i8{2, 4, 1, 2.0f, -5.0f, 78, 0, 0, 0, {-128, 127, -1, 0}};
|
||||
auto bytes = fixture(2, 2, validity, {u8, i8});
|
||||
auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
|
||||
ASSERT_TRUE(result.accepted()) << result.detail;
|
||||
ASSERT_EQ(result.frame->planes.size(), 2u);
|
||||
EXPECT_EQ(*result.frame->planes[0].storage, rawBytes(u8, validity));
|
||||
EXPECT_EQ(*result.frame->planes[1].storage, rawBytes(i8, validity));
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, AcceptsOpaqueFieldTypeZeroAndUnknown) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1, 2, 3, 4}};
|
||||
EXPECT_TRUE(particle::ingress::QbinV3Decoder::decode(meta(), fixture(2, 2, validity, {p}, std::nullopt, 0)).accepted());
|
||||
EXPECT_TRUE(particle::ingress::QbinV3Decoder::decode(meta(), fixture(2, 2, validity, {p}, std::nullopt, 65535)).accepted());
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, RejectsHeaderVersionEndianReservedBoundsAndNegativeZero) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1, 2, 3, 4}};
|
||||
|
||||
auto bytes = fixture(2, 2, validity, {p});
|
||||
bytes[0] = 'X';
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::BadMagic);
|
||||
|
||||
bytes = fixture(2, 2, validity, {p});
|
||||
bytes[4] = 2;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::UnsupportedVersion);
|
||||
|
||||
bytes = fixture(2, 2, validity, {p});
|
||||
bytes[11] = 1;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::ReservedNonZero);
|
||||
|
||||
bytes = fixture(2, 2, validity, {p});
|
||||
bytes[16] = 0;
|
||||
bytes[17] = 0;
|
||||
bytes[18] = 0;
|
||||
bytes[19] = 2;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::LimitExceeded);
|
||||
|
||||
bytes = fixture(2, 2, validity, {p});
|
||||
f64(bytes, 40, 11.0);
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidBounds);
|
||||
|
||||
bytes = fixture(2, 2, validity, {p});
|
||||
u64(bytes, 40, 0x8000000000000000ull);
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidBounds);
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, RejectsDescriptorOrderDuplicateAndPacking) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec u{1, 1, 1, 1.0f, 0.0f, 1, 1, 1, 1, {1, 2, 3, 4}};
|
||||
PlaneSpec v{2, 1, 1, 1.0f, 0.0f, 1, 1, 1, 2, {5, 6, 7, 8}};
|
||||
|
||||
auto bytes = fixture(2, 2, validity, {u, v});
|
||||
u16(bytes, Header, 2);
|
||||
u16(bytes, Header + Descriptor, 1);
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::NonCanonicalDescriptorOrder);
|
||||
|
||||
bytes = fixture(2, 2, validity, {u, v});
|
||||
u16(bytes, Header + Descriptor, 1);
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::DuplicatePlaneKey);
|
||||
|
||||
bytes = fixture(2, 2, validity, {u, v});
|
||||
u32(bytes, Header + Descriptor + 16, 0);
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::NonCanonicalPacking);
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, RejectsUnterminatedUnaryExtraEncodedByteAndRawOverflow) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec zero{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0, 0, 0, 0}};
|
||||
const size_t payload = Header + 2 * Descriptor;
|
||||
|
||||
auto bytes = fixture(2, 2, validity, {zero}, uint8_t(0));
|
||||
bytes[payload + 1] = 0;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice);
|
||||
|
||||
bytes = fixture(2, 2, validity, {zero}, uint8_t(0));
|
||||
const uint32_t length = r32(bytes, Header + 20);
|
||||
ASSERT_LT(payload + length, bytes.size());
|
||||
u32(bytes, Header + 20, length + 1);
|
||||
bytes[payload + length] = 0;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice);
|
||||
|
||||
PlaneSpec overflow{1, 3, 1, 1.0f, 0.0f, 77, 0, 0, 0, {300, 0, 0, 0}};
|
||||
bytes = fixture(2, 2, validity, {overflow});
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidCellPayload);
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, EnforcesDirectionHalfCircleNegativeTie) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec direction{1, 2, 1, 90.0f, 0.0f, 4, 0, 2, 0, {0, 2, 0, 2}};
|
||||
auto bytes = fixture(2, 2, validity, {direction}, uint8_t(0));
|
||||
auto canonical = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
|
||||
ASSERT_TRUE(canonical.accepted()) << canonical.detail;
|
||||
|
||||
const size_t payload = Header + 2 * Descriptor;
|
||||
ASSERT_EQ(r32(bytes, Header + 20), 2u);
|
||||
// canonical bits are 0x71: 0, -2, 0, 0. Replace the -2 symbol
|
||||
// with the forbidden +2 half-circle representation (z=4), yielding 0xe1.
|
||||
bytes[payload + 1] = 0xe1;
|
||||
recalc(bytes);
|
||||
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::ResidualOutOfRange);
|
||||
}
|
||||
|
||||
TEST(ParticleQbinV3Decoder, ContentIdentityMatchesQbinV2CanonicalFrame) {
|
||||
auto validity = validityBits(4);
|
||||
PlaneSpec u{1, 1, 1, 0.25f, 0.0f, 1, 1, 1, 1, {1, -2, 3, -4}};
|
||||
PlaneSpec v{2, 1, 1, 0.25f, 0.0f, 1, 1, 1, 2, {5, 6, -7, 8}};
|
||||
auto v2 = particle::ingress::QbinV2Decoder::decode(meta(), fixtureV2(2, 2, validity, {u, v}));
|
||||
auto v3 = particle::ingress::QbinV3Decoder::decode(meta(), fixture(2, 2, validity, {u, v}));
|
||||
ASSERT_TRUE(v2.accepted()) << v2.detail;
|
||||
ASSERT_TRUE(v3.accepted()) << v3.detail;
|
||||
ASSERT_EQ(v2.frame->planes.size(), v3.frame->planes.size());
|
||||
for (size_t i = 0; i < v2.frame->planes.size(); ++i) {
|
||||
EXPECT_EQ(*v2.frame->planes[i].storage, *v3.frame->planes[i].storage);
|
||||
EXPECT_EQ(v2.frame->planes[i].contentIdentity, v3.frame->planes[i].contentIdentity);
|
||||
}
|
||||
EXPECT_EQ(*v2.frame->validityBits, *v3.frame->validityBits);
|
||||
EXPECT_EQ(v2.frame->validityContentIdentity, v3.frame->validityContentIdentity);
|
||||
}
|
||||
103
test/renderer/mtl_retained_attachments.test.cpp
Normal file
103
test/renderer/mtl_retained_attachments.test.cpp
Normal file
@@ -0,0 +1,103 @@
|
||||
#if MLN_RENDER_BACKEND_METAL
|
||||
|
||||
#include <mln/mtl/context.hpp>
|
||||
#include <mln/mtl/offscreen_texture.hpp>
|
||||
#include <mln/mtl/render_pass.hpp>
|
||||
#include <mln/mtl/renderable_resource.hpp>
|
||||
#include <mln/mtl/renderer_backend.hpp>
|
||||
|
||||
#include <Foundation/Foundation.hpp>
|
||||
#include <Metal/Metal.hpp>
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdlib>
|
||||
|
||||
using namespace mln;
|
||||
|
||||
namespace {
|
||||
|
||||
class TestMetalBackend final : public mtl::RendererBackend {
|
||||
public:
|
||||
TestMetalBackend() : RendererBackend(gfx::ContextMode::Unique) {}
|
||||
|
||||
gfx::Renderable& getDefaultRenderable() override { std::abort(); }
|
||||
void updateAssumedState() override {}
|
||||
|
||||
private:
|
||||
void activate() override {}
|
||||
void deactivate() override {}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(MetalRetainedAttachments, ClearActionsApplyToCombinedDepthStencil) {
|
||||
auto pool = NS::TransferPtr(NS::AutoreleasePool::alloc()->init());
|
||||
auto device = NS::TransferPtr(MTL::CreateSystemDefaultDevice());
|
||||
if (!device) GTEST_SKIP() << "Metal device unavailable";
|
||||
|
||||
auto colorDescriptor = MTL::TextureDescriptor::texture2DDescriptor(MTL::PixelFormatRGBA8Unorm, 4, 4, false);
|
||||
auto combinedDescriptor =
|
||||
MTL::TextureDescriptor::texture2DDescriptor(MTL::PixelFormatDepth32Float_Stencil8, 4, 4, false);
|
||||
colorDescriptor->setUsage(MTL::TextureUsageRenderTarget);
|
||||
combinedDescriptor->setUsage(MTL::TextureUsageRenderTarget);
|
||||
auto colorTexture = NS::TransferPtr(device->newTexture(colorDescriptor));
|
||||
auto combinedTexture = NS::TransferPtr(device->newTexture(combinedDescriptor));
|
||||
if (!combinedTexture) GTEST_SKIP() << "Combined depth-stencil texture unavailable";
|
||||
ASSERT_TRUE(colorTexture);
|
||||
|
||||
auto descriptor = NS::TransferPtr(MTL::RenderPassDescriptor::alloc()->init());
|
||||
descriptor->colorAttachments()->object(0)->setTexture(colorTexture.get());
|
||||
descriptor->depthAttachment()->setTexture(combinedTexture.get());
|
||||
descriptor->stencilAttachment()->setTexture(combinedTexture.get());
|
||||
|
||||
const auto applied = mtl::detail::applyRenderPassClears(
|
||||
*descriptor.get(), Color{0.2f, 0.3f, 0.4f, 0.5f}, 1.0f, 0);
|
||||
EXPECT_TRUE(applied.color);
|
||||
EXPECT_TRUE(applied.depth);
|
||||
EXPECT_TRUE(applied.stencil);
|
||||
EXPECT_EQ(descriptor->colorAttachments()->object(0)->loadAction(), MTL::LoadActionClear);
|
||||
EXPECT_EQ(descriptor->depthAttachment()->loadAction(), MTL::LoadActionClear);
|
||||
EXPECT_EQ(descriptor->stencilAttachment()->loadAction(), MTL::LoadActionClear);
|
||||
EXPECT_EQ(descriptor->colorAttachments()->object(0)->storeAction(), MTL::StoreActionStore);
|
||||
EXPECT_EQ(descriptor->depthAttachment()->storeAction(), MTL::StoreActionStore);
|
||||
EXPECT_EQ(descriptor->stencilAttachment()->storeAction(), MTL::StoreActionStore);
|
||||
EXPECT_DOUBLE_EQ(descriptor->depthAttachment()->clearDepth(), 1.0);
|
||||
EXPECT_EQ(descriptor->stencilAttachment()->clearStencil(), 0u);
|
||||
EXPECT_EQ(descriptor->depthAttachment()->texture(), descriptor->stencilAttachment()->texture());
|
||||
}
|
||||
|
||||
TEST(MetalRetainedAttachments, OffscreenBindsRequestedAttachmentsAndSkipsAbsentOnes) {
|
||||
auto pool = NS::TransferPtr(NS::AutoreleasePool::alloc()->init());
|
||||
auto device = NS::TransferPtr(MTL::CreateSystemDefaultDevice());
|
||||
if (!device) GTEST_SKIP() << "Metal device unavailable";
|
||||
TestMetalBackend backend;
|
||||
mtl::Context context{backend};
|
||||
|
||||
mtl::OffscreenTexture retained(context, {4, 4}, gfx::TextureChannelDataType::UnsignedByte, true, true);
|
||||
ASSERT_TRUE(retained.isRenderable());
|
||||
auto& retainedResource = retained.getResource<mtl::RenderableResource>();
|
||||
retainedResource.bind();
|
||||
const auto& retainedDescriptor = retainedResource.getRenderPassDescriptor();
|
||||
ASSERT_TRUE(retainedDescriptor);
|
||||
ASSERT_NE(retainedDescriptor->colorAttachments()->object(0)->texture(), nullptr);
|
||||
ASSERT_NE(retainedDescriptor->depthAttachment()->texture(), nullptr);
|
||||
ASSERT_NE(retainedDescriptor->stencilAttachment()->texture(), nullptr);
|
||||
#if TARGET_OS_SIMULATOR || defined(__x86_64__)
|
||||
EXPECT_EQ(retainedDescriptor->depthAttachment()->texture(), retainedDescriptor->stencilAttachment()->texture());
|
||||
#else
|
||||
EXPECT_NE(retainedDescriptor->depthAttachment()->texture(), retainedDescriptor->stencilAttachment()->texture());
|
||||
#endif
|
||||
|
||||
mtl::OffscreenTexture colorOnly(context, {4, 4}, gfx::TextureChannelDataType::UnsignedByte, false, false);
|
||||
ASSERT_TRUE(colorOnly.isRenderable());
|
||||
auto& colorResource = colorOnly.getResource<mtl::RenderableResource>();
|
||||
colorResource.bind();
|
||||
const auto& colorDescriptor = colorResource.getRenderPassDescriptor();
|
||||
ASSERT_TRUE(colorDescriptor);
|
||||
EXPECT_EQ(colorDescriptor->depthAttachment()->texture(), nullptr);
|
||||
EXPECT_EQ(colorDescriptor->stencilAttachment()->texture(), nullptr);
|
||||
const auto applied = mtl::detail::applyRenderPassClears(*colorDescriptor.get(), std::nullopt, 1.0f, 0);
|
||||
EXPECT_FALSE(applied.depth);
|
||||
EXPECT_FALSE(applied.stencil);
|
||||
}
|
||||
|
||||
#endif // MLN_RENDER_BACKEND_METAL
|
||||
81
test/renderer/particle_viewport_reseed.test.cpp
Normal file
81
test/renderer/particle_viewport_reseed.test.cpp
Normal file
@@ -0,0 +1,81 @@
|
||||
#include <mln/renderer/particle_viewport_reseed.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
using namespace mln;
|
||||
|
||||
TEST(ParticleViewportReseed, ZoomOutBeyondEpsilonReseedsOnce) {
|
||||
ParticleViewportReseedGesture gesture;
|
||||
gesture.begin(10.0);
|
||||
|
||||
const auto decision = gesture.finish(8.0, true);
|
||||
EXPECT_EQ(decision.action, ParticleViewportReseedAction::Reseed);
|
||||
EXPECT_DOUBLE_EQ(decision.startZoom, 10.0);
|
||||
EXPECT_DOUBLE_EQ(decision.endZoom, 8.0);
|
||||
EXPECT_FALSE(gesture.isActive());
|
||||
EXPECT_EQ(gesture.finish(7.0, true).action, ParticleViewportReseedAction::None);
|
||||
}
|
||||
|
||||
TEST(ParticleViewportReseed, ZoomInDoesNotReseed) {
|
||||
ParticleViewportReseedGesture gesture;
|
||||
gesture.begin(8.0);
|
||||
|
||||
EXPECT_EQ(gesture.finish(10.0, true).action, ParticleViewportReseedAction::None);
|
||||
EXPECT_FALSE(gesture.isActive());
|
||||
}
|
||||
|
||||
TEST(ParticleViewportReseed, PanDoesNotReseed) {
|
||||
ParticleViewportReseedGesture gesture;
|
||||
gesture.begin(8.0);
|
||||
|
||||
EXPECT_EQ(gesture.finish(8.0, true).action, ParticleViewportReseedAction::None);
|
||||
}
|
||||
|
||||
TEST(ParticleViewportReseed, RotateOrPitchWithoutZoomDecreaseDoesNotReseed) {
|
||||
ParticleViewportReseedGesture gesture;
|
||||
gesture.begin(8.0);
|
||||
|
||||
EXPECT_EQ(gesture.finish(8.01, true).action, ParticleViewportReseedAction::None);
|
||||
}
|
||||
|
||||
TEST(ParticleViewportReseed, ZoomJitterWithinEpsilonDoesNotReseed) {
|
||||
ParticleViewportReseedGesture gesture;
|
||||
gesture.begin(8.0);
|
||||
|
||||
EXPECT_EQ(gesture.finish(8.0 - ParticleViewportReseedZoomEpsilon, true).action,
|
||||
ParticleViewportReseedAction::None);
|
||||
}
|
||||
|
||||
TEST(ParticleViewportReseed, InvalidDomainConsumesZoomOutRequest) {
|
||||
ParticleViewportReseedGesture gesture;
|
||||
gesture.begin(10.0);
|
||||
|
||||
const auto decision = gesture.finish(8.0, false);
|
||||
EXPECT_EQ(decision.action, ParticleViewportReseedAction::SkipInvalidDomain);
|
||||
EXPECT_FALSE(gesture.isActive());
|
||||
EXPECT_EQ(gesture.finish(7.0, true).action, ParticleViewportReseedAction::None);
|
||||
}
|
||||
|
||||
TEST(ParticleViewportReseed, ContinuousInvalidationKeepsFirstStartZoom) {
|
||||
ParticleViewportReseedGesture gesture;
|
||||
gesture.begin(10.0);
|
||||
gesture.begin(9.0);
|
||||
gesture.begin(8.0);
|
||||
|
||||
EXPECT_DOUBLE_EQ(gesture.getStartZoom(), 10.0);
|
||||
const auto decision = gesture.finish(7.0, true);
|
||||
EXPECT_DOUBLE_EQ(decision.startZoom, 10.0);
|
||||
EXPECT_EQ(decision.action, ParticleViewportReseedAction::Reseed);
|
||||
}
|
||||
|
||||
TEST(ParticleViewportReseed, NextGestureRefreshesStartZoom) {
|
||||
ParticleViewportReseedGesture gesture;
|
||||
gesture.begin(10.0);
|
||||
EXPECT_EQ(gesture.finish(8.0, true).action, ParticleViewportReseedAction::Reseed);
|
||||
|
||||
gesture.begin(6.0);
|
||||
EXPECT_DOUBLE_EQ(gesture.getStartZoom(), 6.0);
|
||||
const auto decision = gesture.finish(5.0, true);
|
||||
EXPECT_DOUBLE_EQ(decision.startZoom, 6.0);
|
||||
EXPECT_EQ(decision.action, ParticleViewportReseedAction::Reseed);
|
||||
}
|
||||
251
test/renderer/render_orchestrator_error.test.cpp
Normal file
251
test/renderer/render_orchestrator_error.test.cpp
Normal file
@@ -0,0 +1,251 @@
|
||||
#include <mln/actor/scheduler.hpp>
|
||||
#include <mln/gfx/uniform_buffer.hpp>
|
||||
#include <mln/renderer/layer_group.hpp>
|
||||
#include <mln/renderer/render_orchestrator.hpp>
|
||||
#include <mln/renderer/renderer_observer.hpp>
|
||||
#include <mln/renderer/retained_underlay_cache.hpp>
|
||||
#include <mln/renderer/render_source.hpp>
|
||||
#include <mln/renderer/render_source_observer.hpp>
|
||||
#include <mln/renderer/tile_render_data.hpp>
|
||||
#include <mln/style/sources/geojson_source_impl.hpp>
|
||||
#include <mln/tile/tile.hpp>
|
||||
#include <mln/tile/tile_observer.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <stdexcept>
|
||||
|
||||
using namespace mln;
|
||||
|
||||
namespace mln {
|
||||
struct RenderOrchestratorTeardownTestAccess {
|
||||
static void add(RenderOrchestrator& orchestrator, std::unique_ptr<RenderSource> source) {
|
||||
source->setObserver(&orchestrator);
|
||||
orchestrator.renderSources.emplace(source->baseImpl->id, std::move(source));
|
||||
}
|
||||
};
|
||||
} // namespace mln
|
||||
|
||||
namespace {
|
||||
|
||||
class TestUniformBuffers final : public gfx::UniformBufferArray {
|
||||
public:
|
||||
void bind(gfx::RenderPass&) override {}
|
||||
protected:
|
||||
std::unique_ptr<gfx::UniformBuffer> copy(const gfx::UniformBuffer&) override { return nullptr; }
|
||||
};
|
||||
|
||||
class TestLayerGroup final : public LayerGroup {
|
||||
public:
|
||||
explicit TestLayerGroup(int32_t index)
|
||||
: LayerGroup(index, 0, "step097-error-group") {}
|
||||
|
||||
const gfx::UniformBufferArray& getUniformBuffers() const override { return buffers; }
|
||||
gfx::UniformBufferArray& mutableUniformBuffers() override { return buffers; }
|
||||
|
||||
private:
|
||||
TestUniformBuffers buffers;
|
||||
};
|
||||
|
||||
class ErrorObserver final : public RendererObserver {
|
||||
public:
|
||||
void onRenderError(std::exception_ptr error) override {
|
||||
++errors;
|
||||
try {
|
||||
std::rethrow_exception(error);
|
||||
} catch (const std::runtime_error& e) {
|
||||
message = e.what();
|
||||
}
|
||||
}
|
||||
|
||||
int errors = 0;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(RenderOrchestratorError, RangeVisitorReportsOpaqueAndTranslucentFailures) {
|
||||
TaggedScheduler scheduler{Scheduler::GetBackground(), {}};
|
||||
RenderOrchestrator orchestrator(false, scheduler, std::nullopt);
|
||||
ErrorObserver observer;
|
||||
orchestrator.setObserver(&observer);
|
||||
ASSERT_TRUE(orchestrator.addLayerGroup(std::make_shared<TestLayerGroup>(0)));
|
||||
ASSERT_TRUE(orchestrator.addLayerGroup(std::make_shared<TestLayerGroup>(1)));
|
||||
|
||||
bool failed = false;
|
||||
orchestrator.visitLayerGroupsInRangeReversed(0, 2, [](LayerGroupBase&, size_t) {
|
||||
throw std::runtime_error("opaque failure");
|
||||
}, &failed);
|
||||
EXPECT_TRUE(failed);
|
||||
EXPECT_EQ(observer.errors, 2);
|
||||
EXPECT_EQ(observer.message, "opaque failure");
|
||||
|
||||
failed = false;
|
||||
observer.errors = 0;
|
||||
orchestrator.visitLayerGroupsInRange(0, 2, [](LayerGroupBase&, size_t) {
|
||||
throw std::runtime_error("translucent failure");
|
||||
}, &failed);
|
||||
EXPECT_TRUE(failed);
|
||||
EXPECT_EQ(observer.errors, 2);
|
||||
EXPECT_EQ(observer.message, "translucent failure");
|
||||
|
||||
failed = false;
|
||||
int visits = 0;
|
||||
orchestrator.visitLayerGroupsInRange(0, 2, [&](LayerGroupBase&, size_t) { ++visits; }, &failed);
|
||||
EXPECT_FALSE(failed);
|
||||
EXPECT_EQ(visits, 2);
|
||||
}
|
||||
|
||||
TEST(RenderOrchestratorError, FailedRangeCannotReuseAndCleanRetryCanRecover) {
|
||||
TaggedScheduler scheduler{Scheduler::GetBackground(), {}};
|
||||
RenderOrchestrator orchestrator(false, scheduler, std::nullopt);
|
||||
ErrorObserver observer;
|
||||
orchestrator.setObserver(&observer);
|
||||
ASSERT_TRUE(orchestrator.addLayerGroup(std::make_shared<TestLayerGroup>(0)));
|
||||
|
||||
RetainedUnderlayCache cache;
|
||||
RetainedUnderlayKey key{{4, 4}, 0, 1, {}};
|
||||
key.cameraMatrix[0] = key.cameraMatrix[5] = key.cameraMatrix[10] = key.cameraMatrix[15] = 1.0;
|
||||
for (bool opaque : {true, false}) {
|
||||
EXPECT_EQ(cache.evaluate(key, 1, true), RetainedUnderlayDecision::Build);
|
||||
bool failed = false;
|
||||
const auto throwOnRender = [](LayerGroupBase&, size_t) { throw std::runtime_error("render failure"); };
|
||||
if (opaque) orchestrator.visitLayerGroupsInRangeReversed(0, 1, throwOnRender, &failed);
|
||||
else orchestrator.visitLayerGroupsInRange(0, 1, throwOnRender, &failed);
|
||||
ASSERT_TRUE(failed);
|
||||
cache.markBuildFailed("layer-render-error");
|
||||
EXPECT_FALSE(cache.isValid());
|
||||
EXPECT_EQ(cache.evaluate(key, 1, true), RetainedUnderlayDecision::Build);
|
||||
}
|
||||
bool failed = false;
|
||||
orchestrator.visitLayerGroupsInRange(0, 1, [](LayerGroupBase&, size_t) {}, &failed);
|
||||
ASSERT_FALSE(failed);
|
||||
cache.markBuildSucceeded();
|
||||
EXPECT_EQ(cache.evaluate(key, 1, true), RetainedUnderlayDecision::Reuse);
|
||||
}
|
||||
|
||||
namespace {
|
||||
class TeardownTile final : public Tile {
|
||||
public:
|
||||
TeardownTile(TileObserver* observer_)
|
||||
: Tile(Kind::Geometry, OverscaledTileID{0, 0, {0, 0, 0}}, "step101-source", observer_) {}
|
||||
|
||||
~TeardownTile() override { observer->onTileAction(id, sourceID, TileOperation::Cancelled); }
|
||||
std::unique_ptr<TileRenderData> createRenderData() override { return nullptr; }
|
||||
void cancel() override {}
|
||||
bool layerPropertiesUpdated(const Immutable<style::LayerProperties>&) override { return true; }
|
||||
void emitChanged() { observer->onTileChanged(*this); }
|
||||
void emitError() { observer->onTileError(*this, std::make_exception_ptr(std::runtime_error("tile error"))); }
|
||||
void emitAction() { observer->onTileAction(id, sourceID, TileOperation::Cancelled); }
|
||||
};
|
||||
|
||||
class TeardownSource final : public RenderSource {
|
||||
public:
|
||||
TeardownSource(const TaggedScheduler& scheduler_, std::atomic<int>& deferred_, std::atomic<int>& renderJobs_,
|
||||
std::atomic<int>& detached_)
|
||||
: RenderSource(makeMutable<style::GeoJSONSource::Impl>(
|
||||
"step101-source", style::GeoJSONOptions::defaultOptions())),
|
||||
scheduler(scheduler_), deferred(deferred_), renderJobs(renderJobs_), detached(detached_),
|
||||
tile(std::make_unique<TeardownTile>(static_cast<TileObserver*>(this))) {}
|
||||
|
||||
~TeardownSource() override {
|
||||
if (observer != oldObserver) ++detached;
|
||||
tile.reset(); // A pending tile emits Cancelled synchronously during source destruction.
|
||||
scheduler.schedule([&count = deferred] { ++count; });
|
||||
scheduler.runOnRenderThread([&count = renderJobs] { ++count; });
|
||||
}
|
||||
|
||||
void expectDetachFrom(RenderSourceObserver* observer_) { oldObserver = observer_; }
|
||||
|
||||
void emitAll() {
|
||||
tile->emitChanged();
|
||||
tile->emitError();
|
||||
tile->emitAction();
|
||||
}
|
||||
bool isLoaded() const override { return false; }
|
||||
void update(Immutable<style::Source::Impl>, const std::vector<Immutable<style::LayerProperties>>&,
|
||||
bool, bool, const TileParameters&) override {}
|
||||
std::unique_ptr<RenderItem> createRenderItem() override { return nullptr; }
|
||||
void prepare(const SourcePrepareParameters&) override {}
|
||||
void updateFadingTiles() override {}
|
||||
bool hasFadingTiles() const override { return false; }
|
||||
RenderSourceReadinessStats getReadinessDiagnostics() const override {
|
||||
return {.tileBacked = true,
|
||||
.tileCount = std::nullopt,
|
||||
.incompleteTileCount = std::nullopt,
|
||||
.loadedTileCount = std::nullopt,
|
||||
.hasFadingTiles = false};
|
||||
}
|
||||
std::unordered_map<std::string, std::vector<Feature>> queryRenderedFeatures(
|
||||
const ScreenLineString&, const TransformState&,
|
||||
const std::unordered_map<std::string, const RenderLayer*>&,
|
||||
const RenderedQueryOptions&, const mat4&) const override { return {}; }
|
||||
std::vector<Feature> querySourceFeatures(const SourceQueryOptions&) const override { return {}; }
|
||||
void reduceMemoryUse() override {}
|
||||
void dumpDebugLogs() const override {}
|
||||
|
||||
private:
|
||||
TaggedScheduler scheduler;
|
||||
std::atomic<int>& deferred;
|
||||
std::atomic<int>& renderJobs;
|
||||
std::atomic<int>& detached;
|
||||
RenderSourceObserver* oldObserver = nullptr;
|
||||
std::unique_ptr<TeardownTile> tile;
|
||||
};
|
||||
|
||||
class CountingSourceObserver final : public RenderSourceObserver {
|
||||
public:
|
||||
void onTileChanged(RenderSource&, const OverscaledTileID&) override { ++changed; }
|
||||
void onTileError(RenderSource&, const OverscaledTileID&, std::exception_ptr) override { ++errors; }
|
||||
void onTileAction(RenderSource&, TileOperation, const OverscaledTileID&, const std::string&) override { ++actions; }
|
||||
int changed = 0;
|
||||
int errors = 0;
|
||||
int actions = 0;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST(RenderOrchestratorTeardown, NullObserverAndDetachCoverEveryTileCallback) {
|
||||
TaggedScheduler scheduler{Scheduler::GetBackground(), {}};
|
||||
std::atomic<int> deferred{0};
|
||||
std::atomic<int> renderJobs{0};
|
||||
std::atomic<int> detached{0};
|
||||
auto source = std::make_unique<TeardownSource>(scheduler, deferred, renderJobs, detached);
|
||||
source->emitAll(); // Constructor default NullObserver.
|
||||
|
||||
CountingSourceObserver observer;
|
||||
source->setObserver(&observer);
|
||||
source->expectDetachFrom(&observer);
|
||||
source->emitAll();
|
||||
EXPECT_EQ(observer.changed, 1);
|
||||
EXPECT_EQ(observer.errors, 1);
|
||||
EXPECT_EQ(observer.actions, 1);
|
||||
|
||||
source->setObserver(nullptr);
|
||||
source->emitAll();
|
||||
source.reset(); // Cancelled must not reach the detached observer.
|
||||
EXPECT_EQ(detached.load(), 1);
|
||||
EXPECT_EQ(observer.changed, 1);
|
||||
EXPECT_EQ(observer.errors, 1);
|
||||
EXPECT_EQ(observer.actions, 1);
|
||||
scheduler.waitForEmpty();
|
||||
scheduler.runRenderJobs();
|
||||
}
|
||||
|
||||
TEST(RenderOrchestratorTeardown, PendingTileCancellationAndSecondDrainRepeatSafely) {
|
||||
TaggedScheduler scheduler{Scheduler::GetBackground(), {}};
|
||||
std::atomic<int> deferred{0};
|
||||
std::atomic<int> renderJobs{0};
|
||||
std::atomic<int> detached{0};
|
||||
for (int iteration = 0; iteration < 20; ++iteration) {
|
||||
auto orchestrator = std::make_unique<RenderOrchestrator>(false, scheduler, std::nullopt);
|
||||
auto source = std::make_unique<TeardownSource>(scheduler, deferred, renderJobs, detached);
|
||||
source->expectDetachFrom(orchestrator.get());
|
||||
RenderOrchestratorTeardownTestAccess::add(*orchestrator, std::move(source));
|
||||
orchestrator.reset();
|
||||
EXPECT_EQ(detached.load(), iteration + 1); // Source was detached before its tile cancelled.
|
||||
EXPECT_EQ(deferred.load(), iteration + 1); // Work queued by source destruction was drained.
|
||||
scheduler.runRenderJobs(); // Render jobs have a separate backend-owned queue.
|
||||
EXPECT_EQ(renderJobs.load(), iteration + 1);
|
||||
}
|
||||
}
|
||||
1364
test/renderer/retained_underlay_cache.test.cpp
Normal file
1364
test/renderer/retained_underlay_cache.test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
61
test/renderer/weather_color_texture_lifecycle.test.cpp
Normal file
61
test/renderer/weather_color_texture_lifecycle.test.cpp
Normal file
@@ -0,0 +1,61 @@
|
||||
#include <mln/renderer/layers/weather_color_texture_lifecycle.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
using namespace mln;
|
||||
|
||||
TEST(WeatherColorTextureLifecycle, KeepsDrawablesForUnchangedEndpoints) {
|
||||
int texture0 = 0;
|
||||
int texture1 = 1;
|
||||
|
||||
EXPECT_FALSE(weatherColorDrawableBindingsChanged(&texture0, &texture1, &texture0, &texture1, false));
|
||||
// mix, opacity and color stops are dynamic UBO values and are not inputs
|
||||
// to the binding decision.
|
||||
}
|
||||
|
||||
TEST(WeatherColorTextureLifecycle, RebuildsForEitherOrBothReplacedEndpoints) {
|
||||
int texture0 = 0;
|
||||
int texture1 = 1;
|
||||
int replacement0 = 2;
|
||||
int replacement1 = 3;
|
||||
|
||||
EXPECT_TRUE(weatherColorDrawableBindingsChanged(&texture0, &texture1, &replacement0, &texture1, false));
|
||||
EXPECT_TRUE(weatherColorDrawableBindingsChanged(&texture0, &texture1, &texture0, &replacement1, false));
|
||||
EXPECT_TRUE(weatherColorDrawableBindingsChanged(&texture0, &texture1, &replacement0, &replacement1, false));
|
||||
}
|
||||
|
||||
TEST(WeatherColorTextureLifecycle, RebuildsWhenEndpointsAreCrossReusedOrCollapsed) {
|
||||
int texture0 = 0;
|
||||
int texture1 = 1;
|
||||
|
||||
EXPECT_TRUE(weatherColorDrawableBindingsChanged(&texture0, &texture1, &texture1, &texture0, false));
|
||||
EXPECT_TRUE(weatherColorDrawableBindingsChanged(&texture0, &texture1, &texture0, &texture0, false));
|
||||
EXPECT_TRUE(weatherColorDrawableBindingsChanged(&texture0, &texture0, &texture0, &texture1, false));
|
||||
EXPECT_FALSE(weatherColorDrawableBindingsChanged(&texture0, &texture0, &texture0, &texture0, false));
|
||||
}
|
||||
|
||||
TEST(WeatherColorTextureLifecycle, RebuildsWhenWrapRangeChangesWithoutTextureChanges) {
|
||||
int texture0 = 0;
|
||||
int texture1 = 1;
|
||||
|
||||
EXPECT_TRUE(weatherColorDrawableBindingsChanged(&texture0, &texture1, &texture0, &texture1, true));
|
||||
}
|
||||
|
||||
TEST(WeatherColorTextureLifecycle, DetectsEndpointReplacementAfterHiddenPeriod) {
|
||||
int previous0 = 0;
|
||||
int previous1 = 1;
|
||||
int resumed0 = 2;
|
||||
int resumed1 = 1;
|
||||
|
||||
// The layer may skip updates while hidden; on resume, the next update must
|
||||
// rebuild before drawable upload if its selected endpoint changed.
|
||||
EXPECT_TRUE(weatherColorDrawableBindingsChanged(&previous0, &previous1, &resumed0, &resumed1, false));
|
||||
}
|
||||
|
||||
TEST(WeatherColorTextureLifecycle, HandlesInitialAndClearedEndpoints) {
|
||||
int texture0 = 0;
|
||||
|
||||
EXPECT_FALSE(weatherColorDrawableBindingsChanged<int>(nullptr, nullptr, nullptr, nullptr, false));
|
||||
EXPECT_TRUE(weatherColorDrawableBindingsChanged<int>(nullptr, nullptr, &texture0, &texture0, false));
|
||||
EXPECT_TRUE(weatherColorDrawableBindingsChanged<int>(&texture0, &texture0, nullptr, nullptr, false));
|
||||
}
|
||||
971
test/source/raster_array_v4_host_api.test.cpp
Normal file
971
test/source/raster_array_v4_host_api.test.cpp
Normal file
@@ -0,0 +1,971 @@
|
||||
#include <mln/particle/ingress/qbin_v2_decoder.hpp>
|
||||
#include <mln/particle/field/field_projection_materialization.hpp>
|
||||
#include <mln/particle/field/offline_fixture_particle_adapter.hpp>
|
||||
#include <mln/particle/renderer_debug_counters.hpp>
|
||||
#include <mln/style/sources/raster_array_source.hpp>
|
||||
#include <mln/style/sources/raster_array_source_impl.hpp>
|
||||
#include <mln/style/source_observer.hpp>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
using namespace mln;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr size_t Header = 112;
|
||||
constexpr size_t Descriptor = 32;
|
||||
constexpr uint64_t Base = 1'700'000'000'000ULL;
|
||||
constexpr uint64_t Hour = 3'600'000ULL;
|
||||
|
||||
void u16(std::vector<uint8_t>& bytes, size_t offset, uint16_t value) {
|
||||
bytes[offset] = uint8_t(value);
|
||||
bytes[offset + 1] = uint8_t(value >> 8);
|
||||
}
|
||||
void u32(std::vector<uint8_t>& bytes, size_t offset, uint32_t value) {
|
||||
for (size_t i = 0; i < 4; ++i) bytes[offset + i] = uint8_t(value >> (8 * i));
|
||||
}
|
||||
void u64(std::vector<uint8_t>& bytes, size_t offset, uint64_t value) {
|
||||
for (size_t i = 0; i < 8; ++i) bytes[offset + i] = uint8_t(value >> (8 * i));
|
||||
}
|
||||
void i64(std::vector<uint8_t>& bytes, size_t offset, int64_t value) {
|
||||
u64(bytes, offset, static_cast<uint64_t>(value));
|
||||
}
|
||||
void f32(std::vector<uint8_t>& bytes, size_t offset, float value) {
|
||||
uint32_t bits;
|
||||
std::memcpy(&bits, &value, sizeof(bits));
|
||||
u32(bytes, offset, bits);
|
||||
}
|
||||
void f64(std::vector<uint8_t>& bytes, size_t offset, double value) {
|
||||
uint64_t bits;
|
||||
std::memcpy(&bits, &value, sizeof(bits));
|
||||
u64(bytes, offset, bits);
|
||||
}
|
||||
size_t aligned16(size_t value) {
|
||||
return (value + 15) & ~size_t(15);
|
||||
}
|
||||
uint32_t crc(const std::vector<uint8_t>& bytes) {
|
||||
uint32_t result = ~0u;
|
||||
for (size_t i = 0; i < bytes.size(); ++i) {
|
||||
uint8_t value = i >= 76 && i < 80 ? 0 : bytes[i];
|
||||
result ^= value;
|
||||
for (int bit = 0; bit < 8; ++bit) result = (result >> 1) ^ ((result & 1) ? 0xedb88320u : 0);
|
||||
}
|
||||
return ~result;
|
||||
}
|
||||
|
||||
struct Plane {
|
||||
uint16_t semantic, unit, group;
|
||||
uint8_t type, valueClass, component;
|
||||
std::vector<uint8_t> bytes;
|
||||
};
|
||||
|
||||
std::vector<uint8_t> qbin(uint64_t validTime, bool scalar = false, uint8_t validity = 0x0f,
|
||||
uint64_t referenceTime = Base) {
|
||||
std::vector<Plane> planes;
|
||||
if (scalar)
|
||||
planes.push_back({99, 77, 0, 3, 0, 0, {1, 2, 3, 4}});
|
||||
else {
|
||||
planes.push_back({1, 1, 1, 1, 1, 1, {1, 0, 2, 0, 3, 0, 4, 0}});
|
||||
planes.push_back({2, 1, 1, 1, 1, 2, {5, 0, 6, 0, 7, 0, 8, 0}});
|
||||
}
|
||||
for (auto& plane : planes) {
|
||||
if (plane.type == 6) continue;
|
||||
for (size_t cell = 0; cell < 4; ++cell) {
|
||||
if (validity & (1u << cell)) continue;
|
||||
const size_t bytesPerCell = plane.type == 1 || plane.type == 2 ? 2 : plane.type == 5 ? 4 : 1;
|
||||
std::fill_n(plane.bytes.begin() + cell * bytesPerCell, bytesPerCell, uint8_t{0});
|
||||
}
|
||||
}
|
||||
planes.push_back({3, 0, 0, 6, 3, 0, {validity}});
|
||||
std::sort(planes.begin(), planes.end(), [](const Plane& a, const Plane& b) { return a.semantic < b.semantic; });
|
||||
size_t payloadLength = 0;
|
||||
for (const auto& plane : planes) payloadLength = aligned16(payloadLength + plane.bytes.size());
|
||||
std::vector<uint8_t> bytes(Header + planes.size() * Descriptor + payloadLength);
|
||||
std::memcpy(bytes.data(), "QBV2", 4);
|
||||
bytes[4] = 2;
|
||||
bytes[5] = 1;
|
||||
u16(bytes, 6, 999);
|
||||
bytes[8] = bytes[9] = bytes[10] = 1;
|
||||
u16(bytes, 12, planes.size());
|
||||
u32(bytes, 16, 2);
|
||||
u32(bytes, 20, 2);
|
||||
i64(bytes, 24, static_cast<int64_t>(referenceTime / 1000));
|
||||
i64(bytes, 32, static_cast<int64_t>(validTime / 1000));
|
||||
f64(bytes, 40, -10);
|
||||
f64(bytes, 48, -10);
|
||||
f64(bytes, 56, 10);
|
||||
f64(bytes, 64, 10);
|
||||
u32(bytes, 72, payloadLength);
|
||||
u16(bytes, 80, 0xffff);
|
||||
size_t payloadOffset = 0;
|
||||
for (size_t i = 0; i < planes.size(); ++i) {
|
||||
const auto& plane = planes[i];
|
||||
const size_t descriptor = Header + i * Descriptor;
|
||||
u16(bytes, descriptor, plane.semantic);
|
||||
bytes[descriptor + 2] = plane.type;
|
||||
bytes[descriptor + 3] = 1;
|
||||
f32(bytes, descriptor + 4, 1);
|
||||
f32(bytes, descriptor + 8, 0);
|
||||
u32(bytes, descriptor + 12, 4);
|
||||
u32(bytes, descriptor + 16, payloadOffset);
|
||||
u32(bytes, descriptor + 20, plane.bytes.size());
|
||||
u16(bytes, descriptor + 24, plane.unit);
|
||||
u16(bytes, descriptor + 26, plane.group);
|
||||
bytes[descriptor + 28] = plane.valueClass;
|
||||
bytes[descriptor + 29] = plane.component;
|
||||
std::copy(plane.bytes.begin(),
|
||||
plane.bytes.end(),
|
||||
bytes.begin() + Header + planes.size() * Descriptor + payloadOffset);
|
||||
payloadOffset = aligned16(payloadOffset + plane.bytes.size());
|
||||
}
|
||||
u32(bytes, 76, crc(bytes));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
void recomputeQbinCrc(std::vector<uint8_t>& bytes) {
|
||||
u32(bytes, 76, 0);
|
||||
u32(bytes, 76, crc(bytes));
|
||||
}
|
||||
|
||||
particle::ingress::EncodedFieldRuntimeMetadata metadata(const char* id, uint64_t generation, uint64_t version = 1) {
|
||||
return {id, generation, version};
|
||||
}
|
||||
particle::ResolvedFieldProfileContract windContract() {
|
||||
particle::ResolvedFieldProfileContract result;
|
||||
result.profileID = "test.wind";
|
||||
result.profileVersion = 1;
|
||||
result.requiredPlanes = {
|
||||
{1, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::UEast, true},
|
||||
{2, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::VNorth, true},
|
||||
{3, particle::FieldValueClass::Validity, 0, 0, particle::FieldComponent::None, true}};
|
||||
return result;
|
||||
}
|
||||
particle::ResolvedFieldProfileContract waveContract() {
|
||||
particle::ResolvedFieldProfileContract result;
|
||||
result.profileID = "test.wave";
|
||||
result.profileVersion = 1;
|
||||
result.requiredPlanes = {{3, particle::FieldValueClass::Validity, 0, 0, particle::FieldComponent::None, true},
|
||||
{99, particle::FieldValueClass::Scalar, 77, 0, particle::FieldComponent::None, true}};
|
||||
return result;
|
||||
}
|
||||
void submit(style::RasterArraySource& source,
|
||||
const char* key,
|
||||
const char* id,
|
||||
uint64_t time,
|
||||
uint64_t generation,
|
||||
bool scalar = false) {
|
||||
EXPECT_EQ(source.submitEncodedField(
|
||||
key, metadata(id, generation), scalar ? waveContract() : windContract(), qbin(time, scalar)),
|
||||
style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted);
|
||||
}
|
||||
|
||||
void expectSameParticleProjection(const particle::ParticleProjection& expected,
|
||||
const particle::ParticleProjection& actual) {
|
||||
EXPECT_EQ(actual.contentIdentity, expected.contentIdentity);
|
||||
EXPECT_EQ(actual.contentIdentity.t0Coordinate, expected.contentIdentity.t0Coordinate);
|
||||
EXPECT_EQ(actual.contentIdentity.t1Coordinate, expected.contentIdentity.t1Coordinate);
|
||||
EXPECT_EQ(actual.t0FirstPlaneStorage, expected.t0FirstPlaneStorage);
|
||||
EXPECT_EQ(actual.t1FirstPlaneStorage, expected.t1FirstPlaneStorage);
|
||||
EXPECT_EQ(actual.t0SecondPlaneStorage, expected.t0SecondPlaneStorage);
|
||||
EXPECT_EQ(actual.t1SecondPlaneStorage, expected.t1SecondPlaneStorage);
|
||||
EXPECT_EQ(actual.t0ValidityStorage, expected.t0ValidityStorage);
|
||||
EXPECT_EQ(actual.t1ValidityStorage, expected.t1ValidityStorage);
|
||||
}
|
||||
|
||||
std::shared_ptr<const particle::DecodedFieldFrame> materializerFrame(const char* fieldID,
|
||||
uint64_t validTime,
|
||||
uint64_t generation,
|
||||
uint64_t dataVersion,
|
||||
particle::FieldPlaneKey key,
|
||||
particle::FieldDType dtype,
|
||||
particle::FieldValueClass valueClass,
|
||||
uint16_t unitID,
|
||||
float scale,
|
||||
float offset,
|
||||
std::vector<uint8_t> raw,
|
||||
uint8_t validity = 0x0f) {
|
||||
auto frame = std::make_shared<particle::DecodedFieldFrame>();
|
||||
frame->frameContentIdentity = {fieldID, generation, dataVersion, validTime};
|
||||
frame->referenceTimeMs = validTime;
|
||||
frame->grid = {1, 1, 1, 1, 2, 2, -10, -10, 10, 10};
|
||||
frame->validityContentIdentity = "validity";
|
||||
frame->validityBits = std::make_shared<const std::vector<uint8_t>>(std::vector<uint8_t>{validity});
|
||||
frame->validityPlane = {{3, 0, particle::FieldComponent::None},
|
||||
particle::FieldDType::Bitset,
|
||||
particle::FieldValueClass::Validity,
|
||||
0,
|
||||
true,
|
||||
4,
|
||||
1.0f,
|
||||
0.0f,
|
||||
"validity",
|
||||
frame->validityBits};
|
||||
auto storage = std::make_shared<const std::vector<uint8_t>>(std::move(raw));
|
||||
frame->planes.push_back({key, dtype, valueClass, unitID, true, 4, scale, offset, "plane", storage});
|
||||
return frame;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> float32LE(std::initializer_list<float> values) {
|
||||
std::vector<uint8_t> bytes(values.size() * 4);
|
||||
size_t offset = 0;
|
||||
for (const float value : values) {
|
||||
f32(bytes, offset, value);
|
||||
offset += 4;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> uint16LE(std::initializer_list<uint16_t> values) {
|
||||
std::vector<uint8_t> bytes(values.size() * 2);
|
||||
size_t offset = 0;
|
||||
for (const uint16_t value : values) {
|
||||
u16(bytes, offset, value);
|
||||
offset += 2;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(Source, RasterArrayV4HostApiRejectsWithoutPartialMutationAndMapsStoreResults) {
|
||||
style::RasterArraySource source{"v4"};
|
||||
const auto first = qbin(Base);
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), first),
|
||||
style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted);
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), first),
|
||||
style::RasterArrayEncodedFieldSubmitResult::Duplicate);
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 2, 2), windContract(), first),
|
||||
style::RasterArrayEncodedFieldSubmitResult::AcceptedReplacedRevision);
|
||||
submit(source, "wind", "wind", Base + Hour, 3);
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
|
||||
const auto baseline = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(baseline.accepted());
|
||||
|
||||
const auto expectNoPartialMutation = [&] {
|
||||
const auto resolved = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(resolved.accepted());
|
||||
expectSameParticleProjection(*baseline.projection, *resolved.projection);
|
||||
};
|
||||
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), first),
|
||||
style::RasterArrayEncodedFieldSubmitResult::RejectedStaleRevision);
|
||||
expectNoPartialMutation();
|
||||
auto corrupt = first;
|
||||
corrupt[0] = 'X';
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 3), windContract(), corrupt),
|
||||
style::RasterArrayEncodedFieldSubmitResult::RejectedDecode);
|
||||
expectNoPartialMutation();
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 3), waveContract(), first),
|
||||
style::RasterArrayEncodedFieldSubmitResult::RejectedContract);
|
||||
expectNoPartialMutation();
|
||||
EXPECT_EQ(source.submitEncodedField("", metadata("wind", 3), windContract(), first),
|
||||
style::RasterArrayEncodedFieldSubmitResult::RejectedInvalidFieldKey);
|
||||
expectNoPartialMutation();
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4HostApiRejectsKeepPublicProjectionStable) {
|
||||
style::RasterArraySource source{"v4"};
|
||||
const auto t0 = qbin(Base);
|
||||
const auto t1 = qbin(Base + Hour);
|
||||
submit(source, "wind", "wind", Base, 2);
|
||||
submit(source, "wind", "wind", Base + Hour, 3);
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
|
||||
const auto baseline = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(baseline.accepted());
|
||||
const auto expectNoPartialMutation = [&] {
|
||||
const auto resolved = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(resolved.accepted());
|
||||
expectSameParticleProjection(*baseline.projection, *resolved.projection);
|
||||
};
|
||||
|
||||
auto corrupt = t1;
|
||||
corrupt[0] = 'X';
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 4), windContract(), corrupt),
|
||||
style::RasterArrayEncodedFieldSubmitResult::RejectedDecode);
|
||||
expectNoPartialMutation();
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 4), waveContract(), t1),
|
||||
style::RasterArrayEncodedFieldSubmitResult::RejectedContract);
|
||||
expectNoPartialMutation();
|
||||
EXPECT_EQ(source.submitEncodedField("", metadata("wind", 4), windContract(), t1),
|
||||
style::RasterArrayEncodedFieldSubmitResult::RejectedInvalidFieldKey);
|
||||
expectNoPartialMutation();
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), t0),
|
||||
style::RasterArrayEncodedFieldSubmitResult::RejectedStaleRevision);
|
||||
expectNoPartialMutation();
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4RejectsNumericOverflowBeforeReplacingAcceptedRevision) {
|
||||
style::RasterArraySource source{"v4-numeric-transaction"};
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
|
||||
const auto baseline = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(baseline.accepted());
|
||||
const auto materialized = source.impl().v4RendererState.particle;
|
||||
ASSERT_TRUE(materialized);
|
||||
const auto countersBefore = particle::rendererDebugCounterSnapshot();
|
||||
|
||||
auto overflowing = qbin(Base + Hour);
|
||||
const size_t firstPlaneDescriptor = Header;
|
||||
f32(overflowing, firstPlaneDescriptor + 4, std::numeric_limits<float>::max());
|
||||
f32(overflowing, firstPlaneDescriptor + 8, std::numeric_limits<float>::max());
|
||||
recomputeQbinCrc(overflowing);
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 3, 2), windContract(), overflowing),
|
||||
style::RasterArrayEncodedFieldSubmitResult::RejectedContract);
|
||||
|
||||
const auto after = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(after.accepted());
|
||||
expectSameParticleProjection(*baseline.projection, *after.projection);
|
||||
EXPECT_EQ(after.projection->t1Frame, baseline.projection->t1Frame);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle, materialized);
|
||||
const auto countersAfter = particle::rendererDebugCounterSnapshot();
|
||||
EXPECT_EQ(countersAfter.v4ProjectionMaterialize, countersBefore.v4ProjectionMaterialize);
|
||||
EXPECT_EQ(countersAfter.fieldTextureUpload, countersBefore.fieldTextureUpload);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4KeepsValidityZeroCellsAsLegalNoData) {
|
||||
style::RasterArraySource source{"v4-validity-nodata"};
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), qbin(Base, false, 0x0e)),
|
||||
style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
|
||||
const auto projection = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(projection.accepted());
|
||||
const auto snapshots = particle::materializeParticleProjection(*projection.projection);
|
||||
ASSERT_TRUE(snapshots);
|
||||
ASSERT_EQ(snapshots->t0->valid.size(), 4u);
|
||||
EXPECT_EQ(snapshots->t0->valid[0], 0);
|
||||
EXPECT_EQ(snapshots->t0->valid[1], 1);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4HostApiResolvesIndependentBindingsFromOneForecastClock) {
|
||||
style::RasterArraySource source{"v4"};
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
submit(source, "wind", "wind", Base + 2 * Hour, 3);
|
||||
submit(source, "wave", "wave", Base, 1, true);
|
||||
submit(source, "wave", "wave", Base + 3 * Hour, 2, true);
|
||||
submit(source, "wave", "wave", Base + 6 * Hour, 3, true);
|
||||
source.setColorBinding({"wave", {99, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 77});
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + Hour / 2));
|
||||
const auto color = source.resolveColorProjection();
|
||||
const auto particles = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(color.accepted());
|
||||
ASSERT_TRUE(particles.accepted());
|
||||
EXPECT_FLOAT_EQ(color.projection->sampleState.mix, 0.5f);
|
||||
EXPECT_FLOAT_EQ(particles.projection->sampleState.mix, 0.5f);
|
||||
EXPECT_NE(color.projection->contentIdentity.t1Coordinate.validTimeMs,
|
||||
particles.projection->contentIdentity.t1Coordinate.validTimeMs);
|
||||
const auto identity = particles.projection->contentIdentity;
|
||||
source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing);
|
||||
ASSERT_TRUE(source.advanceForecastClock(10));
|
||||
const auto advanced = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(advanced.accepted());
|
||||
EXPECT_NE(advanced.projection->sampleState.mix, particles.projection->sampleState.mix);
|
||||
EXPECT_EQ(advanced.projection->contentIdentity, identity);
|
||||
source.setForecastPlaybackState(particle::ForecastPlaybackState::Paused);
|
||||
EXPECT_FALSE(source.advanceForecastClock(10));
|
||||
EXPECT_TRUE(source.setForecastPlaybackSpeed(0.5f));
|
||||
EXPECT_TRUE(source.setForecastPlaybackSpeed(1.0f));
|
||||
EXPECT_TRUE(source.setForecastPlaybackSpeed(2.0f));
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4ProductionClockTicksOnceForBothBindingsAndResetsOnCommands) {
|
||||
style::RasterArraySource source{"v4-clock"};
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
particle::FieldValueClass::VelocityComponent, 1});
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth}, {}, 1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + 1000));
|
||||
source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing);
|
||||
const auto origin = std::chrono::steady_clock::now();
|
||||
EXPECT_FALSE(source.tickForecastClock(origin));
|
||||
EXPECT_TRUE(source.tickForecastClock(origin + std::chrono::milliseconds(100)));
|
||||
EXPECT_FALSE(source.tickForecastClock(origin + std::chrono::milliseconds(100)));
|
||||
const auto color = source.resolveColorProjection();
|
||||
const auto particle = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(color.accepted());
|
||||
ASSERT_TRUE(particle.accepted());
|
||||
EXPECT_EQ(color.projection->sampleState.requestedForecastTimeMs, Base + 1100);
|
||||
EXPECT_EQ(particle.projection->sampleState.requestedForecastTimeMs, Base + 1100);
|
||||
source.setForecastPlaybackState(particle::ForecastPlaybackState::Paused);
|
||||
EXPECT_FALSE(source.tickForecastClock(origin + std::chrono::seconds(3)));
|
||||
EXPECT_EQ(source.resolveParticleProjection().projection->sampleState.requestedForecastTimeMs, Base + 1100);
|
||||
ASSERT_TRUE(source.setForecastPlaybackSpeed(2.0f));
|
||||
source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing);
|
||||
EXPECT_FALSE(source.tickForecastClock(origin + std::chrono::seconds(4)));
|
||||
EXPECT_TRUE(source.tickForecastClock(origin + std::chrono::milliseconds(4100)));
|
||||
EXPECT_EQ(source.resolveParticleProjection().projection->sampleState.requestedForecastTimeMs, Base + 1300);
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + 2000));
|
||||
EXPECT_FALSE(source.tickForecastClock(origin + std::chrono::seconds(5)));
|
||||
EXPECT_EQ(source.resolveParticleProjection().projection->sampleState.requestedForecastTimeMs, Base + 2000);
|
||||
source.clearAllEncodedFields();
|
||||
EXPECT_FALSE(source.tickForecastClock(origin + std::chrono::seconds(6)));
|
||||
EXPECT_FALSE(source.resolveParticleProjection().accepted());
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4ClockSourceNotificationReentryDoesNotAdvanceTwice) {
|
||||
style::RasterArraySource source{"v4-reentry"};
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
particle::FieldValueClass::VelocityComponent, 1});
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth}, {}, 1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + 1000));
|
||||
source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing);
|
||||
const auto origin = std::chrono::steady_clock::now();
|
||||
EXPECT_FALSE(source.tickForecastClock(origin));
|
||||
|
||||
struct ReentryObserver final : style::SourceObserver {
|
||||
style::RasterArraySource& source;
|
||||
std::chrono::steady_clock::time_point nestedTime;
|
||||
int notifications = 0;
|
||||
bool nestedAdvanced = false;
|
||||
ReentryObserver(style::RasterArraySource& src, std::chrono::steady_clock::time_point time)
|
||||
: source(src), nestedTime(time) {}
|
||||
void onSourceChanged(style::Source&) override {
|
||||
++notifications;
|
||||
nestedAdvanced = source.tickForecastClock(nestedTime) || nestedAdvanced;
|
||||
}
|
||||
} observer(source, origin + std::chrono::milliseconds(200));
|
||||
source.setObserver(&observer);
|
||||
ASSERT_TRUE(source.tickForecastClock(origin + std::chrono::milliseconds(100)));
|
||||
EXPECT_GE(observer.notifications, 1);
|
||||
EXPECT_FALSE(observer.nestedAdvanced);
|
||||
EXPECT_EQ(source.resolveParticleProjection().projection->sampleState.requestedForecastTimeMs, Base + 1100);
|
||||
ASSERT_TRUE(source.tickForecastClock(origin + std::chrono::milliseconds(200)));
|
||||
EXPECT_FALSE(observer.nestedAdvanced);
|
||||
EXPECT_EQ(source.resolveParticleProjection().projection->sampleState.requestedForecastTimeMs, Base + 1200);
|
||||
source.setObserver(nullptr);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4MixTicksReuseImmutableMaterializationsUntilEndpointChanges) {
|
||||
style::RasterArraySource source{"v4-materialization-cache"};
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
particle::FieldValueClass::VelocityComponent, 1});
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth}, {}, 1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + 1000));
|
||||
|
||||
const auto beforeTicks = particle::rendererDebugCounterSnapshot();
|
||||
const auto initialColor = source.impl().v4RendererState.color;
|
||||
const auto initialParticle = source.impl().v4RendererState.particle;
|
||||
ASSERT_TRUE(initialColor);
|
||||
ASSERT_TRUE(initialParticle);
|
||||
ASSERT_TRUE(initialColor->t0);
|
||||
ASSERT_TRUE(initialColor->t1);
|
||||
ASSERT_TRUE(initialParticle->t0);
|
||||
ASSERT_TRUE(initialParticle->t1);
|
||||
|
||||
source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing);
|
||||
const auto origin = std::chrono::steady_clock::now();
|
||||
EXPECT_FALSE(source.tickForecastClock(origin));
|
||||
for (uint64_t tick = 1; tick <= 50; ++tick)
|
||||
EXPECT_TRUE(source.tickForecastClock(origin + std::chrono::milliseconds(tick * 16)));
|
||||
|
||||
const auto afterTicks = particle::rendererDebugCounterSnapshot();
|
||||
const auto mixedColor = source.impl().v4RendererState.color;
|
||||
const auto mixedParticle = source.impl().v4RendererState.particle;
|
||||
ASSERT_TRUE(mixedColor);
|
||||
ASSERT_TRUE(mixedParticle);
|
||||
EXPECT_EQ(afterTicks.v4ProjectionMaterialize, beforeTicks.v4ProjectionMaterialize);
|
||||
EXPECT_EQ(afterTicks.fieldTextureUpload, beforeTicks.fieldTextureUpload);
|
||||
EXPECT_EQ(afterTicks.gpuResourceCreate, beforeTicks.gpuResourceCreate);
|
||||
EXPECT_EQ(afterTicks.gpuResourceRelease, beforeTicks.gpuResourceRelease);
|
||||
EXPECT_EQ(mixedColor->t0, initialColor->t0);
|
||||
EXPECT_EQ(mixedColor->t1, initialColor->t1);
|
||||
EXPECT_EQ(mixedParticle->t0, initialParticle->t0);
|
||||
EXPECT_EQ(mixedParticle->t1, initialParticle->t1);
|
||||
EXPECT_EQ(mixedParticle->sampleState.requestedForecastTimeMs, Base + 1800);
|
||||
|
||||
submit(source, "wind", "wind", Base + 2 * Hour, 3);
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + 1000));
|
||||
const auto afterEndpointChange = particle::rendererDebugCounterSnapshot();
|
||||
const auto advancedParticle = source.impl().v4RendererState.particle;
|
||||
ASSERT_TRUE(advancedParticle);
|
||||
EXPECT_EQ(afterEndpointChange.v4ProjectionMaterialize, afterTicks.v4ProjectionMaterialize + 2);
|
||||
EXPECT_NE(advancedParticle->t0, initialParticle->t0);
|
||||
EXPECT_NE(advancedParticle->t1, initialParticle->t1);
|
||||
|
||||
const auto beforeRevision = afterEndpointChange.v4ProjectionMaterialize;
|
||||
EXPECT_EQ(source.submitEncodedField("wind", metadata("wind", 4), windContract(), qbin(Base + Hour)),
|
||||
style::RasterArrayEncodedFieldSubmitResult::AcceptedReplacedRevision);
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + 1000));
|
||||
const auto afterRevision = particle::rendererDebugCounterSnapshot();
|
||||
const auto revisedParticle = source.impl().v4RendererState.particle;
|
||||
ASSERT_TRUE(revisedParticle);
|
||||
EXPECT_EQ(afterRevision.v4ProjectionMaterialize, beforeRevision + 2);
|
||||
EXPECT_NE(revisedParticle->t0, advancedParticle->t0);
|
||||
EXPECT_NE(revisedParticle->t1, advancedParticle->t1);
|
||||
|
||||
source.clearAllEncodedFields();
|
||||
EXPECT_FALSE(source.impl().v4RendererState.color);
|
||||
EXPECT_FALSE(source.impl().v4RendererState.particle);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4HostApiClearRetainsBindingsAndNeverTouchesV3) {
|
||||
style::RasterArraySource source{"v4"};
|
||||
const auto v3T0 = particle::OfflineFixtureParticleAdapter::load("uniform-east", 71);
|
||||
auto v3T1 = particle::OfflineFixtureParticleAdapter::load("uniform-north", 72);
|
||||
v3T1.validTime = v3T0.validTime + Hour;
|
||||
ASSERT_EQ(source.submitInitialRuntimeFieldWindow(v3T0, v3T1), style::RasterArrayRuntimeFieldUpdateResult::Applied);
|
||||
ASSERT_TRUE(source.impl().field);
|
||||
ASSERT_TRUE(source.impl().nextField);
|
||||
const auto v3FieldIdentity = source.impl().field->fieldID;
|
||||
const auto v3FieldGeneration = source.impl().field->generation;
|
||||
const auto v3FieldValidTime = source.impl().field->validTime;
|
||||
const auto v3NextIdentity = source.impl().nextField->fieldID;
|
||||
const auto v3NextGeneration = source.impl().nextField->generation;
|
||||
const auto v3NextValidTime = source.impl().nextField->validTime;
|
||||
const auto expectV3Unchanged = [&] {
|
||||
ASSERT_TRUE(source.impl().field);
|
||||
ASSERT_TRUE(source.impl().nextField);
|
||||
EXPECT_EQ(source.impl().field->fieldID, v3FieldIdentity);
|
||||
EXPECT_EQ(source.impl().field->generation, v3FieldGeneration);
|
||||
EXPECT_EQ(source.impl().field->validTime, v3FieldValidTime);
|
||||
EXPECT_EQ(source.impl().nextField->fieldID, v3NextIdentity);
|
||||
EXPECT_EQ(source.impl().nextField->generation, v3NextGeneration);
|
||||
EXPECT_EQ(source.impl().nextField->validTime, v3NextValidTime);
|
||||
};
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
submit(source, "wave", "wave", Base, 1, true);
|
||||
submit(source, "wave", "wave", Base + 3 * Hour, 2, true);
|
||||
source.setColorBinding({"wave", {99, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 77});
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour));
|
||||
ASSERT_TRUE(source.resolveColorProjection().accepted());
|
||||
EXPECT_TRUE(source.clearEncodedField("wave"));
|
||||
EXPECT_TRUE(source.resolveParticleProjection().accepted());
|
||||
EXPECT_EQ(source.resolveColorProjection().error, particle::BindingProjectionError::FieldSeriesMissing);
|
||||
expectV3Unchanged();
|
||||
submit(source, "wave", "wave", Base, 1, true);
|
||||
submit(source, "wave", "wave", Base + 3 * Hour, 2, true);
|
||||
EXPECT_TRUE(source.resolveColorProjection().accepted());
|
||||
source.clearAllEncodedFields();
|
||||
EXPECT_EQ(source.resolveColorProjection().error, particle::BindingProjectionError::FieldSeriesMissing);
|
||||
EXPECT_EQ(source.resolveParticleProjection().error, particle::BindingProjectionError::FieldSeriesMissing);
|
||||
expectV3Unchanged();
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
submit(source, "wave", "wave", Base, 1, true);
|
||||
submit(source, "wave", "wave", Base + 3 * Hour, 2, true);
|
||||
EXPECT_TRUE(source.resolveColorProjection().accepted());
|
||||
EXPECT_TRUE(source.resolveParticleProjection().accepted());
|
||||
expectV3Unchanged();
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4RendererStateMaterializesBindingsAndMixWithoutTouchingV3) {
|
||||
style::RasterArraySource source{"v4-render"};
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
submit(source, "wave", "wave", Base, 1, true);
|
||||
submit(source, "wave", "wave", Base + Hour, 2, true);
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
source.setColorBinding({"wave", {99, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 77});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
|
||||
ASSERT_TRUE(source.impl().v4RendererState.particle);
|
||||
ASSERT_TRUE(source.impl().v4RendererState.color);
|
||||
const auto& particles = *source.impl().v4RendererState.particle;
|
||||
const auto& color = *source.impl().v4RendererState.color;
|
||||
EXPECT_FLOAT_EQ(particles.sampleState.mix, 0.5f);
|
||||
EXPECT_FLOAT_EQ(color.sampleState.mix, 0.5f);
|
||||
ASSERT_TRUE(particles.t0);
|
||||
ASSERT_TRUE(color.t0);
|
||||
EXPECT_EQ(particles.t0->vector, (std::vector<float>{1, 5, 2, 6, 3, 7, 4, 8}));
|
||||
EXPECT_EQ(particles.t0->valid, (std::vector<uint8_t>{1, 1, 1, 1}));
|
||||
EXPECT_EQ(color.t0->scalar, (std::vector<float>{1, 2, 3, 4}));
|
||||
EXPECT_EQ(color.valueSource, particle::MaterializedColorValueSource::Scalar);
|
||||
const auto particleIdentity = particles.contentIdentity;
|
||||
const auto retainedParticleT0 = particles.t0;
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 4));
|
||||
ASSERT_TRUE(source.impl().v4RendererState.particle);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle->contentIdentity, particleIdentity);
|
||||
EXPECT_FLOAT_EQ(source.impl().v4RendererState.particle->sampleState.mix, 0.25f);
|
||||
ASSERT_TRUE(retainedParticleT0);
|
||||
EXPECT_EQ(retainedParticleT0->vector, (std::vector<float>{1, 5, 2, 6, 3, 7, 4, 8}));
|
||||
EXPECT_FALSE(source.impl().field);
|
||||
EXPECT_FALSE(source.impl().nextField);
|
||||
EXPECT_FALSE(source.impl().timeline);
|
||||
source.clearAllEncodedFields();
|
||||
EXPECT_FALSE(source.impl().v4RendererState.particle);
|
||||
EXPECT_FALSE(source.impl().v4RendererState.color);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4SingleExactFrameMaterializesAndSecondFrameEnablesInterpolation) {
|
||||
style::RasterArraySource source{"v4-single-first"};
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
particle::FieldValueClass::VelocityComponent, 1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base));
|
||||
ASSERT_TRUE(source.resolveParticleProjection().accepted());
|
||||
ASSERT_TRUE(source.resolveColorProjection().accepted());
|
||||
ASSERT_TRUE(source.impl().v4RendererState.particle);
|
||||
ASSERT_TRUE(source.impl().v4RendererState.color);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle->t0, source.impl().v4RendererState.particle->t1);
|
||||
EXPECT_EQ(source.impl().v4RendererState.color->t0, source.impl().v4RendererState.color->t1);
|
||||
EXPECT_FLOAT_EQ(source.impl().v4RendererState.particle->sampleState.mix, 0.0f);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle->t0->validTime, Base);
|
||||
EXPECT_TRUE(source.impl().v4RendererState.particle->t0->hasVector());
|
||||
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
|
||||
const auto pair = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(pair.accepted());
|
||||
EXPECT_NE(pair.projection->t0Frame, pair.projection->t1Frame);
|
||||
EXPECT_FLOAT_EQ(pair.projection->sampleState.mix, 0.5f);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle->t0->validTime, Base);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle->t1->validTime, Base + Hour);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4RollingWindowPreservesAnUnchangedActivePair) {
|
||||
style::RasterArraySource source{"v4-rolling-window"};
|
||||
for (uint64_t index = 0; index < 8; ++index)
|
||||
submit(source, "wind", "wind", Base + index * Hour, index + 1);
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + Hour / 2));
|
||||
ASSERT_TRUE(source.resolveParticleProjection().accepted());
|
||||
// Source-level test: verify public projection behavior; Core tests own the
|
||||
// internal FieldSeries size/order assertions.
|
||||
const auto retainedProjection = source.impl().v4RendererState.particle;
|
||||
ASSERT_TRUE(retainedProjection);
|
||||
|
||||
submit(source, "wind", "wind", Base + 8 * Hour, 9);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle->sampleState.requestedForecastTimeMs,
|
||||
Base + Hour + Hour / 2);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle, retainedProjection);
|
||||
|
||||
submit(source, "wind", "wind", Base - Hour, 10);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle, retainedProjection);
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base - Hour));
|
||||
const auto pastProjection = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(pastProjection.accepted());
|
||||
ASSERT_TRUE(pastProjection.projection);
|
||||
EXPECT_EQ(pastProjection.projection->t0Frame->frameContentIdentity.validTimeMs, Base - Hour);
|
||||
EXPECT_EQ(pastProjection.projection->t1Frame->frameContentIdentity.validTimeMs, Base - Hour);
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + 8 * Hour));
|
||||
EXPECT_FALSE(source.resolveParticleProjection().accepted());
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4EvictedActiveFrameClearsItsMaterializedProjection) {
|
||||
style::RasterArraySource source{"v4-evicted-active-frame"};
|
||||
for (uint64_t index = 0; index < 8; ++index)
|
||||
submit(source, "wind", "wind", Base + index * Hour, index + 1);
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base));
|
||||
ASSERT_TRUE(source.impl().v4RendererState.particle);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle->t0->validTime, Base);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle->t0, source.impl().v4RendererState.particle->t1);
|
||||
|
||||
submit(source, "wind", "wind", Base + 8 * Hour, 9);
|
||||
EXPECT_FALSE(source.resolveParticleProjection().accepted());
|
||||
EXPECT_FALSE(source.impl().v4RendererState.particle);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4StoresNewReferenceAndProfileAsSingleFrameWithoutCrossBatchMix) {
|
||||
style::RasterArraySource source{"v4-batch-boundary"};
|
||||
ASSERT_EQ(source.submitEncodedField("wind", metadata("wind", 1), windContract(), qbin(Base)),
|
||||
style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted);
|
||||
auto nextProfile = windContract();
|
||||
nextProfile.profileID = "test.wind.v2";
|
||||
ASSERT_EQ(source.submitEncodedField("wind", metadata("wind", 2), nextProfile,
|
||||
qbin(Base + Hour, false, 0x0f, Base + Hour)),
|
||||
style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted);
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
|
||||
EXPECT_EQ(source.resolveParticleProjection().error, particle::BindingProjectionError::WindowUnavailable);
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour));
|
||||
const auto latestExact = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(latestExact.accepted());
|
||||
EXPECT_EQ(latestExact.projection->t0Frame, latestExact.projection->t1Frame);
|
||||
EXPECT_EQ(latestExact.projection->t0Frame->resolvedProfileID, "test.wind.v2");
|
||||
|
||||
ASSERT_EQ(source.submitEncodedField("wind", metadata("wind", 3), nextProfile,
|
||||
qbin(Base + 2 * Hour, false, 0x0f, Base + Hour)),
|
||||
style::RasterArrayEncodedFieldSubmitResult::AcceptedInserted);
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour + Hour / 2));
|
||||
const auto nextPair = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(nextPair.accepted());
|
||||
EXPECT_FLOAT_EQ(nextPair.projection->sampleState.mix, 0.5f);
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base));
|
||||
const auto returnedToEarlierBatch = source.resolveParticleProjection();
|
||||
ASSERT_TRUE(returnedToEarlierBatch.accepted());
|
||||
EXPECT_EQ(returnedToEarlierBatch.projection->t0Frame, returnedToEarlierBatch.projection->t1Frame);
|
||||
}
|
||||
TEST(Source, RasterArrayV4VelocityColorReconstructsDecodedUVIndependentlyOfParticles) {
|
||||
style::RasterArraySource source{"v4-vector-color"};
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
|
||||
source.setColorBinding(
|
||||
{"wind", {1, 1, particle::FieldComponent::UEast}, particle::FieldValueClass::VelocityComponent, 1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
|
||||
|
||||
ASSERT_TRUE(source.impl().v4RendererState.color);
|
||||
EXPECT_FALSE(source.impl().v4RendererState.particle);
|
||||
const auto& colorWithoutParticles = *source.impl().v4RendererState.color;
|
||||
EXPECT_EQ(colorWithoutParticles.valueSource, particle::MaterializedColorValueSource::VectorMagnitude);
|
||||
ASSERT_TRUE(colorWithoutParticles.t0);
|
||||
ASSERT_TRUE(colorWithoutParticles.t1);
|
||||
EXPECT_TRUE(colorWithoutParticles.t0->hasVector());
|
||||
EXPECT_FALSE(colorWithoutParticles.t0->hasScalar());
|
||||
EXPECT_EQ(colorWithoutParticles.t0->vector, (std::vector<float>{1, 5, 2, 6, 3, 7, 4, 8}));
|
||||
EXPECT_EQ(colorWithoutParticles.t0->valid, (std::vector<uint8_t>{1, 1, 1, 1}));
|
||||
EXPECT_FLOAT_EQ(colorWithoutParticles.sampleState.mix, 0.5f);
|
||||
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind",
|
||||
{1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth},
|
||||
{},
|
||||
1});
|
||||
ASSERT_TRUE(source.impl().v4RendererState.color);
|
||||
ASSERT_TRUE(source.impl().v4RendererState.particle);
|
||||
const auto& color = *source.impl().v4RendererState.color;
|
||||
const auto& particles = *source.impl().v4RendererState.particle;
|
||||
EXPECT_EQ(color.valueSource, particle::MaterializedColorValueSource::VectorMagnitude);
|
||||
EXPECT_EQ(color.t0->vector, particles.t0->vector);
|
||||
EXPECT_EQ(color.t1->vector, particles.t1->vector);
|
||||
EXPECT_EQ(color.t0->valid, particles.t0->valid);
|
||||
EXPECT_FALSE(source.impl().field);
|
||||
EXPECT_FALSE(source.impl().nextField);
|
||||
EXPECT_FALSE(source.impl().timeline);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4MaterializerUsesExplicitLittleEndianScaleOffsetAndStableRevision) {
|
||||
const auto t0 = materializerFrame("scalar",
|
||||
Base,
|
||||
17,
|
||||
23,
|
||||
{99, 0, particle::FieldComponent::None},
|
||||
particle::FieldDType::Float32,
|
||||
particle::FieldValueClass::Scalar,
|
||||
77,
|
||||
2.0f,
|
||||
1.0f,
|
||||
float32LE({1.5f, -2.0f, 3.25f, 4.0f}),
|
||||
0x0d);
|
||||
const auto t1 = materializerFrame("scalar",
|
||||
Base + Hour,
|
||||
18,
|
||||
24,
|
||||
{99, 0, particle::FieldComponent::None},
|
||||
particle::FieldDType::Float32,
|
||||
particle::FieldValueClass::Scalar,
|
||||
77,
|
||||
2.0f,
|
||||
1.0f,
|
||||
float32LE({2.0f, -1.5f, 3.5f, 5.0f}),
|
||||
0x0f);
|
||||
particle::ColorBinding binding{
|
||||
"scalar", {99, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 77};
|
||||
particle::ColorProjection projection;
|
||||
projection.contentIdentity = {
|
||||
binding, {"scalar", Base}, {"scalar", Base + Hour}, "plane-t0", "plane-t1", "validity-t0", "validity-t1"};
|
||||
projection.sampleState = {Base + Hour / 2, 0.5f};
|
||||
projection.t0Frame = t0;
|
||||
projection.t1Frame = t1;
|
||||
auto materialized = particle::materializeColorProjection(projection);
|
||||
ASSERT_TRUE(materialized);
|
||||
ASSERT_TRUE(materialized->t0);
|
||||
ASSERT_TRUE(materialized->t1);
|
||||
EXPECT_EQ(materialized->t0->scalar, (std::vector<float>{4.0f, -3.0f, 7.5f, 9.0f}));
|
||||
EXPECT_EQ(materialized->t0->valid, (std::vector<uint8_t>{1, 0, 1, 1}));
|
||||
EXPECT_EQ(materialized->t0->generation, 17u);
|
||||
EXPECT_EQ(materialized->t0->dataVersion, 23u);
|
||||
EXPECT_EQ(materialized->t0->fieldSha256, "plane-t0");
|
||||
EXPECT_EQ(materialized->t1->generation, 18u);
|
||||
EXPECT_EQ(materialized->t1->dataVersion, 24u);
|
||||
const auto retainedT0 = materialized->t0;
|
||||
materialized.reset();
|
||||
ASSERT_TRUE(retainedT0);
|
||||
EXPECT_EQ(retainedT0->scalar, (std::vector<float>{4.0f, -3.0f, 7.5f, 9.0f}));
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4MaterializerDecodesDirectionToUInt16LittleEndian) {
|
||||
const auto t0 = materializerFrame("direction",
|
||||
Base,
|
||||
31,
|
||||
41,
|
||||
{10, 1, particle::FieldComponent::None},
|
||||
particle::FieldDType::UInt16,
|
||||
particle::FieldValueClass::DirectionAngle,
|
||||
particle::FieldUnitDegrees,
|
||||
0.01f,
|
||||
0.0f,
|
||||
uint16LE({0, 9000, 18000, 27000}));
|
||||
const auto t1 = materializerFrame("direction",
|
||||
Base + Hour,
|
||||
32,
|
||||
42,
|
||||
{10, 1, particle::FieldComponent::None},
|
||||
particle::FieldDType::UInt16,
|
||||
particle::FieldValueClass::DirectionAngle,
|
||||
particle::FieldUnitDegrees,
|
||||
0.01f,
|
||||
0.0f,
|
||||
uint16LE({0, 9000, 18000, 27000}));
|
||||
particle::ParticleBinding binding{particle::ParticleBindingMode::DirectionAngle,
|
||||
"direction",
|
||||
{},
|
||||
{},
|
||||
{10, 1, particle::FieldComponent::None},
|
||||
particle::FieldUnitDegrees};
|
||||
particle::ParticleProjection projection;
|
||||
projection.contentIdentity = {binding,
|
||||
{"direction", Base},
|
||||
{"direction", Base + Hour},
|
||||
"dir-t0",
|
||||
"dir-t1",
|
||||
{},
|
||||
{},
|
||||
"validity-t0",
|
||||
"validity-t1"};
|
||||
projection.sampleState = {Base + Hour / 2, 0.5f};
|
||||
projection.t0Frame = t0;
|
||||
projection.t1Frame = t1;
|
||||
const auto materialized = particle::materializeParticleProjection(projection);
|
||||
ASSERT_TRUE(materialized);
|
||||
ASSERT_TRUE(materialized->t0);
|
||||
ASSERT_EQ(materialized->t0->vector.size(), 8u);
|
||||
EXPECT_NEAR(materialized->t0->vector[0], 0.0f, 1e-5f);
|
||||
EXPECT_NEAR(materialized->t0->vector[1], 1.0f, 1e-5f);
|
||||
EXPECT_NEAR(materialized->t0->vector[2], 1.0f, 1e-5f);
|
||||
EXPECT_NEAR(materialized->t0->vector[3], 0.0f, 1e-5f);
|
||||
EXPECT_NEAR(materialized->t0->vector[4], 0.0f, 1e-5f);
|
||||
EXPECT_NEAR(materialized->t0->vector[5], -1.0f, 1e-5f);
|
||||
EXPECT_NEAR(materialized->t0->vector[6], -1.0f, 1e-5f);
|
||||
EXPECT_NEAR(materialized->t0->vector[7], 0.0f, 1e-5f);
|
||||
EXPECT_EQ(materialized->t0->vectorSemantics, particle::VectorSemantics::Direction);
|
||||
}
|
||||
|
||||
|
||||
// The RN weather source uses Host V3 ingress and V4 QBIN on one Style source.
|
||||
// A terminal detach must clear both, drop old bindings, and permit a new owner.
|
||||
TEST(Source, RasterArrayV4TerminalResetClearsBothIngressesAndAllowsNewGeneration) {
|
||||
style::RasterArraySource source{"v4-detach-reset"};
|
||||
auto v3T0 = particle::OfflineFixtureParticleAdapter::load("uniform-east", 71);
|
||||
auto v3T1 = particle::OfflineFixtureParticleAdapter::load("uniform-north", 72);
|
||||
v3T1.validTime = v3T0.validTime + Hour;
|
||||
ASSERT_EQ(source.submitInitialRuntimeFieldWindow(v3T0, v3T1),
|
||||
style::RasterArrayRuntimeFieldUpdateResult::Applied);
|
||||
ASSERT_TRUE(source.impl().field);
|
||||
ASSERT_TRUE(source.impl().nextField);
|
||||
|
||||
submit(source, "wind", "wind", Base, 1);
|
||||
submit(source, "wind", "wind", Base + Hour, 2);
|
||||
source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
particle::FieldValueClass::VelocityComponent, 1});
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth}, {}, 1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
|
||||
ASSERT_TRUE(source.impl().v4RendererState.color);
|
||||
ASSERT_TRUE(source.impl().v4RendererState.particle);
|
||||
ASSERT_EQ(source.impl().v4RendererState.color->sampleState.mix, 0.5f);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle->sampleState.mix, 0.5f);
|
||||
source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing);
|
||||
ASSERT_EQ(source.impl().hostFieldSubmission, true);
|
||||
|
||||
ASSERT_TRUE(source.resetWeatherRuntimeState());
|
||||
EXPECT_FALSE(source.impl().field);
|
||||
EXPECT_FALSE(source.impl().nextField);
|
||||
EXPECT_FALSE(source.impl().timeline);
|
||||
EXPECT_EQ(source.impl().v4RendererState.color, nullptr);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle, nullptr);
|
||||
EXPECT_FALSE(source.impl().v4RendererState.colorInput);
|
||||
EXPECT_FALSE(source.impl().v4RendererState.particleInput);
|
||||
EXPECT_EQ(source.resolveColorProjection().error, particle::BindingProjectionError::InvalidBinding);
|
||||
EXPECT_EQ(source.resolveParticleProjection().error, particle::BindingProjectionError::InvalidBinding);
|
||||
EXPECT_EQ(source.impl().v4RendererState.color, nullptr);
|
||||
EXPECT_EQ(source.impl().v4RendererState.particle, nullptr);
|
||||
EXPECT_FALSE(source.tickForecastClock(std::chrono::steady_clock::now()));
|
||||
EXPECT_TRUE(source.resetWeatherRuntimeState()); // Idempotent.
|
||||
|
||||
submit(source, "wind", "wind", Base, 3);
|
||||
submit(source, "wind", "wind", Base + Hour, 4);
|
||||
source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
particle::FieldValueClass::VelocityComponent, 1});
|
||||
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
|
||||
"wind", {1, 1, particle::FieldComponent::UEast},
|
||||
{2, 1, particle::FieldComponent::VNorth}, {}, 1});
|
||||
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
|
||||
EXPECT_TRUE(source.resolveColorProjection().accepted());
|
||||
EXPECT_TRUE(source.resolveParticleProjection().accepted());
|
||||
EXPECT_TRUE(source.impl().v4RendererState.color);
|
||||
EXPECT_TRUE(source.impl().v4RendererState.particle);
|
||||
}
|
||||
@@ -12,16 +12,23 @@
|
||||
#include <mln/style/layers/line_layer_impl.hpp>
|
||||
#include <mln/style/layers/raster_layer.hpp>
|
||||
#include <mln/style/layers/raster_layer_impl.hpp>
|
||||
#include <mln/style/layers/raster_particle_layer_impl.hpp>
|
||||
#include <mln/style/layers/weather_color_layer_impl.hpp>
|
||||
#include <mln/style/source_impl.hpp>
|
||||
#include <mln/style/sources/custom_geometry_source.hpp>
|
||||
#include <mln/style/sources/geojson_source.hpp>
|
||||
#include <mln/style/sources/image_source.hpp>
|
||||
#include <mln/style/sources/raster_array_source.hpp>
|
||||
#include <mln/style/sources/raster_array_source_impl.hpp>
|
||||
#include <mln/style/sources/raster_dem_source.hpp>
|
||||
#include <mln/style/sources/raster_source.hpp>
|
||||
#include <mln/style/sources/vector_source.hpp>
|
||||
#include <mln/style/style.hpp>
|
||||
#include <mln/particle/field/field_data_adapter.hpp>
|
||||
#include <mln/particle/field/local_particle_field_feed.hpp>
|
||||
|
||||
#include <mln/renderer/sources/render_geojson_source.hpp>
|
||||
#include <mln/renderer/sources/render_raster_array_source.hpp>
|
||||
#include <mln/renderer/sources/render_raster_dem_source.hpp>
|
||||
#include <mln/renderer/sources/render_raster_source.hpp>
|
||||
#include <mln/renderer/sources/render_vector_source.hpp>
|
||||
@@ -50,6 +57,61 @@
|
||||
#include <gmock/gmock.h>
|
||||
|
||||
#include <optional>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
|
||||
std::string fixtureBytes(const std::string& fixtureID, const std::string& file) {
|
||||
return util::read_file("particle-fixtures/" + fixtureID + "/" + file);
|
||||
}
|
||||
|
||||
std::string localMetadata(const int index) {
|
||||
return "{\"schema\":\"maplibre-particle-field/v1\",\"fieldId\":\"production-t" +
|
||||
std::to_string(index) + "\",\"fieldType\":\"WIND\",\"displayDomain\":\"GLOBAL\",\"generation\":" +
|
||||
std::to_string(index + 1) + ",\"validTime\":" + std::to_string(1704067200000ULL + index * 3600000ULL) +
|
||||
",\"dataVersion\":" + std::to_string(index + 1) +
|
||||
",\"bounds\":{\"west\":-180,\"south\":-85,\"east\":180,\"north\":85},\"grid\":{\"width\":16,\"height\":16},\"vector\":{\"layout\":\"interleaved-uv\",\"encoding\":\"float32-le\",\"noData\":-9999},\"payload\":{\"byteLength\":2048}}";
|
||||
}
|
||||
|
||||
std::vector<char> floatBytes(std::initializer_list<float> values) {
|
||||
std::vector<float> copy(values);
|
||||
std::vector<char> bytes(copy.size() * sizeof(float));
|
||||
std::memcpy(bytes.data(), copy.data(), bytes.size());
|
||||
return bytes;
|
||||
}
|
||||
|
||||
std::string v2Metadata(bool scalar, bool vector, const char* semantics = "VELOCITY") {
|
||||
std::string result = "{\"schema\":\"maplibre-particle-field/v2\",\"fieldId\":\"v2-field\",\"generation\":1,\"validTime\":1,\"grid\":{\"width\":2,\"height\":1},\"bounds\":{\"west\":0,\"south\":0,\"east\":1,\"north\":1},\"validity\":{\"encoding\":\"uint8\",\"byteLength\":2}";
|
||||
if (scalar) result += ",\"scalar\":{\"encoding\":\"float32-le\",\"byteLength\":8,\"noData\":-9999}";
|
||||
if (vector) result += std::string(",\"vector\":{\"layout\":\"interleaved-uv\",\"encoding\":\"float32-le\",\"byteLength\":16,\"noData\":-9999,\"semantics\":\"") + semantics + "\"}";
|
||||
return result + "}";
|
||||
}
|
||||
|
||||
particle::ParticleFieldPayloadBundle v2Payload(std::optional<std::vector<char>> scalar,
|
||||
std::optional<std::vector<char>> vector,
|
||||
std::vector<char> validity) {
|
||||
return {.scalar = std::move(scalar), .vector = std::move(vector), .validity = std::move(validity)};
|
||||
}
|
||||
|
||||
std::string v2HostMetadata(const int index, const bool scalar, const bool vector, const char* semantics = "VELOCITY") {
|
||||
std::string result = "{\"schema\":\"maplibre-particle-field/v2\",\"fieldId\":\"v2-host-" +
|
||||
std::to_string(index) + "\",\"generation\":" + std::to_string(index + 1) +
|
||||
",\"validTime\":" + std::to_string(100 + index) +
|
||||
",\"grid\":{\"width\":2,\"height\":1},\"bounds\":{\"west\":0,\"south\":0,\"east\":1,\"north\":1},\"validity\":{\"encoding\":\"uint8\",\"byteLength\":2}";
|
||||
if (scalar) result += ",\"scalar\":{\"encoding\":\"float32-le\",\"byteLength\":8,\"noData\":-9999}";
|
||||
if (vector) result += std::string(",\"vector\":{\"layout\":\"interleaved-uv\",\"encoding\":\"float32-le\",\"byteLength\":16,\"noData\":-9999,\"semantics\":\"") + semantics + "\"}";
|
||||
return result + "}";
|
||||
}
|
||||
|
||||
std::string v2LegacyCompatibleHostMetadata(const int index) {
|
||||
return "{\"schema\":\"maplibre-particle-field/v2\",\"fieldId\":\"v2-compatible-t" +
|
||||
std::to_string(index) + "\",\"generation\":" + std::to_string(index + 1) +
|
||||
",\"validTime\":" + std::to_string(1704067200000ULL + index * 3600000ULL) +
|
||||
",\"grid\":{\"width\":16,\"height\":16},\"bounds\":{\"west\":-180,\"south\":-85,\"east\":180,\"north\":85},\"validity\":{\"encoding\":\"uint8\",\"byteLength\":256},\"vector\":{\"layout\":\"interleaved-uv\",\"encoding\":\"float32-le\",\"byteLength\":2048,\"noData\":-9999,\"semantics\":\"VELOCITY\"}}";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
using namespace mln;
|
||||
using SourceType = mln::style::SourceType;
|
||||
@@ -126,6 +188,827 @@ TEST(Source, LoadingFail) {
|
||||
test.run();
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayRenderItem) {
|
||||
SourceTest test;
|
||||
|
||||
style::RasterArraySource source{"source", "fixture", "metadata", "field"};
|
||||
particle::ParticleFieldSnapshot field;
|
||||
auto impl = Immutable<style::RasterArraySource::Impl>(
|
||||
makeMutable<style::RasterArraySource::Impl>(source.impl(), std::move(field)));
|
||||
RenderRasterArraySource renderSource{impl};
|
||||
|
||||
renderSource.update(impl, {}, true, false, test.tileParameters());
|
||||
EXPECT_TRUE(renderSource.isLoaded());
|
||||
EXPECT_TRUE(renderSource.isEnabled());
|
||||
|
||||
TransformParameters transformParameters{test.transformState};
|
||||
const MapDebugOptions debugOptions = MapDebugOptions::NoDebug;
|
||||
renderSource.prepare({.transform = transformParameters,
|
||||
.debugOptions = debugOptions,
|
||||
.imageManager = *test.imageManager,
|
||||
.sourceName = source.getID()});
|
||||
|
||||
auto item = renderSource.createRenderItem();
|
||||
ASSERT_TRUE(item);
|
||||
EXPECT_EQ(item->getName(), "source");
|
||||
EXPECT_FALSE(item->hasRenderPass(RenderPass::Opaque));
|
||||
EXPECT_FALSE(item->hasRenderPass(RenderPass::Translucent));
|
||||
}
|
||||
|
||||
TEST(Source, RasterArraySharesCanonicalSnapshotsWithWeatherColorLayer) {
|
||||
style::RasterArraySource source{"source", "fixture", "metadata", "field"};
|
||||
EXPECT_TRUE(source.supportsLayerType(style::RasterParticleLayer::Impl::staticTypeInfo()));
|
||||
EXPECT_TRUE(source.supportsLayerType(style::WeatherColorLayer::Impl::staticTypeInfo()));
|
||||
}
|
||||
|
||||
TEST(Source, RasterArraySnapshotsUseProcessWideGenerations) {
|
||||
SourceTest test;
|
||||
const auto eastMetadata = fixtureBytes("uniform-east", "metadata.json");
|
||||
const auto eastField = fixtureBytes("uniform-east", "field.f32");
|
||||
const auto northMetadata = fixtureBytes("uniform-north", "metadata.json");
|
||||
const auto northField = fixtureBytes("uniform-north", "field.f32");
|
||||
|
||||
test.fileSource->sourceResponse = [&](const Resource& resource) -> Response {
|
||||
Response response;
|
||||
if (resource.url == "east-metadata") {
|
||||
response.data = std::make_unique<std::string>(eastMetadata);
|
||||
} else if (resource.url == "east-field") {
|
||||
response.data = std::make_unique<std::string>(eastField);
|
||||
} else if (resource.url == "north-metadata") {
|
||||
response.data = std::make_unique<std::string>(northMetadata);
|
||||
} else if (resource.url == "north-field") {
|
||||
response.data = std::make_unique<std::string>(northField);
|
||||
} else {
|
||||
ADD_FAILURE() << "Unexpected RasterArray resource: " << resource.url;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
style::RasterArraySource east{"source", "uniform-east", "east-metadata", "east-field"};
|
||||
east.setObserver(&test.styleObserver);
|
||||
style::RasterArraySource north{"source", "uniform-north", "north-metadata", "north-field"};
|
||||
north.setObserver(&test.styleObserver);
|
||||
|
||||
uint8_t loadedSources = 0;
|
||||
test.styleObserver.sourceChanged = [&](Source&) {
|
||||
if (++loadedSources == 2) {
|
||||
test.end();
|
||||
}
|
||||
};
|
||||
east.loadDescription(*test.fileSource);
|
||||
north.loadDescription(*test.fileSource);
|
||||
|
||||
test.run();
|
||||
ASSERT_TRUE(east.impl().field);
|
||||
ASSERT_TRUE(north.impl().field);
|
||||
|
||||
EXPECT_NE(east.impl().field->generation, north.impl().field->generation);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayFieldIdentityIncludesFixtureAndDataVersion) {
|
||||
const auto east = particle::OfflineFixtureParticleAdapter::load("uniform-east", 7);
|
||||
const auto northWithSameGeneration = particle::OfflineFixtureParticleAdapter::load("uniform-north", 7);
|
||||
auto eastWithNewDataVersion = east;
|
||||
++eastWithNewDataVersion.dataVersion;
|
||||
|
||||
EXPECT_TRUE(particle::fieldIdentityChanged(east, northWithSameGeneration));
|
||||
EXPECT_TRUE(particle::fieldIdentityChanged(east, eastWithNewDataVersion));
|
||||
EXPECT_FALSE(particle::fieldIdentityChanged(east, east));
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayGpuContentIdentityExcludesRuntimeGeneration) {
|
||||
const auto east = particle::OfflineFixtureParticleAdapter::load("uniform-east", 100);
|
||||
auto reloadedEast = east;
|
||||
reloadedEast.generation = 200;
|
||||
auto changed = reloadedEast;
|
||||
++changed.dataVersion;
|
||||
|
||||
EXPECT_TRUE(particle::fieldIdentityChanged(east, reloadedEast));
|
||||
// Decoded content equivalence intentionally ignores producer generation and
|
||||
// dataVersion; the stricter GPU endpoint identity must not ignore either.
|
||||
EXPECT_TRUE(particle::sameFieldGpuContent(east, reloadedEast));
|
||||
EXPECT_TRUE(particle::sameFieldGpuContent(east, changed));
|
||||
EXPECT_FALSE(particle::sameFieldGpuEndpoint(east, reloadedEast));
|
||||
EXPECT_FALSE(particle::sameFieldGpuEndpoint(reloadedEast, changed));
|
||||
|
||||
auto changedBytes = changed;
|
||||
changedBytes.vector[0] += 1.0f;
|
||||
EXPECT_FALSE(particle::sameFieldGpuContent(east, changedBytes));
|
||||
EXPECT_FALSE(particle::sameFieldGpuEndpoint(east, changedBytes));
|
||||
}
|
||||
|
||||
TEST(Source, ParticleFieldDataAdapterAcceptsNeutralDescriptor) {
|
||||
particle::ParticleFieldDescriptor descriptor;
|
||||
descriptor.fieldID = "weather-service-wind-20260904T0000Z";
|
||||
descriptor.fieldType = "WIND";
|
||||
descriptor.displayDomain = "GLOBAL";
|
||||
descriptor.generation = 1;
|
||||
descriptor.validTime = 1;
|
||||
descriptor.width = 1;
|
||||
descriptor.height = 1;
|
||||
descriptor.byteLength = 2 * sizeof(float);
|
||||
float values[] = {1.0f, 2.0f};
|
||||
std::vector<char> bytes(sizeof(values));
|
||||
std::memcpy(bytes.data(), values, sizeof(values));
|
||||
|
||||
const auto result = particle::ParticleFieldDataAdapter::decode(descriptor, bytes, 1);
|
||||
ASSERT_TRUE(result);
|
||||
ASSERT_TRUE(result.snapshot);
|
||||
EXPECT_EQ(result.snapshot->fieldID, descriptor.fieldID);
|
||||
EXPECT_EQ(result.snapshot->vectorSemantics, particle::VectorSemantics::Velocity);
|
||||
EXPECT_TRUE(result.snapshot->hasVector());
|
||||
EXPECT_FALSE(result.snapshot->hasScalar());
|
||||
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::None);
|
||||
}
|
||||
|
||||
TEST(Source, ParticleFieldDataAdapterReturnsStructuredValidationErrors) {
|
||||
particle::ParticleFieldDescriptor descriptor;
|
||||
descriptor.fieldID = "field";
|
||||
descriptor.fieldType = "WIND";
|
||||
descriptor.displayDomain = "GLOBAL";
|
||||
descriptor.generation = 1;
|
||||
descriptor.validTime = 1;
|
||||
descriptor.width = 2;
|
||||
descriptor.height = 1;
|
||||
descriptor.byteLength = 4 * sizeof(float);
|
||||
|
||||
std::vector<char> bytes(3 * sizeof(float));
|
||||
auto result = particle::ParticleFieldDataAdapter::decode(descriptor, bytes, 1);
|
||||
EXPECT_FALSE(result);
|
||||
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::PayloadSizeMismatch);
|
||||
|
||||
descriptor.byteLength = bytes.size();
|
||||
descriptor.encoding = "provider-packed";
|
||||
result = particle::ParticleFieldDataAdapter::decode(descriptor, bytes, 1);
|
||||
EXPECT_FALSE(result);
|
||||
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::UnsupportedFormat);
|
||||
}
|
||||
|
||||
TEST(Source, ParticleFieldV3V2DecodesAllSupportedChannelCombinations) {
|
||||
const std::vector<char> valid{1, 0};
|
||||
const auto vectorOnly = particle::LocalParticleFieldFeed::load(
|
||||
v2Metadata(false, true), v2Payload(std::nullopt, floatBytes({1, 2, -9999, -9999}), valid));
|
||||
ASSERT_TRUE(vectorOnly) << vectorOnly.message;
|
||||
EXPECT_TRUE(vectorOnly.snapshot->hasVector());
|
||||
EXPECT_FALSE(vectorOnly.snapshot->hasScalar());
|
||||
EXPECT_EQ(vectorOnly.snapshot->vectorSemantics, particle::VectorSemantics::Velocity);
|
||||
|
||||
const auto scalarOnly = particle::LocalParticleFieldFeed::load(
|
||||
v2Metadata(true, false), v2Payload(floatBytes({3, -9999}), std::nullopt, valid));
|
||||
ASSERT_TRUE(scalarOnly) << scalarOnly.message;
|
||||
EXPECT_TRUE(scalarOnly.snapshot->hasScalar());
|
||||
EXPECT_FALSE(scalarOnly.snapshot->hasVector());
|
||||
|
||||
const auto combined = particle::LocalParticleFieldFeed::load(
|
||||
v2Metadata(true, true), v2Payload(floatBytes({3, -9999}), floatBytes({1, 2, -9999, -9999}), valid));
|
||||
ASSERT_TRUE(combined) << combined.message;
|
||||
EXPECT_TRUE(combined.snapshot->hasScalar());
|
||||
EXPECT_TRUE(combined.snapshot->hasVector());
|
||||
}
|
||||
|
||||
TEST(Source, ParticleFieldV3BusinessFixtureShapesRemainProviderNeutral) {
|
||||
// These are the exact four offline-business shapes used by STEP-070 fixtures:
|
||||
// Wind/Tide are velocity vectors, Rain is scalar-only, and Wave is scalar+direction.
|
||||
const std::vector<char> valid{1, 1};
|
||||
const auto wind = particle::LocalParticleFieldFeed::load(v2Metadata(false, true, "VELOCITY"),
|
||||
v2Payload(std::nullopt, floatBytes({1, 2, 3, 4}), valid));
|
||||
const auto tide = particle::LocalParticleFieldFeed::load(v2Metadata(false, true, "VELOCITY"),
|
||||
v2Payload(std::nullopt, floatBytes({0.1f, 0.2f, 0.3f, 0.4f}), valid));
|
||||
const auto rain = particle::LocalParticleFieldFeed::load(v2Metadata(true, false),
|
||||
v2Payload(floatBytes({1, 2}), std::nullopt, valid));
|
||||
const auto wave = particle::LocalParticleFieldFeed::load(v2Metadata(true, true, "DIRECTION"),
|
||||
v2Payload(floatBytes({1, 9}), floatBytes({0, 1, 0, 1}), valid));
|
||||
ASSERT_TRUE(wind) << wind.message; ASSERT_TRUE(tide) << tide.message;
|
||||
ASSERT_TRUE(rain) << rain.message; ASSERT_TRUE(wave) << wave.message;
|
||||
EXPECT_EQ(wave.snapshot->vectorSemantics, particle::VectorSemantics::Direction);
|
||||
EXPECT_FALSE(rain.snapshot->hasVector());
|
||||
EXPECT_TRUE(wind.snapshot->isTimeInterpolationCompatibleWith(*tide.snapshot));
|
||||
EXPECT_FALSE(wind.snapshot->isTimeInterpolationCompatibleWith(*wave.snapshot));
|
||||
}
|
||||
|
||||
TEST(Source, ParticleFieldV3V2RejectsInvalidPlaneAndSemanticsContracts) {
|
||||
const std::vector<char> valid{1, 1};
|
||||
auto neither = particle::LocalParticleFieldFeed::load(v2Metadata(false, false), v2Payload(std::nullopt, std::nullopt, valid));
|
||||
EXPECT_FALSE(neither);
|
||||
auto scalarLength = particle::LocalParticleFieldFeed::load(v2Metadata(true, false), v2Payload(floatBytes({1}), std::nullopt, valid));
|
||||
EXPECT_FALSE(scalarLength);
|
||||
auto vectorLength = particle::LocalParticleFieldFeed::load(v2Metadata(false, true), v2Payload(std::nullopt, floatBytes({1, 2}), valid));
|
||||
EXPECT_FALSE(vectorLength);
|
||||
auto validityLength = particle::LocalParticleFieldFeed::load(v2Metadata(true, false), v2Payload(floatBytes({1, 2}), std::nullopt, std::vector<char>{1}));
|
||||
EXPECT_FALSE(validityLength);
|
||||
auto missingSemantics = v2Metadata(false, true);
|
||||
const auto position = missingSemantics.find(",\"semantics\":\"VELOCITY\"");
|
||||
ASSERT_NE(position, std::string::npos);
|
||||
missingSemantics.erase(position, std::string(",\"semantics\":\"VELOCITY\"").size());
|
||||
EXPECT_FALSE(particle::LocalParticleFieldFeed::load(missingSemantics, v2Payload(std::nullopt, floatBytes({1, 2, 3, 4}), valid)));
|
||||
auto directionWithoutVector = v2Metadata(true, false);
|
||||
directionWithoutVector.pop_back();
|
||||
directionWithoutVector += ",\"vector\":{\"semantics\":\"DIRECTION\"}}";
|
||||
EXPECT_FALSE(particle::LocalParticleFieldFeed::load(directionWithoutVector, v2Payload(floatBytes({1, 2}), std::nullopt, valid)));
|
||||
}
|
||||
|
||||
TEST(Source, ParticleFieldV3V2RejectsNonFiniteValidDataAndAcceptsInvalidFiniteNoData) {
|
||||
const auto nonFinite = particle::LocalParticleFieldFeed::load(
|
||||
v2Metadata(true, false), v2Payload(floatBytes({std::numeric_limits<float>::infinity(), 2}), std::nullopt, std::vector<char>{1, 1}));
|
||||
EXPECT_FALSE(nonFinite);
|
||||
const auto placeholder = particle::LocalParticleFieldFeed::load(
|
||||
v2Metadata(true, false), v2Payload(floatBytes({1, -9999}), std::nullopt, std::vector<char>{1, 0}));
|
||||
EXPECT_TRUE(placeholder) << placeholder.message;
|
||||
}
|
||||
|
||||
TEST(Source, ParticleFieldV3CompatibilityAndContentIdentityAreChannelAware) {
|
||||
const std::vector<char> valid{1, 1};
|
||||
auto scalar = *particle::LocalParticleFieldFeed::load(v2Metadata(true, false), v2Payload(floatBytes({1, 2}), std::nullopt, valid)).snapshot;
|
||||
auto scalarChanged = scalar; scalarChanged.scalar[0] = 3;
|
||||
auto validityChanged = scalar; validityChanged.valid[0] = 0;
|
||||
EXPECT_TRUE(scalar.isTimeInterpolationCompatibleWith(scalarChanged));
|
||||
EXPECT_FALSE(particle::sameFieldGpuContent(scalar, scalarChanged));
|
||||
EXPECT_FALSE(particle::sameFieldGpuContent(scalar, validityChanged));
|
||||
auto vector = *particle::LocalParticleFieldFeed::load(v2Metadata(false, true), v2Payload(std::nullopt, floatBytes({1, 2, 3, 4}), valid)).snapshot;
|
||||
auto direction = *particle::LocalParticleFieldFeed::load(v2Metadata(false, true, "DIRECTION"), v2Payload(std::nullopt, floatBytes({1, 2, 3, 4}), valid)).snapshot;
|
||||
auto vectorChanged = vector; vectorChanged.vector[0] = 9;
|
||||
EXPECT_FALSE(scalar.isTimeInterpolationCompatibleWith(vector));
|
||||
EXPECT_FALSE(vector.isTimeInterpolationCompatibleWith(direction));
|
||||
EXPECT_FALSE(particle::sameFieldGpuContent(vector, vectorChanged));
|
||||
}
|
||||
|
||||
TEST(Source, ParticleFieldV3DirectionAdvectionResolution) {
|
||||
std::array<float, 2> resolved{};
|
||||
|
||||
EXPECT_TRUE(particle::resolveParticleAdvection(particle::VectorSemantics::Direction,
|
||||
{{1.0f, 0.0f}}, {{1.0f, 0.0f}}, 0.0f, 2.0f, resolved));
|
||||
EXPECT_FLOAT_EQ(2.0f, resolved[0]);
|
||||
EXPECT_FLOAT_EQ(0.0f, resolved[1]);
|
||||
EXPECT_TRUE(particle::resolveParticleAdvection(particle::VectorSemantics::Direction,
|
||||
{{0.0f, 1.0f}}, {{0.0f, 1.0f}}, 0.0f, 2.0f, resolved));
|
||||
EXPECT_FLOAT_EQ(0.0f, resolved[0]);
|
||||
EXPECT_FLOAT_EQ(2.0f, resolved[1]);
|
||||
|
||||
EXPECT_TRUE(particle::resolveParticleAdvection(particle::VectorSemantics::Direction,
|
||||
{{1.0f, 0.0f}}, {{0.0f, 1.0f}}, 0.5f, 3.0f, resolved));
|
||||
EXPECT_NEAR(3.0f / std::sqrt(2.0f), resolved[0], 0.00001f);
|
||||
EXPECT_NEAR(3.0f / std::sqrt(2.0f), resolved[1], 0.00001f);
|
||||
EXPECT_FALSE(particle::resolveParticleAdvection(particle::VectorSemantics::Direction,
|
||||
{{1.0f, 0.0f}}, {{-1.0f, 0.0f}}, 0.5f, 1.0f, resolved));
|
||||
|
||||
EXPECT_TRUE(particle::resolveParticleAdvection(particle::VectorSemantics::Velocity,
|
||||
{{2.0f, -3.0f}}, {{6.0f, 1.0f}}, 0.25f, 999.0f, resolved));
|
||||
EXPECT_FLOAT_EQ(3.0f, resolved[0]);
|
||||
EXPECT_FLOAT_EQ(-2.0f, resolved[1]);
|
||||
}
|
||||
|
||||
TEST(Source, LocalProductionFeedSubmitsSnapshotsWithProducerTime) {
|
||||
const auto field = fixtureBytes("uniform-east", "field.f32");
|
||||
const std::vector<char> payload(field.begin(), field.end());
|
||||
std::vector<particle::ParticleFieldSnapshot> snapshots;
|
||||
for (int index = 0; index < 4; ++index) {
|
||||
const auto result = particle::LocalParticleFieldFeed::load(localMetadata(index), payload);
|
||||
ASSERT_TRUE(result) << result.message;
|
||||
ASSERT_TRUE(result.snapshot);
|
||||
EXPECT_EQ(result.snapshot->generation, static_cast<uint64_t>(index + 1));
|
||||
EXPECT_EQ(result.snapshot->validTime, 1704067200000ULL + static_cast<uint64_t>(index) * 3600000ULL);
|
||||
snapshots.push_back(std::move(*result.snapshot));
|
||||
}
|
||||
for (size_t index = 1; index < snapshots.size(); ++index) {
|
||||
EXPECT_LT(snapshots[index - 1].validTime, snapshots[index].validTime);
|
||||
}
|
||||
|
||||
SourceTest test;
|
||||
std::vector<std::string> metadata;
|
||||
for (int index = 0; index < 4; ++index) metadata.push_back(localMetadata(index));
|
||||
test.fileSource->sourceResponse = [&](const Resource& resource) -> Response {
|
||||
Response response;
|
||||
const auto prefix = std::string("local-metadata-");
|
||||
if (resource.url.rfind(prefix, 0) == 0) {
|
||||
const auto index = static_cast<size_t>(std::stoi(resource.url.substr(prefix.size())));
|
||||
response.data = std::make_unique<std::string>(metadata.at(index));
|
||||
} else if (resource.url.rfind("local-field-", 0) == 0) {
|
||||
response.data = std::make_unique<std::string>(field);
|
||||
} else {
|
||||
ADD_FAILURE() << "Unexpected local production resource: " << resource.url;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
std::vector<style::RasterArrayLocalFieldDescriptor> resources;
|
||||
for (int index = 0; index < 4; ++index) {
|
||||
resources.push_back({"local-metadata-" + std::to_string(index), "local-field-" + std::to_string(index)});
|
||||
}
|
||||
style::RasterArraySource source{"local-production", std::move(resources)};
|
||||
source.setObserver(&test.styleObserver);
|
||||
test.styleObserver.sourceChanged = [&](Source&) {
|
||||
if (source.impl().timeline) test.end();
|
||||
};
|
||||
source.loadDescription(*test.fileSource);
|
||||
test.run();
|
||||
ASSERT_TRUE(source.impl().timeline);
|
||||
EXPECT_EQ(source.impl().timeline->frame().state, style::RasterArrayPlaybackState::Ready);
|
||||
EXPECT_EQ(source.impl().timeline->frame().t0->validTime, snapshots[0].validTime);
|
||||
EXPECT_EQ(source.impl().timeline->frame().t1->validTime, snapshots[1].validTime);
|
||||
EXPECT_TRUE(source.controlRuntimeTimeline(0));
|
||||
}
|
||||
|
||||
TEST(Source, LocalProductionFeedRejectsMissingGeneration) {
|
||||
const auto field = fixtureBytes("uniform-east", "field.f32");
|
||||
auto metadata = localMetadata(0);
|
||||
const auto position = metadata.find("\"generation\":1");
|
||||
ASSERT_NE(position, std::string::npos);
|
||||
metadata.replace(position, std::string("\"generation\":1").size(), "\"generation\":0");
|
||||
const std::vector<char> payload(field.begin(), field.end());
|
||||
const auto result = particle::LocalParticleFieldFeed::load(metadata, payload);
|
||||
EXPECT_FALSE(result);
|
||||
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::InvalidTimestampOrIdentity);
|
||||
}
|
||||
|
||||
TEST(Source, CanonicalFieldUsesEastwardUAndNorthwardV) {
|
||||
const auto east = fixtureBytes("uniform-east", "field.f32");
|
||||
const auto north = fixtureBytes("uniform-north", "field.f32");
|
||||
const auto vortex = fixtureBytes("vortex", "field.f32");
|
||||
const auto eastResult = particle::LocalParticleFieldFeed::load(localMetadata(0),
|
||||
std::vector<char>(east.begin(), east.end()));
|
||||
const auto northResult = particle::LocalParticleFieldFeed::load(localMetadata(1),
|
||||
std::vector<char>(north.begin(), north.end()));
|
||||
const auto vortexResult = particle::LocalParticleFieldFeed::load(localMetadata(2),
|
||||
std::vector<char>(vortex.begin(), vortex.end()));
|
||||
ASSERT_TRUE(eastResult);
|
||||
ASSERT_TRUE(northResult);
|
||||
ASSERT_TRUE(vortexResult);
|
||||
ASSERT_TRUE(eastResult.snapshot);
|
||||
ASSERT_TRUE(northResult.snapshot);
|
||||
ASSERT_GE(eastResult.snapshot->vector.size(), 2u);
|
||||
ASSERT_GE(northResult.snapshot->vector.size(), 2u);
|
||||
EXPECT_FLOAT_EQ(eastResult.snapshot->vector[0], 1.0f);
|
||||
EXPECT_FLOAT_EQ(eastResult.snapshot->vector[1], 0.0f);
|
||||
EXPECT_FLOAT_EQ(northResult.snapshot->vector[0], 0.0f);
|
||||
EXPECT_FLOAT_EQ(northResult.snapshot->vector[1], 1.0f);
|
||||
ASSERT_TRUE(vortexResult.snapshot);
|
||||
EXPECT_FLOAT_EQ(vortexResult.snapshot->vector[0], 0.9375f);
|
||||
EXPECT_FLOAT_EQ(vortexResult.snapshot->vector[1], -0.9375f);
|
||||
EXPECT_FLOAT_EQ(vortexResult.snapshot->vector[vortexResult.snapshot->vector.size() - 2], -0.9375f);
|
||||
EXPECT_FLOAT_EQ(vortexResult.snapshot->vector.back(), 0.9375f);
|
||||
}
|
||||
|
||||
TEST(Source, HostFieldSubmissionBuildsOneCoreOwnedTimeline) {
|
||||
SourceTest test;
|
||||
style::RasterArraySource source{"host-submission"};
|
||||
source.setObserver(&test.styleObserver);
|
||||
source.loadDescription(*test.fileSource);
|
||||
EXPECT_TRUE(source.loaded);
|
||||
EXPECT_TRUE(source.impl().hostFieldSubmission);
|
||||
EXPECT_FALSE(source.impl().field);
|
||||
|
||||
const auto east = fixtureBytes("uniform-east", "field.f32");
|
||||
const auto north = fixtureBytes("uniform-north", "field.f32");
|
||||
const auto vortex = fixtureBytes("vortex", "field.f32");
|
||||
const std::vector<char> eastPayload(east.begin(), east.end());
|
||||
const std::vector<char> northPayload(north.begin(), north.end());
|
||||
const std::vector<char> vortexPayload(vortex.begin(), vortex.end());
|
||||
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(0), eastPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow);
|
||||
EXPECT_FALSE(source.impl().field);
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(1), northPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::InitialWindowReady);
|
||||
ASSERT_TRUE(source.impl().field);
|
||||
ASSERT_TRUE(source.impl().nextField);
|
||||
ASSERT_TRUE(source.impl().timeline);
|
||||
EXPECT_EQ(source.impl().field->fieldID, "production-t0");
|
||||
EXPECT_EQ(source.impl().nextField->fieldID, "production-t1");
|
||||
EXPECT_EQ(source.impl().timeline->frame().state, style::RasterArrayPlaybackState::Ready);
|
||||
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(2), vortexPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::AcceptedNextSnapshot);
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(3), eastPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::AcceptedNextSnapshot);
|
||||
EXPECT_EQ(source.impl().timeline->size(), 4u);
|
||||
EXPECT_EQ(source.impl().timeline->lastEntry().field->fieldID, "production-t3");
|
||||
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(3), eastPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::Duplicate);
|
||||
EXPECT_EQ(source.impl().timeline->size(), 4u);
|
||||
EXPECT_TRUE(source.controlRuntimeTimeline(4));
|
||||
EXPECT_EQ(source.impl().timeline->frame().t0Index, 1u);
|
||||
EXPECT_TRUE(source.controlRuntimeTimeline(4));
|
||||
EXPECT_EQ(source.impl().timeline->frame().t0Index, 2u);
|
||||
}
|
||||
|
||||
TEST(Source, HostFieldV2BundleUsesTheSameTimelineIngress) {
|
||||
SourceTest test;
|
||||
style::RasterArraySource source{"host-submission"};
|
||||
source.setObserver(&test.styleObserver);
|
||||
source.loadDescription(*test.fileSource);
|
||||
const auto valid = std::vector<char>{1, 1};
|
||||
const auto scalarT0 = floatBytes({2.0f, 3.0f});
|
||||
const auto scalarT1 = floatBytes({2.5f, 3.5f});
|
||||
const auto vectorT0 = floatBytes({1.0f, 0.0f, 0.0f, 1.0f});
|
||||
const auto vectorT1 = floatBytes({0.5f, 0.0f, 0.0f, 1.0f});
|
||||
|
||||
EXPECT_EQ(source.submitHostField(v2HostMetadata(0, true, false), v2Payload(scalarT0, std::nullopt, valid)),
|
||||
style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow);
|
||||
EXPECT_EQ(source.submitHostField(v2HostMetadata(1, true, false), v2Payload(scalarT1, std::nullopt, valid)),
|
||||
style::RasterArrayHostFieldSubmitResult::InitialWindowReady);
|
||||
EXPECT_TRUE(source.impl().field->hasScalar());
|
||||
EXPECT_FALSE(source.impl().field->hasVector());
|
||||
|
||||
EXPECT_TRUE(source.clearHostFields());
|
||||
EXPECT_EQ(source.submitHostField(v2HostMetadata(2, false, true), v2Payload(std::nullopt, vectorT0, valid)),
|
||||
style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow);
|
||||
EXPECT_EQ(source.submitHostField(v2HostMetadata(3, false, true), v2Payload(std::nullopt, vectorT1, valid)),
|
||||
style::RasterArrayHostFieldSubmitResult::InitialWindowReady);
|
||||
EXPECT_TRUE(source.impl().field->hasVector());
|
||||
EXPECT_EQ(source.impl().field->vectorSemantics, particle::VectorSemantics::Velocity);
|
||||
|
||||
EXPECT_TRUE(source.clearHostFields());
|
||||
EXPECT_EQ(source.submitHostField(v2HostMetadata(4, true, true, "DIRECTION"), v2Payload(scalarT0, vectorT0, valid)),
|
||||
style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow);
|
||||
EXPECT_EQ(source.submitHostField(v2HostMetadata(5, true, true, "DIRECTION"), v2Payload(scalarT1, vectorT1, valid)),
|
||||
style::RasterArrayHostFieldSubmitResult::InitialWindowReady);
|
||||
EXPECT_EQ(source.impl().field->vectorSemantics, particle::VectorSemantics::Direction);
|
||||
EXPECT_EQ(source.submitHostField(v2HostMetadata(6, true, true), v2Payload(scalarT1, vectorT1, valid)),
|
||||
style::RasterArrayHostFieldSubmitResult::RejectedIncompatibleSnapshot);
|
||||
|
||||
EXPECT_TRUE(source.clearHostFields());
|
||||
EXPECT_EQ(source.submitHostField(v2HostMetadata(7, false, true),
|
||||
particle::ParticleFieldPayloadBundle{.scalar = std::nullopt,
|
||||
.vector = vectorT0,
|
||||
.validity = std::nullopt}),
|
||||
style::RasterArrayHostFieldSubmitResult::RejectedAdapter);
|
||||
EXPECT_EQ(source.submitHostField(v2HostMetadata(8, false, false), v2Payload(std::nullopt, std::nullopt, valid)),
|
||||
style::RasterArrayHostFieldSubmitResult::RejectedAdapter);
|
||||
}
|
||||
|
||||
TEST(Source, HostFieldLegacyAndCompatibleV2VectorOnlyShareOneTimeline) {
|
||||
SourceTest test;
|
||||
style::RasterArraySource source{"host-submission"};
|
||||
source.setObserver(&test.styleObserver);
|
||||
source.loadDescription(*test.fileSource);
|
||||
const auto legacyBytes = fixtureBytes("uniform-east", "field.f32");
|
||||
const std::vector<char> legacyPayload(legacyBytes.begin(), legacyBytes.end());
|
||||
const std::vector<char> validity(16 * 16, 1);
|
||||
const auto northBytes = fixtureBytes("uniform-north", "field.f32");
|
||||
const std::vector<char> nextPayload(northBytes.begin(), northBytes.end());
|
||||
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(0), legacyPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow);
|
||||
EXPECT_EQ(source.submitHostField(v2LegacyCompatibleHostMetadata(1),
|
||||
v2Payload(std::nullopt, nextPayload, validity)),
|
||||
style::RasterArrayHostFieldSubmitResult::InitialWindowReady);
|
||||
ASSERT_TRUE(source.impl().timeline);
|
||||
EXPECT_EQ(source.impl().timeline->size(), 2u);
|
||||
EXPECT_EQ(source.impl().field->vectorSemantics, particle::VectorSemantics::Velocity);
|
||||
EXPECT_EQ(source.impl().nextField->vectorSemantics, particle::VectorSemantics::Velocity);
|
||||
}
|
||||
|
||||
TEST(Source, HostFieldClearAndTimestampSeekAreFailClosed) {
|
||||
SourceTest test;
|
||||
style::RasterArraySource source{"host-submission"};
|
||||
source.setObserver(&test.styleObserver);
|
||||
source.loadDescription(*test.fileSource);
|
||||
const auto east = fixtureBytes("uniform-east", "field.f32");
|
||||
const auto north = fixtureBytes("uniform-north", "field.f32");
|
||||
const std::vector<char> eastPayload(east.begin(), east.end());
|
||||
const std::vector<char> northPayload(north.begin(), north.end());
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(0), eastPayload), style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow);
|
||||
EXPECT_TRUE(source.clearHostFields());
|
||||
EXPECT_FALSE(source.impl().field);
|
||||
EXPECT_FALSE(source.impl().timeline);
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(0), eastPayload), style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow);
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(1), northPayload), style::RasterArrayHostFieldSubmitResult::InitialWindowReady);
|
||||
const uint64_t t0 = 1704067200000ULL;
|
||||
const uint64_t t1 = 1704070800000ULL;
|
||||
EXPECT_TRUE(source.seekRuntimeTimelineToTimestamp(t0));
|
||||
EXPECT_FLOAT_EQ(source.impl().timeline->frame().mix, 0.0f);
|
||||
EXPECT_TRUE(source.seekRuntimeTimelineToTimestamp(t0 + 1800000ULL));
|
||||
EXPECT_FLOAT_EQ(source.impl().timeline->frame().mix, 0.5f);
|
||||
EXPECT_TRUE(source.seekRuntimeTimelineToTimestamp(t1));
|
||||
EXPECT_FLOAT_EQ(source.impl().timeline->frame().mix, 1.0f);
|
||||
EXPECT_FALSE(source.seekRuntimeTimelineToTimestamp(t0 - 1));
|
||||
EXPECT_FALSE(source.seekRuntimeTimelineToTimestamp(t1 + 1));
|
||||
EXPECT_TRUE(source.clearHostFields());
|
||||
EXPECT_FALSE(source.seekRuntimeTimelineToTimestamp(t0 + 1800000ULL));
|
||||
}
|
||||
|
||||
TEST(Source, HostFieldSubmissionStyleContractCreatesEmptyIngress) {
|
||||
SourceTest test;
|
||||
test.style.loadJSON(R"STYLE({
|
||||
"version": 8,
|
||||
"sources": {
|
||||
"host-submission": {"type": "raster-array", "host-field-submission": true}
|
||||
},
|
||||
"layers": []
|
||||
})STYLE");
|
||||
auto* baseSource = test.style.getSource("host-submission");
|
||||
ASSERT_NE(baseSource, nullptr);
|
||||
auto* source = baseSource->as<style::RasterArraySource>();
|
||||
ASSERT_NE(source, nullptr);
|
||||
EXPECT_TRUE(source->impl().hostFieldSubmission);
|
||||
EXPECT_FALSE(source->impl().field);
|
||||
EXPECT_FALSE(source->impl().timeline);
|
||||
}
|
||||
|
||||
TEST(Source, HostFieldSubmissionRejectsInvalidWithoutChangingLastValidField) {
|
||||
SourceTest test;
|
||||
style::RasterArraySource source{"host-submission"};
|
||||
source.setObserver(&test.styleObserver);
|
||||
source.loadDescription(*test.fileSource);
|
||||
const auto eastField = fixtureBytes("uniform-east", "field.f32");
|
||||
const auto northField = fixtureBytes("uniform-north", "field.f32");
|
||||
const std::vector<char> eastPayload(eastField.begin(), eastField.end());
|
||||
const std::vector<char> northPayload(northField.begin(), northField.end());
|
||||
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(0), eastPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow);
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(1), northPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::InitialWindowReady);
|
||||
ASSERT_TRUE(source.impl().timeline);
|
||||
const auto lastValid = source.impl().timeline->lastEntry().field;
|
||||
|
||||
auto invalidIdentity = localMetadata(2);
|
||||
const auto fieldID = invalidIdentity.find("production-t2");
|
||||
ASSERT_NE(fieldID, std::string::npos);
|
||||
invalidIdentity.replace(fieldID, std::string("production-t2").size(), "");
|
||||
EXPECT_EQ(source.submitHostField(invalidIdentity, eastPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::RejectedAdapter);
|
||||
|
||||
auto unsupportedLayout = localMetadata(2);
|
||||
const auto layout = unsupportedLayout.find("interleaved-uv");
|
||||
ASSERT_NE(layout, std::string::npos);
|
||||
unsupportedLayout.replace(layout, std::string("interleaved-uv").size(), "planar-uv");
|
||||
EXPECT_EQ(source.submitHostField(unsupportedLayout, eastPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::RejectedAdapter);
|
||||
|
||||
auto unsupportedEncoding = localMetadata(2);
|
||||
const auto encoding = unsupportedEncoding.find("float32-le");
|
||||
ASSERT_NE(encoding, std::string::npos);
|
||||
unsupportedEncoding.replace(encoding, std::string("float32-le").size(), "float64-le");
|
||||
EXPECT_EQ(source.submitHostField(unsupportedEncoding, eastPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::RejectedAdapter);
|
||||
|
||||
auto invalidDimensions = localMetadata(2);
|
||||
const auto width = invalidDimensions.find("\"width\":16");
|
||||
ASSERT_NE(width, std::string::npos);
|
||||
invalidDimensions.replace(width, std::string("\"width\":16").size(), "\"width\":0");
|
||||
EXPECT_EQ(source.submitHostField(invalidDimensions, eastPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::RejectedAdapter);
|
||||
|
||||
auto shortPayload = eastPayload;
|
||||
shortPayload.pop_back();
|
||||
EXPECT_EQ(source.submitHostField(localMetadata(2), shortPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::RejectedAdapter);
|
||||
|
||||
auto nonMonotonic = localMetadata(2);
|
||||
const auto validTime = nonMonotonic.find("1704074400000");
|
||||
ASSERT_NE(validTime, std::string::npos);
|
||||
nonMonotonic.replace(validTime, std::string("1704074400000").size(), "1704069000000");
|
||||
EXPECT_EQ(source.submitHostField(nonMonotonic, eastPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::RejectedNonMonotonicSnapshot);
|
||||
|
||||
EXPECT_EQ(source.impl().timeline->size(), 2u);
|
||||
EXPECT_EQ(source.impl().timeline->lastEntry().field, lastValid);
|
||||
EXPECT_EQ(source.impl().timeline->frame().state, style::RasterArrayPlaybackState::Ready);
|
||||
|
||||
style::RasterArraySource fixtureSource{"fixture", "uniform-east", "metadata", "field"};
|
||||
EXPECT_EQ(fixtureSource.submitHostField(localMetadata(2), eastPayload),
|
||||
style::RasterArrayHostFieldSubmitResult::RejectedSourceMode);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayTimelineCandidatesReachReadyThroughFieldAdapter) {
|
||||
SourceTest test;
|
||||
const auto eastMetadata = fixtureBytes("uniform-east", "metadata.json");
|
||||
const auto eastField = fixtureBytes("uniform-east", "field.f32");
|
||||
auto northMetadata = fixtureBytes("uniform-north", "metadata.json");
|
||||
const auto northField = fixtureBytes("uniform-north", "field.f32");
|
||||
auto vortexMetadata = fixtureBytes("vortex", "metadata.json");
|
||||
const auto vortexField = fixtureBytes("vortex", "field.f32");
|
||||
const auto northTimeIndex = northMetadata.find("\"timeIndex\": 0");
|
||||
ASSERT_NE(northTimeIndex, std::string::npos);
|
||||
northMetadata.replace(northTimeIndex, std::string("\"timeIndex\": 0").size(), "\"timeIndex\": 1");
|
||||
const auto vortexTimeIndex = vortexMetadata.find("\"timeIndex\": 0");
|
||||
ASSERT_NE(vortexTimeIndex, std::string::npos);
|
||||
vortexMetadata.replace(vortexTimeIndex, std::string("\"timeIndex\": 0").size(), "\"timeIndex\": 2");
|
||||
test.fileSource->sourceResponse = [&](const Resource& resource) -> Response {
|
||||
Response response;
|
||||
const std::string* text = nullptr;
|
||||
const std::string* bytes = nullptr;
|
||||
if (resource.url == "east-metadata") text = &eastMetadata;
|
||||
if (resource.url == "east-field") bytes = &eastField;
|
||||
if (resource.url == "north-metadata") text = &northMetadata;
|
||||
if (resource.url == "north-field") bytes = &northField;
|
||||
if (resource.url == "vortex-metadata") text = &vortexMetadata;
|
||||
if (resource.url == "vortex-field") bytes = &vortexField;
|
||||
if (text) response.data = std::make_unique<std::string>(*text);
|
||||
if (bytes) response.data = std::make_unique<std::string>(*bytes);
|
||||
if (!response.data) ADD_FAILURE() << "Unexpected Timeline resource: " << resource.url;
|
||||
return response;
|
||||
};
|
||||
|
||||
style::RasterArraySource source{
|
||||
"source", "uniform-east", "east-metadata", "east-field", "uniform-north", "north-metadata", "north-field",
|
||||
{{"vortex", "vortex-metadata", "vortex-field", 0}}};
|
||||
source.setObserver(&test.styleObserver);
|
||||
test.styleObserver.sourceChanged = [&](Source&) {
|
||||
if (source.impl().timeline) test.end();
|
||||
};
|
||||
source.loadDescription(*test.fileSource);
|
||||
test.run();
|
||||
|
||||
ASSERT_TRUE(source.impl().timeline);
|
||||
const auto frame = source.impl().timeline->frame();
|
||||
ASSERT_TRUE(frame.t0);
|
||||
ASSERT_TRUE(frame.t1);
|
||||
EXPECT_EQ(frame.state, style::RasterArrayPlaybackState::Ready);
|
||||
EXPECT_EQ(frame.t0Index, 0u);
|
||||
EXPECT_TRUE(frame.t0->isValid());
|
||||
EXPECT_TRUE(frame.t1->isValid());
|
||||
EXPECT_FALSE(frame.t1->fieldID.empty());
|
||||
EXPECT_GT(frame.t1->generation, 0u);
|
||||
EXPECT_TRUE(source.impl().timeline->play());
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayGpuReuseConsumesNewRevision) {
|
||||
const auto oldT1 = particle::OfflineFixtureParticleAdapter::load("uniform-north", 100);
|
||||
auto wantedNewT0 = oldT1;
|
||||
wantedNewT0.generation = 200;
|
||||
const auto assignedAfterReuse = wantedNewT0;
|
||||
|
||||
EXPECT_TRUE(particle::sameFieldGpuContent(oldT1, wantedNewT0));
|
||||
EXPECT_FALSE(particle::fieldIdentityChanged(assignedAfterReuse, wantedNewT0));
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayTimelineKeepsCoreOwnedWindowAndPlaybackControls) {
|
||||
std::vector<style::RasterArrayTimelineRuntime::Entry> entries;
|
||||
for (uint64_t index = 0; index < 5; ++index) {
|
||||
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(
|
||||
particle::OfflineFixtureParticleAdapter::load("uniform-east", index + 1)),
|
||||
index * 4000});
|
||||
}
|
||||
style::RasterArrayTimelineRuntime timeline{std::move(entries)};
|
||||
EXPECT_EQ(timeline.frame().t0Index, 0u);
|
||||
EXPECT_TRUE(timeline.play());
|
||||
EXPECT_TRUE(timeline.pause());
|
||||
EXPECT_TRUE(timeline.setSpeed(0.5f));
|
||||
EXPECT_FALSE(timeline.setSpeed(3.0f));
|
||||
EXPECT_TRUE(timeline.step(1));
|
||||
EXPECT_EQ(timeline.frame().t0Index, 1u);
|
||||
EXPECT_TRUE(timeline.seek(4));
|
||||
EXPECT_EQ(timeline.frame().state, style::RasterArrayPlaybackState::Ended);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayTimelineCadenceUsesOneSharedDeterministicDeadline) {
|
||||
std::vector<style::RasterArrayTimelineRuntime::Entry> entries;
|
||||
for (uint64_t index = 0; index < 3; ++index) {
|
||||
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(
|
||||
particle::OfflineFixtureParticleAdapter::load("uniform-east", index + 1)), index * 4000});
|
||||
}
|
||||
style::RasterArrayTimelineRuntime timeline{std::move(entries)};
|
||||
const auto start = std::chrono::steady_clock::time_point{} + std::chrono::seconds(1);
|
||||
EXPECT_TRUE(timeline.setTargetFps(style::RasterArrayTargetFps::Fps30));
|
||||
const auto weatherColorAdvance = timeline.advanceIfDue(start);
|
||||
EXPECT_TRUE(weatherColorAdvance.advanced);
|
||||
const auto particleSameDeadline = timeline.advanceIfDue(start);
|
||||
EXPECT_FALSE(particleSameDeadline.advanced);
|
||||
EXPECT_EQ(particleSameDeadline.frame.cadenceRevision, weatherColorAdvance.frame.cadenceRevision);
|
||||
EXPECT_FALSE(timeline.cadenceDue(start + std::chrono::milliseconds(33)));
|
||||
EXPECT_TRUE(timeline.cadenceDue(start + std::chrono::milliseconds(34)));
|
||||
const auto particleNextDeadline = timeline.advanceIfDue(start + std::chrono::milliseconds(34));
|
||||
EXPECT_TRUE(particleNextDeadline.advanced);
|
||||
const auto weatherColorLateSameDeadline = timeline.advanceIfDue(start + std::chrono::milliseconds(34));
|
||||
EXPECT_FALSE(weatherColorLateSameDeadline.advanced);
|
||||
EXPECT_TRUE(timeline.pause());
|
||||
const auto pausedColorFrame = timeline.frame();
|
||||
EXPECT_EQ(pausedColorFrame.state, style::RasterArrayPlaybackState::Paused);
|
||||
// A paused WeatherColor-only layer asks no continuous repaint; if another
|
||||
// map frame arrives, one shared advance is harmless and does not move MIX.
|
||||
const auto pausedAdvance = timeline.advanceIfDue(start + std::chrono::milliseconds(68));
|
||||
EXPECT_TRUE(pausedAdvance.advanced);
|
||||
EXPECT_FLOAT_EQ(pausedAdvance.frame.mix, pausedColorFrame.mix);
|
||||
EXPECT_TRUE(timeline.setTargetFps(style::RasterArrayTargetFps::Fps60));
|
||||
const auto afterSwitch = timeline.advanceIfDue(start + std::chrono::milliseconds(68));
|
||||
EXPECT_TRUE(afterSwitch.advanced);
|
||||
EXPECT_FALSE(timeline.advanceIfDue(start + std::chrono::milliseconds(84)).advanced);
|
||||
EXPECT_TRUE(timeline.advanceIfDue(start + std::chrono::milliseconds(85)).advanced);
|
||||
const auto missedDeadline = timeline.advanceIfDue(start + std::chrono::seconds(5));
|
||||
EXPECT_TRUE(missedDeadline.advanced);
|
||||
EXPECT_FALSE(timeline.advanceIfDue(start + std::chrono::seconds(5)).advanced);
|
||||
EXPECT_TRUE(timeline.setTargetFps(style::RasterArrayTargetFps::Auto));
|
||||
EXPECT_TRUE(timeline.cadenceDue(start));
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayV4RendererCadenceGatesVisualSimulationWithoutForecastPlayback) {
|
||||
style::RasterArrayRendererCadence cadence;
|
||||
const auto start = std::chrono::steady_clock::time_point{} + std::chrono::seconds(1);
|
||||
cadence.setTargetFps(style::RasterArrayTargetFps::Fps30);
|
||||
EXPECT_TRUE(cadence.cadenceDue(start));
|
||||
EXPECT_TRUE(cadence.consumeIfDue(start));
|
||||
EXPECT_FALSE(cadence.consumeIfDue(start));
|
||||
EXPECT_FALSE(cadence.cadenceDue(start + std::chrono::milliseconds(33)));
|
||||
EXPECT_TRUE(cadence.cadenceDue(start + std::chrono::milliseconds(34)));
|
||||
EXPECT_TRUE(cadence.consumeIfDue(start + std::chrono::milliseconds(34)));
|
||||
const auto revisionAt30 = cadence.revision();
|
||||
|
||||
cadence.setTargetFps(style::RasterArrayTargetFps::Fps60);
|
||||
EXPECT_TRUE(cadence.consumeIfDue(start + std::chrono::milliseconds(34)));
|
||||
EXPECT_FALSE(cadence.consumeIfDue(start + std::chrono::milliseconds(50)));
|
||||
EXPECT_TRUE(cadence.consumeIfDue(start + std::chrono::milliseconds(51)));
|
||||
EXPECT_GT(cadence.revision(), revisionAt30);
|
||||
|
||||
cadence.setTargetFps(style::RasterArrayTargetFps::Auto);
|
||||
EXPECT_TRUE(cadence.consumeIfDue(start + std::chrono::milliseconds(51)));
|
||||
EXPECT_TRUE(cadence.consumeIfDue(start + std::chrono::milliseconds(51)));
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayRuntimeFieldWindowAdvancesWithoutReplacingTheSource) {
|
||||
SourceTest test;
|
||||
const auto eastMetadata = fixtureBytes("uniform-east", "metadata.json");
|
||||
const auto eastField = fixtureBytes("uniform-east", "field.f32");
|
||||
const auto northMetadata = fixtureBytes("uniform-north", "metadata.json");
|
||||
const auto northField = fixtureBytes("uniform-north", "field.f32");
|
||||
|
||||
test.fileSource->sourceResponse = [&](const Resource& resource) -> Response {
|
||||
Response response;
|
||||
if (resource.url == "east-metadata") {
|
||||
response.data = std::make_unique<std::string>(eastMetadata);
|
||||
} else if (resource.url == "east-field") {
|
||||
response.data = std::make_unique<std::string>(eastField);
|
||||
} else if (resource.url == "north-metadata") {
|
||||
response.data = std::make_unique<std::string>(northMetadata);
|
||||
} else if (resource.url == "north-field") {
|
||||
response.data = std::make_unique<std::string>(northField);
|
||||
} else {
|
||||
ADD_FAILURE() << "Unexpected RasterArray resource: " << resource.url;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
style::RasterArraySource source{
|
||||
"source", "uniform-east", "east-metadata", "east-field", "uniform-north", "north-metadata", "north-field"};
|
||||
source.setObserver(&test.styleObserver);
|
||||
bool initialLoad = true;
|
||||
uint32_t sourceChangedCount = 0;
|
||||
test.styleObserver.sourceChanged = [&](Source&) {
|
||||
++sourceChangedCount;
|
||||
if (initialLoad) {
|
||||
initialLoad = false;
|
||||
test.end();
|
||||
}
|
||||
};
|
||||
source.loadDescription(*test.fileSource);
|
||||
test.run();
|
||||
|
||||
ASSERT_TRUE(source.impl().field);
|
||||
ASSERT_TRUE(source.impl().nextField);
|
||||
const auto initialT0Generation = source.impl().field->generation;
|
||||
const auto initialT1Generation = source.impl().nextField->generation;
|
||||
const auto vortex = particle::OfflineFixtureParticleAdapter::load("vortex", initialT1Generation + 1);
|
||||
const auto nodata = particle::OfflineFixtureParticleAdapter::load("nodata", vortex.generation + 1);
|
||||
|
||||
EXPECT_EQ(source.updateRuntimeFieldWindow(vortex), style::RasterArrayRuntimeFieldUpdateResult::Applied);
|
||||
EXPECT_EQ(source.impl().field->fieldID, "uniform-north");
|
||||
EXPECT_EQ(source.impl().field->generation, initialT1Generation);
|
||||
EXPECT_EQ(source.impl().nextField->fieldID, "vortex");
|
||||
EXPECT_EQ(source.impl().nextField->generation, vortex.generation);
|
||||
EXPECT_EQ(sourceChangedCount, 2u);
|
||||
|
||||
EXPECT_EQ(source.updateRuntimeFieldWindow(vortex), style::RasterArrayRuntimeFieldUpdateResult::Noop);
|
||||
auto invalid = nodata;
|
||||
invalid.vector.clear();
|
||||
EXPECT_EQ(source.updateRuntimeFieldWindow(invalid),
|
||||
style::RasterArrayRuntimeFieldUpdateResult::RejectedInvalidSnapshot);
|
||||
EXPECT_EQ(source.impl().field->fieldID, "uniform-north");
|
||||
EXPECT_EQ(source.impl().nextField->fieldID, "vortex");
|
||||
|
||||
EXPECT_EQ(source.updateRuntimeFieldWindow(nodata), style::RasterArrayRuntimeFieldUpdateResult::Applied);
|
||||
EXPECT_EQ(source.impl().field->fieldID, "vortex");
|
||||
EXPECT_EQ(source.impl().nextField->fieldID, "nodata");
|
||||
EXPECT_EQ(sourceChangedCount, 3u);
|
||||
EXPECT_NE(source.impl().field->generation, initialT0Generation);
|
||||
|
||||
EXPECT_EQ(source.repeatRuntimeFieldWindow(), style::RasterArrayRuntimeFieldUpdateResult::Noop);
|
||||
EXPECT_EQ(source.impl().field->fieldID, "vortex");
|
||||
EXPECT_EQ(source.impl().nextField->fieldID, "nodata");
|
||||
EXPECT_EQ(sourceChangedCount, 3u);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayTimeWindowKeepsIndependentCompatibleSnapshots) {
|
||||
const auto east = particle::OfflineFixtureParticleAdapter::load("uniform-east", 11);
|
||||
const auto north = particle::OfflineFixtureParticleAdapter::load("uniform-north", 12);
|
||||
const particle::ParticleFieldTimeWindow window{east, north};
|
||||
|
||||
EXPECT_TRUE(window.isValid());
|
||||
EXPECT_NE(window.t0.generation, window.t1.generation);
|
||||
EXPECT_TRUE(window.t0.isTimeInterpolationCompatibleWith(window.t1));
|
||||
EXPECT_TRUE(particle::fieldIdentityChanged(window.t0, window.t1));
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayTimeWindowRejectsIncompatibleGrid) {
|
||||
const auto east = particle::OfflineFixtureParticleAdapter::load("uniform-east", 13);
|
||||
auto incompatible = east;
|
||||
++incompatible.width;
|
||||
const particle::ParticleFieldTimeWindow window{east, incompatible};
|
||||
|
||||
EXPECT_FALSE(east.isTimeInterpolationCompatibleWith(incompatible));
|
||||
EXPECT_FALSE(window.isValid());
|
||||
}
|
||||
|
||||
TEST(Source, LoadingCorrupt) {
|
||||
SourceTest test;
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
#include <mln/style/layers/line_layer_impl.hpp>
|
||||
#include <mln/style/layers/raster_layer.hpp>
|
||||
#include <mln/style/layers/raster_layer_impl.hpp>
|
||||
#include <mln/style/layers/raster_particle_layer.hpp>
|
||||
#include <mln/style/layers/weather_color_layer.hpp>
|
||||
#include <mln/style/layers/symbol_layer.hpp>
|
||||
#include <mln/style/layers/symbol_layer_impl.hpp>
|
||||
#include <mln/style/style_impl.hpp>
|
||||
@@ -27,6 +29,7 @@
|
||||
#include <mln/util/run_loop.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <limits>
|
||||
|
||||
using namespace mln;
|
||||
using namespace mln::style;
|
||||
@@ -111,6 +114,109 @@ TEST(Layer, CircleProperties) {
|
||||
EXPECT_EQ(layer->getCircleTranslateAnchor(), translateAnchor);
|
||||
}
|
||||
|
||||
TEST(Layer, RasterParticleCountProperties) {
|
||||
RasterParticleLayer layer{"particle", "raster-array"};
|
||||
|
||||
EXPECT_EQ(256u, layer.getParticleCount());
|
||||
|
||||
layer.setParticleCount(65536);
|
||||
EXPECT_EQ(65536u, layer.getParticleCount());
|
||||
|
||||
// Production-like validation also supports the explicit non-square
|
||||
// presets used by the Android visual tuner. The Renderer rounds its State
|
||||
// texture grid up and only draws the requested particle count.
|
||||
layer.setParticleCount(2048);
|
||||
EXPECT_EQ(2048u, layer.getParticleCount());
|
||||
|
||||
// Other values remain rejected by the Core Layer.
|
||||
layer.setParticleCount(1000);
|
||||
EXPECT_EQ(2048u, layer.getParticleCount());
|
||||
layer.setParticleCount(1048577);
|
||||
EXPECT_EQ(2048u, layer.getParticleCount());
|
||||
}
|
||||
|
||||
TEST(Layer, RasterParticleVisualProperties) {
|
||||
RasterParticleLayer layer{"particle", "raster-array"};
|
||||
|
||||
EXPECT_FLOAT_EQ(2.0f, layer.getLineWidth());
|
||||
EXPECT_FLOAT_EQ(0.018f, layer.getParticleLength());
|
||||
EXPECT_FLOAT_EQ(1.0f, layer.getParticleOpacity());
|
||||
EXPECT_FLOAT_EQ(1.0f, layer.getTrailOpacity());
|
||||
EXPECT_FLOAT_EQ(0.92f, layer.getTrailFade());
|
||||
EXPECT_FALSE(layer.isSpeedColorEnabled());
|
||||
|
||||
layer.setSpeedColorEnabled(true);
|
||||
EXPECT_TRUE(layer.isSpeedColorEnabled());
|
||||
layer.setLineWidth(4.0f);
|
||||
layer.setParticleLength(0.030f);
|
||||
layer.setParticleOpacity(0.45f);
|
||||
layer.setTrailOpacity(0.60f);
|
||||
layer.setTrailFade(0.80f);
|
||||
layer.setSpeedColorEnabled(false);
|
||||
|
||||
EXPECT_FLOAT_EQ(4.0f, layer.getLineWidth());
|
||||
EXPECT_FLOAT_EQ(0.030f, layer.getParticleLength());
|
||||
EXPECT_FLOAT_EQ(0.45f, layer.getParticleOpacity());
|
||||
EXPECT_FLOAT_EQ(0.60f, layer.getTrailOpacity());
|
||||
EXPECT_FLOAT_EQ(0.80f, layer.getTrailFade());
|
||||
EXPECT_FALSE(layer.isSpeedColorEnabled());
|
||||
|
||||
// Visual properties are bounded, and cannot affect the existing Field or
|
||||
// simulation resource contracts through invalid values.
|
||||
layer.setLineWidth(100.0f);
|
||||
layer.setParticleLength(-1.0f);
|
||||
layer.setTrailFade(2.0f);
|
||||
EXPECT_FLOAT_EQ(8.0f, layer.getLineWidth());
|
||||
EXPECT_FLOAT_EQ(0.0005f, layer.getParticleLength());
|
||||
EXPECT_FLOAT_EQ(0.995f, layer.getTrailFade());
|
||||
|
||||
const std::array<float, 4> particleColor{0.2f, 0.3f, 0.4f, 0.5f};
|
||||
const std::array<float, 4> trailColor{0.7f, 0.6f, 0.5f, 0.4f};
|
||||
layer.setParticleColor(particleColor);
|
||||
layer.setTrailColor(trailColor);
|
||||
EXPECT_EQ(layer.getParticleColor(), particleColor);
|
||||
EXPECT_EQ(layer.getTrailColor(), trailColor);
|
||||
layer.setParticleColor({-1.0f, 0.0f, 0.0f, 1.0f});
|
||||
EXPECT_EQ(layer.getParticleColor(), particleColor);
|
||||
}
|
||||
|
||||
TEST(Layer, RasterParticleDirectionAdvectionScale) {
|
||||
RasterParticleLayer layer{"particle", "raster-array"};
|
||||
EXPECT_FLOAT_EQ(1.0f, layer.getAdvectionScale());
|
||||
|
||||
layer.setAdvectionScale(2.5f);
|
||||
EXPECT_FLOAT_EQ(2.5f, layer.getAdvectionScale());
|
||||
layer.setAdvectionScale(0.0f);
|
||||
layer.setAdvectionScale(-1.0f);
|
||||
layer.setAdvectionScale(std::numeric_limits<float>::infinity());
|
||||
EXPECT_FLOAT_EQ(2.5f, layer.getAdvectionScale());
|
||||
}
|
||||
|
||||
TEST(Layer, WeatherColorUsesIndependentFixedOpacity) {
|
||||
WeatherColorLayer layer{"weather-color", "raster-array"};
|
||||
ASSERT_STREQ("weather-color", layer.getTypeInfo()->type);
|
||||
EXPECT_EQ(FieldValueSource::VectorMagnitude, layer.getFieldValueSource());
|
||||
layer.setFieldValueSource(FieldValueSource::Scalar);
|
||||
EXPECT_EQ(FieldValueSource::Scalar, layer.getFieldValueSource());
|
||||
EXPECT_FLOAT_EQ(0.62f, layer.getOpacity());
|
||||
layer.setOpacity(0.35f);
|
||||
EXPECT_FLOAT_EQ(0.35f, layer.getOpacity());
|
||||
layer.setOpacity(4.0f);
|
||||
EXPECT_FLOAT_EQ(1.0f, layer.getOpacity());
|
||||
|
||||
std::vector<WeatherColorStop> valid{{0.0f, {0, 0, 1, 1}}, {10.0f, {0, 1, 0, 1}}, {20.0f, {1, 0, 0, 1}}};
|
||||
EXPECT_TRUE(layer.setColorStops(valid));
|
||||
EXPECT_EQ(layer.getColorStops(), valid);
|
||||
EXPECT_FALSE(layer.setColorStops({{0.0f, {0, 0, 1, 1}}}));
|
||||
EXPECT_FALSE(layer.setColorStops({{0.0f, {0, 0, 1, 1}}, {0.0f, {1, 0, 0, 1}}}));
|
||||
EXPECT_TRUE(layer.setColorStops({{-2.0f, {0, 0, 1, 1}}, {3.0f, {1, 0, 0, 1}}}));
|
||||
EXPECT_FALSE(layer.setColorStops({{0.0f, {0, 0, 1, 1}}, {10.0f, {std::numeric_limits<float>::quiet_NaN(), 0, 0, 1}}}));
|
||||
EXPECT_FALSE(layer.setColorStops({{0.0f, {0, 0, 1, 1}}, {1.0f, {0, 0, 1, 1}}, {2.0f, {0, 0, 1, 1}},
|
||||
{3.0f, {0, 0, 1, 1}}, {4.0f, {0, 0, 1, 1}}, {5.0f, {0, 0, 1, 1}},
|
||||
{6.0f, {0, 0, 1, 1}}, {7.0f, {0, 0, 1, 1}}, {8.0f, {0, 0, 1, 1}}}));
|
||||
EXPECT_EQ(layer.getColorStops(), valid);
|
||||
}
|
||||
|
||||
TEST(Layer, FillProperties) {
|
||||
auto layer = std::make_unique<FillLayer>("fill", "source");
|
||||
ASSERT_STREQ("fill", layer->getTypeInfo()->type);
|
||||
|
||||
Reference in New Issue
Block a user