#include #include #include #include #include #include #include #include #include #include #include #include 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& bytes, size_t offset, uint16_t value) { bytes[offset] = uint8_t(value); bytes[offset + 1] = uint8_t(value >> 8); } void u32(std::vector& 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& 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& bytes, size_t offset, int64_t value) { u64(bytes, offset, static_cast(value)); } void f32(std::vector& bytes, size_t offset, float value) { uint32_t bits; std::memcpy(&bits, &value, sizeof(bits)); u32(bytes, offset, bits); } void f64(std::vector& 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& 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 bytes; }; std::vector qbin(uint64_t validTime, bool scalar = false, uint8_t validity = 0x0f, uint64_t referenceTime = Base) { std::vector 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 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(referenceTime / 1000)); i64(bytes, 32, static_cast(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& 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 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 raw, uint8_t validity = 0x0f) { auto frame = std::make_shared(); 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>(std::vector{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>(std::move(raw)); frame->planes.push_back({key, dtype, valueClass, unitID, true, 4, scale, offset, "plane", storage}); return frame; } std::vector float32LE(std::initializer_list values) { std::vector bytes(values.size() * 4); size_t offset = 0; for (const float value : values) { f32(bytes, offset, value); offset += 4; } return bytes; } std::vector uint16LE(std::initializer_list values) { std::vector 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::max()); f32(overflowing, firstPlaneDescriptor + 8, std::numeric_limits::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{1, 5, 2, 6, 3, 7, 4, 8})); EXPECT_EQ(particles.t0->valid, (std::vector{1, 1, 1, 1})); EXPECT_EQ(color.t0->scalar, (std::vector{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{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{1, 5, 2, 6, 3, 7, 4, 8})); EXPECT_EQ(colorWithoutParticles.t0->valid, (std::vector{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{4.0f, -3.0f, 7.5f, 9.0f})); EXPECT_EQ(materialized->t0->valid, (std::vector{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{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); }