merge(upgrade): port particle extensions onto official native 13.6.1 (unverified)

This commit is contained in:
tei
2026-10-09 09:37:07 +09:00
767 changed files with 85435 additions and 153 deletions

View File

@@ -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(

View File

@@ -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);

View 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

View 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);
}

View 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

View 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());
}

View 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]);
}

View 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);
}

View 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);
}

View 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

View 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);
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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));
}

View 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);
}

View File

@@ -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;

View File

@@ -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);