Files
MapLibreParticleLab/test/renderer/retained_underlay_cache.test.cpp
tei 3d989a00a8
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
feat: admit settled Symbol render results to retained cache
2026-10-11 16:47:21 +09:00

2112 lines
102 KiB
C++

#include <mln/renderer/retained_underlay_cache.hpp>
#include <mln/renderer/particle_fast_path.hpp>
#include <mln/renderer/continuous_layer_range.hpp>
#include <mln/renderer/render_segment_json.hpp>
#include <mln/renderer/render_segment_plan.hpp>
#include <mln/renderer/update_parameters.hpp>
#include <mln/text/placement.hpp>
#include <mln/renderer/symbol_render_result.hpp>
#include <mln/renderer/symbol_render_inputs.hpp>
#include <mln/renderer/buckets/symbol_bucket.hpp>
#include <mln/renderer/symbol_shader_provenance.hpp>
#include <mln/shaders/symbol_layer_ubo.hpp>
#include <gtest/gtest.h>
#include <algorithm>
#include <initializer_list>
#include <limits>
#include <stdexcept>
#include <utility>
#include <vector>
using namespace mln;
namespace {
RetainedUnderlayKey key(Size size = {640, 480}, int32_t boundary = 4) {
RetainedUnderlayKey result{size, 0, boundary, {}};
result.cameraMatrix[0] = 1.0;
result.cameraMatrix[5] = 1.0;
result.cameraMatrix[10] = 1.0;
result.cameraMatrix[15] = 1.0;
return result;
}
RetainedUnderlayRevisionState revisionState(Size size = {640, 480}, int32_t boundary = 4) {
RetainedUnderlayRevisionState result;
result.viewport = size;
result.boundary = boundary;
result.pixelRatio = 2.0f;
result.contentRevision = 1;
result.topologyRevision = 1;
result.styleLoaded = true;
result.renderTreeLoaded = true;
result.staticStable = true;
result.placementStable = true;
result.cameraMatrix = key(size, boundary).cameraMatrix;
return result;
}
ParticleFastPathGateInput fastPathInput() {
return {
.hasPreparedTree = true,
.retainedEnabled = true,
.cacheValid = true,
.revisionMatches = true,
.contentRevisionMatches = true,
.cameraMatches = true,
.viewportMatches = true,
.updateParametersMatch = true,
.styleLoaded = true,
.boundaryMatches = true,
.topologyMatches = true,
.staticStable = true,
.hasPlacementTransition = false,
.hasPendingChanges = false,
.hasNonParticleTransition = false,
.hasNonParticleRepaint = false,
.hasFadingTiles = false,
.particleNeedsRepaint = true,
.stillImageRequest = false,
.continuousMode = true,
.debugDisabled = true,
.unsupportedContinuousLayer = false,
.supportedScene = true,
.annotationMutation = false,
};
}
std::shared_ptr<UpdateParameters> step117PlacementParameters(TimePoint now,
style::TransitionOptions transitions = {}) {
TransformState state;
state.setSize({640, 480});
return std::make_shared<UpdateParameters>(UpdateParameters{
.styleLoaded = true,
.mode = MapMode::Continuous,
.pixelRatio = 1.0f,
.debugOptions = MapDebugOptions::NoDebug,
.timePoint = now,
.transformState = state,
.glyphURL = {},
.fontFaces = {},
.spriteLoaded = true,
.transitionOptions = transitions,
.light = style::Light{}.impl,
.images = makeMutable<std::vector<Immutable<style::Image::Impl>>>(),
.sources = makeMutable<std::vector<Immutable<style::Source::Impl>>>(),
.layers = makeMutable<std::vector<Immutable<style::Layer::Impl>>>(),
.annotationManager = {},
.fileSource = {},
.prefetchZoomDelta = 0,
.stillImageRequest = false,
.crossSourceCollisions = true,
.retainedUnderlayState = {},
});
}
} // namespace
namespace {
SymbolResultBinding step119Binding() {
SymbolResultBinding binding;
binding.viewport = {640, 480};
binding.camera = key().cameraMatrix;
binding.content = binding.topology = binding.config = 1;
return binding;
}
SymbolRenderResult step119BufferResult(const SymbolBucket::Buffer& buffer, float fade) {
SymbolRenderResult result;
const auto& dynamic = buffer.dynamicAttributeData();
const auto& opacity = buffer.opacityAttributeData();
result.appendBlock(dynamic.getRawData(), dynamic.bytes());
result.appendBlock(opacity.getRawData(), opacity.bytes());
for (const auto& vertex : opacity.vector()) {
result.complete = result.complete && symbolOpacityIsSettled(vertex.a1[0], fade);
}
return result;
}
void step119Settle(SymbolResultCertificate& certificate, const SymbolRenderResult& result,
const SymbolResultBinding& binding) {
EXPECT_FALSE(certificate.observeFull(result, binding, true));
certificate.completeFull(binding, true, false);
EXPECT_TRUE(certificate.observeFull(result, binding, true));
EXPECT_FALSE(certificate.candidateHasBuild(binding));
certificate.completeFull(binding, true, true);
EXPECT_TRUE(certificate.canReuse(binding));
EXPECT_FALSE(certificate.canFast(binding));
EXPECT_TRUE(certificate.observeFull(result, binding, true));
EXPECT_TRUE(certificate.candidateHasBuild(binding));
certificate.completeFull(binding, true, false, true);
EXPECT_TRUE(certificate.canFast(binding));
}
} // namespace
TEST(Step119SymbolResults, FreshSettledBuildReuseWithRealControllerAndBuffer) {
const auto start = TimePoint{} + Seconds(10);
PlacementController controller;
SymbolBucket::Buffer buffer;
buffer.dynamicAttributeData().emplace_back(SymbolBucket::dynamicLayoutAttributes({20, 30}, 0));
buffer.opacityAttributeData().emplace_back(SymbolBucket::opacityAttributes(true, 1));
SymbolResultCertificate certificate;
RetainedUnderlayCache cache;
const auto binding = step119Binding();
unsigned builds = 0, reuses = 0, calculations = 0;
for (unsigned frame = 0; frame < 50; ++frame) {
const auto now = start + Milliseconds(frame * 30);
if (!controller.placementIsRecent(now, 0)) {
auto placement = Placement::create(step117PlacementParameters(now), controller.getPlacement());
placement->placeLayers({});
controller.setPlacement(std::move(placement));
++calculations;
} else {
controller.setPlacementStale();
EXPECT_TRUE(controller.hasTransitions(now)); // scheduling preserved
EXPECT_FALSE(controller.hasActiveSymbolFade(now));
}
const auto result = step119BufferResult(buffer, controller.getPlacement()->symbolFadeChange(now));
const bool stable = certificate.observeFull(result, binding, !controller.hasActiveSymbolFade(now));
const auto decision = cache.evaluate(key(), certificate.current(), stable, "static-placement");
EXPECT_FALSE(certificate.canFast(binding)); // Full still in flight
if (decision == RetainedUnderlayDecision::Build) { cache.markBuildSucceeded(); ++builds; }
if (decision == RetainedUnderlayDecision::Reuse) ++reuses;
certificate.completeFull(binding, true, decision == RetainedUnderlayDecision::Build,
decision == RetainedUnderlayDecision::Reuse);
}
EXPECT_GT(calculations, 1u);
EXPECT_EQ(builds, 1u);
EXPECT_GT(reuses, 0u);
EXPECT_TRUE(certificate.canFast(binding));
// Cache submission here is a state-machine fixture, not a GPU draw proof.
}
TEST(Step119SymbolResults, ActualPackedOpacityRejectsFadeAndAcceptsShaderEndpoints) {
for (unsigned opacity = 0; opacity <= 127; ++opacity) {
const float packedIn = static_cast<float>(opacity * 2 + 1);
const float packedOut = static_cast<float>(opacity * 2);
EXPECT_EQ(symbolOpacityIsSettled(packedIn, 0), opacity == 127);
EXPECT_EQ(symbolOpacityIsSettled(packedOut, 0), opacity == 0);
EXPECT_TRUE(symbolOpacityIsSettled(packedIn, 1));
EXPECT_TRUE(symbolOpacityIsSettled(packedOut, 1));
EXPECT_TRUE(symbolOpacityIsSettled(packedIn, 5));
}
EXPECT_FALSE(symbolOpacityIsSettled(1, 0.5f));
EXPECT_FALSE(symbolOpacityIsSettled(254, 0.5f));
EXPECT_FALSE(symbolOpacityIsSettled(1.5f, 1));
EXPECT_FALSE(symbolOpacityIsSettled(256, 1));
EXPECT_FALSE(symbolOpacityIsSettled(1, std::numeric_limits<float>::infinity()));
EXPECT_FALSE(symbolOpacityIsSettled(1, std::numeric_limits<float>::quiet_NaN()));
EXPECT_FALSE(symbolOpacityIsSettled(255, -1));
SymbolBucket::Buffer buffer;
buffer.opacityAttributeData().emplace_back(SymbolBucket::opacityAttributes(true, 0));
SymbolResultCertificate certificate;
const auto binding = step119Binding();
const auto fading = step119BufferResult(buffer, 0.5f);
ASSERT_FALSE(fading.complete);
EXPECT_FALSE(certificate.observeFull(fading, binding, true));
certificate.completeFull(binding, true, true);
EXPECT_FALSE(certificate.canReuse(binding));
}
TEST(Step119SymbolResults, SamePlacedBooleanDoesNotProveVariableAnchorOrOrientation) {
SymbolBucket::Buffer buffer;
buffer.dynamicAttributeData().emplace_back(SymbolBucket::dynamicLayoutAttributes({20, 30}, 0));
buffer.opacityAttributeData().emplace_back(SymbolBucket::opacityAttributes(true, 1));
const auto binding = step119Binding();
for (const auto changed : {SymbolBucket::dynamicLayoutAttributes({21, 30}, 0),
SymbolBucket::dynamicLayoutAttributes({20, 30}, 1)}) {
buffer.dynamicAttributeData().at(0) = SymbolBucket::dynamicLayoutAttributes({20, 30}, 0);
SymbolResultCertificate certificate;
step119Settle(certificate, step119BufferResult(buffer, 1), binding);
buffer.dynamicAttributeData().at(0) = changed;
EXPECT_FALSE(certificate.observeFull(step119BufferResult(buffer, 1), binding, true));
EXPECT_FALSE(certificate.canFast(binding));
}
}
TEST(Step119SymbolResults, CameraViewportPixelRatioAndCollisionConfigurationInvalidate) {
SymbolRenderResult result;
result.append(255.0f);
const auto original = step119Binding();
std::vector<SymbolResultBinding> updates(7, original);
updates[0].camera[12] += 1; // pan
updates[1].camera[0] *= 2; // zoom
updates[2].camera[1] = 0.5; // rotation/pitch projection
updates[3].viewport.width += 1;
updates[4].pixelRatio = 2;
updates[5].crossSourceCollisions = false;
updates[6].config += 1;
for (const auto& update : updates) {
SymbolResultCertificate certificate;
step119Settle(certificate, result, original);
EXPECT_FALSE(certificate.canFast(update));
EXPECT_FALSE(certificate.observeFull(result, update, true));
}
CollisionGroups shared(true), isolated(false);
EXPECT_EQ(shared.get("A").first, shared.get("B").first);
EXPECT_NE(isolated.get("A").first, isolated.get("B").first);
}
TEST(Step119SymbolResults, FailedUploadDrawSubmissionAndUnreadableResultsCannotCertify) {
SymbolRenderResult result;
result.append(255.0f);
const auto binding = step119Binding();
SymbolResultCertificate certificate;
EXPECT_FALSE(certificate.observeFull(result, binding, true));
certificate.completeFull(binding, false, false);
EXPECT_FALSE(certificate.observeFull(result, binding, true));
certificate.completeFull(binding, true, false);
EXPECT_TRUE(certificate.observeFull(result, binding, true));
certificate.completeFull(binding, true, false); // resource/compositor failed
EXPECT_FALSE(certificate.canReuse(binding));
result.complete = false;
EXPECT_FALSE(certificate.observeFull(result, binding, true));
certificate.completeFull(binding, true, true);
EXPECT_FALSE(certificate.canFast(binding));
SymbolRenderResult oversize;
oversize.appendBytes(nullptr, SymbolRenderResult::maxBytes + 1);
EXPECT_FALSE(oversize.complete);
auto attributes = std::make_shared<gfx::VertexAttributeArray>();
const auto& attribute = attributes->set(shaders::idSymbolFadeOpacityAttribute);
ASSERT_TRUE(attribute);
auto opacity = std::make_shared<SymbolBucket::OpacityAttributeVector>();
opacity->emplace_back(SymbolBucket::opacityAttributes(true, 1));
attribute->setSharedRawData(opacity, 0, 0, sizeof(SymbolOpacityAttributes), gfx::AttributeDataType::Float);
SymbolRenderResult notUploaded;
appendSymbolAttributes(notUploaded, attributes);
EXPECT_FALSE(notUploaded.complete); // real CPU vector without a GPU buffer
opacity->release();
SymbolRenderResult releasedWithoutBacking;
appendSymbolAttributes(releasedWithoutBacking, attributes);
EXPECT_FALSE(releasedWithoutBacking.complete);
}
TEST(Step119SymbolResults, ExactPayloadAndAtlasIdentityRequireNewResultRevision) {
const auto binding = step119Binding();
SymbolRenderResult original;
original.append(255.0f);
original.append(0.0f); // uniform / draw ordering test payload
for (unsigned component = 0; component < original.bytes.size(); ++component) {
SymbolResultCertificate certificate;
step119Settle(certificate, original, binding);
auto changed = original;
changed.bytes[component] ^= 1;
const auto revision = certificate.current();
EXPECT_FALSE(certificate.observeFull(changed, binding, true));
EXPECT_FALSE(certificate.candidateHasBuild(binding));
EXPECT_GT(certificate.current(), revision);
EXPECT_FALSE(certificate.matchesCandidate(original));
}
// Shader/texture identities are kept alive as part of the exact evidence.
auto oldAtlas = std::make_shared<unsigned>(1), newAtlas = std::make_shared<unsigned>(1);
SymbolRenderResult oldResult, newResult;
oldResult.retain(oldAtlas);
newResult.retain(newAtlas);
SymbolResultCertificate certificate;
step119Settle(certificate, oldResult, binding);
EXPECT_FALSE(certificate.observeFull(newResult, binding, true));
// Payload coverage is not a texture readback or real wrap/collision draw.
}
TEST(Step119SymbolResults, ActualUniformAndWrappedTileInputsDifferEvenWithSamePlacement) {
shaders::SymbolDrawableUBO uniform{};
uniform.matrix[0] = 1;
uniform.opacity_t = 0.5;
const auto payload = [](const shaders::SymbolDrawableUBO& ubo, const OverscaledTileID& tile) {
SymbolRenderResult result;
result.appendBytes(&ubo, offsetof(shaders::SymbolDrawableUBO, halo_blur_t) + sizeof(float));
const auto name = util::toString(tile);
result.appendBlock(name.data(), name.size());
return result;
};
const auto binding = step119Binding();
const OverscaledTileID tile{2, 0, {2, 1, 1}}, wrapped{2, 1, {2, 1, 1}};
const auto original = payload(uniform, tile);
SymbolResultCertificate certificate;
step119Settle(certificate, original, binding);
EXPECT_FALSE(certificate.matchesCandidate(payload(uniform, wrapped)));
uniform.opacity_t = 0.75;
EXPECT_FALSE(certificate.observeFull(payload(uniform, tile), binding, true));
EXPECT_FALSE(certificate.canFast(binding));
}
TEST(Step119SymbolResults, AnnotationGateBlocksOtherwiseCertifiedResultAndKeeps24GateAgreement) {
SymbolRenderResult result;
result.append(255.0f);
const auto binding = step119Binding();
SymbolResultCertificate certificate;
step119Settle(certificate, result, binding);
auto input = fastPathInput();
input.staticStable = certificate.canFast(binding);
ASSERT_TRUE(evaluateParticleFastPath(input).candidate);
input.annotationMutation = true;
EXPECT_FALSE(evaluateParticleFastPath(input).candidate);
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::AnnotationMutation);
EXPECT_EQ(particleFastGateFailureMask(input), 1u << 23);
}
namespace {
class Step119UnknownSymbolProgram final : public gfx::ShaderProgramBase {
public:
const std::string_view typeName() const noexcept override { return "GenericGLShader"; }
std::optional<size_t> getSamplerLocation(size_t) const override { return std::nullopt; }
const gfx::VertexAttributeArray& getVertexAttributes() const override { throw std::logic_error("unknown shader"); }
const gfx::VertexAttributeArray& getInstanceAttributes() const override { throw std::logic_error("unknown shader"); }
};
} // namespace
TEST(Step119SymbolResults, UnknownShaderCannotBorrowNativeFadeContractByName) {
Step119UnknownSymbolProgram replacement;
EXPECT_FALSE(SymbolShaderProvenance::get().supports(replacement));
EXPECT_TRUE(symbolUniformDefine("HAS_UNIFORM_u_opacity"));
EXPECT_TRUE(symbolUniformDefine("HAS_UNIFORM_u_sorted_instance"));
EXPECT_FALSE(symbolUniformDefine("HAS_UNIFORM_u_opacity 1\n#define main custom"));
EXPECT_FALSE(symbolUniformDefine("HAS_UNIFORM_u_custom_animation"));
}
TEST(RetainedDynamicBoundary, SelectsFirstCandidate) {
RetainedDynamicBoundaryTracker none;
none.consider(0, false);
none.consider(3, false);
EXPECT_FALSE(none.value());
RetainedDynamicBoundaryTracker particleOnly;
particleOnly.consider(5, true);
EXPECT_EQ(particleOnly.value(), 5);
RetainedDynamicBoundaryTracker weatherAndParticle;
weatherAndParticle.consider(7, true);
weatherAndParticle.consider(2, true);
EXPECT_EQ(weatherAndParticle.value(), 2);
RetainedDynamicBoundaryTracker particleAndWeather;
particleAndWeather.consider(2, true);
particleAndWeather.consider(7, true);
EXPECT_EQ(particleAndWeather.value(), 2);
}
TEST(RetainedLayerGroupRange, PreservesForwardReverseOriginalOrdinals) {
const std::vector<int32_t> indices{0, 1, 3, 5, 8};
std::vector<size_t> beforeForward;
std::vector<size_t> fromForward;
std::vector<size_t> beforeReverse;
std::vector<size_t> fromReverse;
for (size_t ordinal = 0; ordinal < indices.size(); ++ordinal) {
if (retainedLayerGroupInRange(indices[ordinal], 5, RetainedLayerGroupRange::Before))
beforeForward.push_back(ordinal);
else
fromForward.push_back(ordinal);
}
for (size_t ordinal = indices.size(); ordinal-- > 0;) {
if (retainedLayerGroupInRange(indices[ordinal], 5, RetainedLayerGroupRange::Before))
beforeReverse.push_back(ordinal);
else
fromReverse.push_back(ordinal);
}
EXPECT_EQ(beforeForward, (std::vector<size_t>{0, 1, 2}));
EXPECT_EQ(fromForward, (std::vector<size_t>{3, 4}));
EXPECT_EQ(beforeReverse, (std::vector<size_t>{2, 1, 0}));
EXPECT_EQ(fromReverse, (std::vector<size_t>{4, 3}));
}
TEST(RetainedUnderlayCache, BuildReuseAndInvalidation) {
RetainedUnderlayCache cache;
EXPECT_EQ(cache.evaluate(std::nullopt, 1, false), RetainedUnderlayDecision::Disabled);
EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Reuse);
EXPECT_EQ(cache.getBuildRevision(), 1u);
EXPECT_EQ(cache.evaluate(key({800, 600}), 2, true, nullptr, "viewport"), RetainedUnderlayDecision::Build);
EXPECT_EQ(cache.getLastInvalidationReason(), "viewport");
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(key({800, 600}, 6), 3, true, nullptr, "camera-or-boundary"),
RetainedUnderlayDecision::Build);
EXPECT_EQ(cache.getLastInvalidationReason(), "camera-or-boundary");
}
TEST(RetainedUnderlayCache, RuntimeValidationDiagnosticsExposeRangeResourcesAndFastPath) {
RetainedUnderlayRuntimeState state;
ASSERT_TRUE(state.setContinuousLayerRange("weather", "particle"));
const auto config = state.continuousLayerRangeConfig();
state.recordRangeResolution(
{.configured = true,
.valid = true,
.startLayerId = "weather",
.endLayerId = "particle",
.startIndex = 2,
.endIndex = 3,
.configRevision = config.revision,
.reason = "explicit-range"},
true,
true);
state.recordRangeCacheState(false, RetainedRangeCacheState::Reuse);
state.recordRangeInvalidationReason(false, RetainedUnderlayReason::Viewport);
state.recordRangeTextureCreate(false);
state.recordRangeCompositeCreate(false);
state.recordRangeResourceRelease(false);
state.recordFastPathEligibility(true);
state.recordFastPathDecision(true, ParticleFastPathMissReason::None);
state.recordFullCpuFrame();
const auto snapshot = state.snapshot();
EXPECT_TRUE(snapshot.rangeValid);
EXPECT_EQ(snapshot.continuousBegin, 2);
EXPECT_EQ(snapshot.continuousEndExclusive, 4);
EXPECT_EQ(snapshot.bottomCacheState, "reuse");
EXPECT_EQ(snapshot.bottomLastInvalidationReason, "viewport");
EXPECT_EQ(snapshot.bottomTextureCreate, 1u);
EXPECT_EQ(snapshot.bottomCompositeCreate, 1u);
EXPECT_EQ(snapshot.bottomResourceRelease, 1u);
EXPECT_TRUE(snapshot.fastPathEligible);
EXPECT_FALSE(snapshot.fastPathActive);
EXPECT_EQ(snapshot.fastPathHit, 1u);
EXPECT_EQ(snapshot.fastPathMiss, 0u);
EXPECT_EQ(snapshot.fullCpuFrame, 1u);
}
TEST(RetainedUnderlayRevision, MonotonicAcrossCameraViewportStyleAndSourceChanges) {
RetainedUnderlayRevisionTracker tracker;
RetainedUnderlayCache cache;
auto state = revisionState();
const auto currentKey = [&] {
return RetainedUnderlayKey{state.viewport, 0, *state.boundary, state.cameraMatrix};
};
EXPECT_EQ(tracker.observe(state), 1u);
EXPECT_EQ(tracker.observe(state), 1u);
EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
state.cameraMatrix[12] = 4.0;
EXPECT_EQ(tracker.observe(state), 2u);
EXPECT_EQ(tracker.reason(), "camera-or-boundary");
EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()),
RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
state.viewport = {800, 600};
EXPECT_EQ(tracker.observe(state), 3u);
EXPECT_EQ(tracker.reason(), "viewport");
EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()),
RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
state.pixelRatio = 3.0f;
EXPECT_EQ(tracker.observe(state), 4u);
EXPECT_EQ(tracker.reason(), "pixel-ratio");
EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()),
RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
++state.contentRevision;
EXPECT_EQ(tracker.observe(state, "style-mutation"), 5u);
EXPECT_EQ(tracker.reason(), "style-mutation");
EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()),
RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
++state.contentRevision;
EXPECT_EQ(tracker.observe(state, "layer-mutation"), 6u);
EXPECT_EQ(tracker.reason(), "layer-mutation");
EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()),
RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
++state.contentRevision;
EXPECT_EQ(tracker.observe(state, "source-mutation"), 7u);
EXPECT_EQ(tracker.reason(), "source-mutation");
EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()),
RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
++state.contentRevision;
EXPECT_EQ(tracker.observe(state, "tile-mutation"), 8u);
EXPECT_EQ(tracker.reason(), "tile-mutation");
EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()),
RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
++state.topologyRevision;
EXPECT_EQ(tracker.observe(state), 9u);
EXPECT_EQ(tracker.reason(), "layer-group-mutation");
EXPECT_EQ(cache.evaluate(currentKey(), tracker.current(), true, nullptr, tracker.reason().c_str()),
RetainedUnderlayDecision::Build);
}
TEST(ContinuousLayerRange, ResolvesInclusiveSingleAndMultiLayerRanges) {
const std::vector<std::string> layers{"bottom", "hidden-start", "middle", "hidden-end", "top"};
auto single = resolveContinuousLayerRange({true, "middle", "middle", 1}, layers);
ASSERT_TRUE(single.valid);
EXPECT_EQ(single.startIndex, 2);
EXPECT_EQ(single.endIndex, 2);
EXPECT_EQ(single.endExclusive(), 3);
auto multi = resolveContinuousLayerRange({true, "hidden-start", "hidden-end", 2}, layers);
ASSERT_TRUE(multi.valid);
EXPECT_EQ(multi.startIndex, 1);
EXPECT_EQ(multi.endIndex, 3);
EXPECT_TRUE(multi.bottomPresent());
EXPECT_TRUE(multi.topPresent(static_cast<int32_t>(layers.size())));
}
TEST(ContinuousLayerRange, RejectsMissingAndReversedEndpointsWithoutStaleIndices) {
const std::vector<std::string> layers{"a", "b", "c"};
const auto reversed = resolveContinuousLayerRange({true, "c", "a", 1}, layers);
EXPECT_FALSE(reversed.valid);
EXPECT_EQ(reversed.reason, "range-order-invalid");
const auto missingStart = resolveContinuousLayerRange({true, "missing", "c", 2}, layers);
EXPECT_FALSE(missingStart.valid);
EXPECT_EQ(missingStart.startIndex, -1);
EXPECT_EQ(missingStart.reason, "start-layer-missing");
const auto missingEnd = resolveContinuousLayerRange({true, "a", "missing", 3}, layers);
EXPECT_FALSE(missingEnd.valid);
EXPECT_EQ(missingEnd.endIndex, -1);
EXPECT_EQ(missingEnd.reason, "end-layer-missing");
}
TEST(ContinuousLayerRange, SupportsAllFourDegradedTopologies) {
const std::vector<std::string> layers{"a", "b", "c", "d"};
const auto three = resolveContinuousLayerRange({true, "b", "c", 1}, layers);
EXPECT_TRUE(three.bottomPresent());
EXPECT_TRUE(three.topPresent(4));
const auto bottomOnly = resolveContinuousLayerRange({true, "b", "d", 2}, layers);
EXPECT_TRUE(bottomOnly.bottomPresent());
EXPECT_FALSE(bottomOnly.topPresent(4));
const auto topOnly = resolveContinuousLayerRange({true, "a", "c", 3}, layers);
EXPECT_FALSE(topOnly.bottomPresent());
EXPECT_TRUE(topOnly.topPresent(4));
const auto continuousOnly = resolveContinuousLayerRange({true, "a", "d", 4}, layers);
EXPECT_TRUE(continuousOnly.valid);
EXPECT_FALSE(continuousOnly.bottomPresent());
EXPECT_FALSE(continuousOnly.topPresent(4));
}
TEST(ContinuousLayerRange, OnlyActuallyPresentRetainedRangesRequestCacheBuilds) {
RetainedUnderlayCache bottom(RetainedRangeRole::Bottom);
RetainedUnderlayCache top(RetainedRangeRole::Top);
const auto countBuilds = [&](bool bottomPresent, bool topPresent) {
const auto bottomDecision = bottom.evaluate(bottomPresent ? std::optional<RetainedUnderlayKey>(key())
: std::nullopt,
1, true);
const auto topDecision = top.evaluate(topPresent ? std::optional<RetainedUnderlayKey>(key())
: std::nullopt,
1, true);
return static_cast<int>(bottomDecision == RetainedUnderlayDecision::Build) +
static_cast<int>(topDecision == RetainedUnderlayDecision::Build);
};
EXPECT_EQ(countBuilds(true, true), 2);
EXPECT_EQ(countBuilds(true, false), 1);
EXPECT_EQ(countBuilds(false, true), 1);
EXPECT_EQ(countBuilds(false, false), 0);
}
TEST(ContinuousLayerRange, ReorderAndEndpointDeletionAreResolvedFromCurrentStyle) {
const ContinuousLayerRangeConfig config{true, "b", "c", 7};
EXPECT_TRUE(resolveContinuousLayerRange(config, {"a", "b", "c"}).valid);
EXPECT_FALSE(resolveContinuousLayerRange(config, {"a", "c", "b"}).valid);
const auto deleted = resolveContinuousLayerRange(config, {"a", "b"});
EXPECT_FALSE(deleted.valid);
EXPECT_EQ(deleted.reason, "end-layer-missing");
}
TEST(ContinuousLayerRange, RuntimeConfigurationRejectsEmptyIdsAndAdvancesMonotonically) {
RetainedUnderlayRuntimeState state;
EXPECT_FALSE(state.setContinuousLayerRange("", "end"));
EXPECT_EQ(state.continuousLayerRangeConfig().revision, 0u);
EXPECT_TRUE(state.setContinuousLayerRange("start", "end"));
EXPECT_EQ(state.continuousLayerRangeConfig().revision, 1u);
EXPECT_TRUE(state.setContinuousLayerRange("start", "end"));
EXPECT_EQ(state.continuousLayerRangeConfig().revision, 1u);
EXPECT_TRUE(state.setContinuousLayerRange("same", "same"));
const auto config = state.continuousLayerRangeConfig();
EXPECT_EQ(config.revision, 2u);
EXPECT_EQ(config.startLayerId, config.endLayerId);
}
TEST(ContinuousLayerRange, ExplicitConfigurationNeverMixesWithLegacyBoundary) {
ContinuousLayerRangeResolution valid;
valid.configured = true;
valid.valid = true;
valid.startIndex = 0;
valid.endIndex = 1;
valid.configRevision = 4;
const auto explicitPlan = selectContinuousRenderRanges(valid, 7, 10);
EXPECT_EQ(explicitPlan.continuousBegin, 0);
EXPECT_EQ(explicitPlan.continuousEndExclusive, 2);
EXPECT_FALSE(explicitPlan.bottomPresent());
EXPECT_TRUE(explicitPlan.topPresent());
valid.valid = false;
valid.reason = "start-layer-missing";
const auto invalidPlan = selectContinuousRenderRanges(valid, 7, 10);
EXPECT_FALSE(invalidPlan.retainedMode());
EXPECT_FALSE(invalidPlan.bottomPresent());
EXPECT_FALSE(invalidPlan.topPresent());
const ContinuousLayerRangeResolution unconfigured;
const auto legacyPlan = selectContinuousRenderRanges(unconfigured, 7, 10);
EXPECT_TRUE(legacyPlan.retainedMode());
EXPECT_EQ(legacyPlan.continuousBegin, 7);
EXPECT_FALSE(legacyPlan.topPresent());
}
TEST(ContinuousLayerRange, AbsentRetainedRangesDoNotCauseFastPathMiss) {
auto input = fastPathInput();
input.cacheValid = true; // all zero actually-present retained ranges are valid vacuously.
EXPECT_TRUE(evaluateParticleFastPath(input).candidate);
}
TEST(RetainedUnderlayRevision, RepeatedFramesWithoutSemanticMutationDoNotAdvanceRevision) {
RetainedUnderlayRevisionTracker tracker;
auto state = revisionState();
const auto settledRevision = tracker.observe(state);
// Transient drawable add/remove/rebuild and equivalent stencil assignment are
// deliberately not inputs to the semantic revision state. Stable frames must
// therefore keep the revision used by the retained build.
for (int frame = 0; frame < 240; ++frame) {
EXPECT_EQ(tracker.observe(state), settledRevision);
}
++state.contentRevision;
EXPECT_GT(tracker.observe(state, "tile-mutation"), settledRevision);
EXPECT_EQ(tracker.reason(), "tile-mutation");
}
TEST(RetainedUnderlayRevision, EquivalentIntraFrameRemoveAddDoesNotAdvanceTopology) {
RetainedUnderlayTopologyTracker tracker;
const RetainedUnderlayTopologySnapshot initial{
.boundary = 32,
.staticPrefix = {{3, "sea-area-fill", 1}, {31, "clip-outline", 1}},
.continuousEndExclusive = std::nullopt,
.staticSuffix = {},
};
const auto settledRevision = tracker.observe(initial);
// The request processor may temporarily remove a group and add an equivalent
// replacement. Only the final post-processChanges snapshot is observed.
auto finalTopology = initial;
finalTopology.staticPrefix.erase(finalTopology.staticPrefix.begin());
finalTopology.staticPrefix.insert(finalTopology.staticPrefix.begin(), {3, "sea-area-fill", 1});
EXPECT_EQ(finalTopology, initial);
EXPECT_EQ(tracker.observe(finalTopology), settledRevision);
}
TEST(RetainedUnderlayRevision, PersistentTopologyChangeAdvancesOnce) {
RetainedUnderlayTopologyTracker tracker;
const RetainedUnderlayTopologySnapshot initial{
.boundary = 32,
.staticPrefix = {{3, "sea-area-fill", 1}, {31, "clip-outline", 1}},
.continuousEndExclusive = std::nullopt,
.staticSuffix = {},
};
const auto initialRevision = tracker.observe(initial);
auto changed = initial;
changed.staticPrefix.pop_back();
const auto changedRevision = tracker.observe(changed);
EXPECT_GT(changedRevision, initialRevision);
EXPECT_EQ(tracker.observe(changed), changedRevision);
}
TEST(RetainedUnderlayRevision, OrderingAndIndexChangesAdvanceTopology) {
RetainedUnderlayTopologyTracker tracker;
RetainedUnderlayTopologySnapshot topology{
.boundary = 32,
.staticPrefix = {{3, "sea-area-fill", 1}, {31, "clip-outline", 1}},
.continuousEndExclusive = std::nullopt,
.staticSuffix = {},
};
const auto initialRevision = tracker.observe(topology);
std::swap(topology.staticPrefix[0], topology.staticPrefix[1]);
const auto reorderedRevision = tracker.observe(topology);
EXPECT_GT(reorderedRevision, initialRevision);
topology.staticPrefix[0].layerIndex = 30;
const auto reindexedRevision = tracker.observe(topology);
EXPECT_GT(reindexedRevision, reorderedRevision);
EXPECT_EQ(tracker.observe(topology), reindexedRevision);
}
TEST(RetainedUnderlayRevision, MatchingBuildRevisionAllowsReuseAndMismatchRebuilds) {
RetainedUnderlayCache cache;
EXPECT_EQ(cache.evaluate(key(), 7, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
EXPECT_EQ(cache.getBuildRevision(), 7u);
EXPECT_EQ(cache.evaluate(key(), 7, true), RetainedUnderlayDecision::Reuse);
EXPECT_EQ(cache.evaluate(key(), 8, true, nullptr, "tile-mutation"), RetainedUnderlayDecision::Build);
EXPECT_FALSE(cache.isValid());
EXPECT_EQ(cache.getLastInvalidationReason(), "tile-mutation");
}
TEST(RetainedUnderlayRevision, PlacementAndFadeActivityRejectsStaleCacheUntilStable) {
RetainedUnderlayRevisionTracker tracker;
RetainedUnderlayCache cache;
auto state = revisionState();
auto revision = tracker.observe(state);
EXPECT_EQ(cache.evaluate(key(), revision, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
state.placementStable = false;
revision = tracker.observe(state);
EXPECT_EQ(cache.evaluate(key(), revision, false, "static-placement", tracker.reason().c_str()),
RetainedUnderlayDecision::Fallback);
EXPECT_FALSE(cache.isValid());
state.placementStable = true;
revision = tracker.observe(state);
EXPECT_EQ(cache.evaluate(key(), revision, true, nullptr, tracker.reason().c_str()),
RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
state.staticStable = false;
revision = tracker.observe(state);
EXPECT_EQ(cache.evaluate(key(), revision, false, "static-transition", tracker.reason().c_str()),
RetainedUnderlayDecision::Fallback);
EXPECT_FALSE(cache.isValid());
state.staticStable = true;
revision = tracker.observe(state);
EXPECT_EQ(cache.evaluate(key(), revision, true, nullptr, tracker.reason().c_str()),
RetainedUnderlayDecision::Build);
}
TEST(RetainedUnderlayCache, UnsupportedAndResourceFailureFallBackSafely) {
RetainedUnderlayCache cache;
EXPECT_EQ(cache.evaluate(key(), 1, false, "3d"), RetainedUnderlayDecision::Fallback);
EXPECT_EQ(cache.getLastInvalidationReason(), "3d");
EXPECT_FALSE(cache.isValid());
EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Build);
cache.markBuildFailed("resource-or-compositor");
EXPECT_FALSE(cache.isValid());
EXPECT_EQ(cache.getFallbackCount(), 2u);
}
TEST(RetainedUnderlayCache, RuntimeControlDisablesAndReenablesMapScopedCache) {
auto state = std::make_shared<RetainedUnderlayRuntimeState>();
RetainedUnderlayCache cache;
cache.synchronizeRuntimeState(state);
EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
EXPECT_TRUE(cache.isValid());
EXPECT_TRUE(state->setEnabled(false));
cache.synchronizeRuntimeState(state);
EXPECT_FALSE(cache.isValid());
EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Fallback);
auto disabled = state->snapshot();
EXPECT_FALSE(disabled.enabled);
EXPECT_EQ(disabled.build, 1u);
EXPECT_EQ(disabled.fallback, 1u);
EXPECT_EQ(disabled.invalidation, 2u); // first-frame plus the runtime disable.
EXPECT_EQ(disabled.lastReason, "runtime-disabled");
EXPECT_TRUE(state->setEnabled(true));
cache.synchronizeRuntimeState(state);
EXPECT_EQ(cache.evaluate(key(), 1, true), RetainedUnderlayDecision::Build);
EXPECT_TRUE(state->snapshot().enabled);
}
TEST(ParticleFastPath, ParticleOnlyStableRevisionIsCandidate) {
const auto decision = evaluateParticleFastPath(fastPathInput());
EXPECT_TRUE(decision.candidate);
EXPECT_EQ(decision.reason, ParticleFastPathMissReason::None);
}
TEST(Step114FastPathSafety, PlacementFreeSceneIgnoresControllerStaleOnly) {
EXPECT_TRUE(particleFastPathHasPlacementTransition({}, true)); // Missing evidence stays closed.
const std::vector<StyleLayerDiagnostics> layers{
{0, "background", "background", true, std::nullopt, std::nullopt},
{1, "probe", "circle", true, "probe-source", std::nullopt},
{2, "color", "weather-color", true, "field", std::nullopt},
{3, "particle", "raster-particle", true, "field", std::nullopt}};
auto input = fastPathInput();
input.hasPlacementTransition = particleFastPathHasPlacementTransition(layers, true);
EXPECT_FALSE(input.hasPlacementTransition);
EXPECT_TRUE(evaluateParticleFastPath(input).candidate);
// This narrow change does not override any source/tile/resource/camera gate.
input.contentRevisionMatches = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::ContentMutation);
input.contentRevisionMatches = true;
input.hasPendingChanges = true;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::PendingChanges);
input.hasPendingChanges = false;
input.cameraMatches = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::CameraChanged);
input.cameraMatches = true;
input.unsupportedContinuousLayer = true;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::UnsupportedContinuousLayer);
input.unsupportedContinuousLayer = false;
input.annotationMutation = true;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::AnnotationMutation);
}
TEST(Step114FastPathSafety, HiddenEmptyAndAnnotationSymbolsKeepPlacementProtection) {
for (const bool visible : {false, true}) {
for (const char* id : {"symbol-in-R", "symbol-in-C", "org.maplibre.annotations.points"}) {
const std::vector<StyleLayerDiagnostics> layers{
{0, "background", "background", true, std::nullopt, std::nullopt},
{1, id, "symbol", visible, "symbols", std::nullopt}};
EXPECT_TRUE(particleFastPathHasPlacementTransition(layers, true));
EXPECT_FALSE(particleFastPathHasPlacementTransition(layers, false));
auto input = fastPathInput();
input.hasPlacementTransition = particleFastPathHasPlacementTransition(layers, true);
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::PlacementOrFade);
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, true, 0.0f, false));
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, false, 0.0f, true));
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, false, 1.0f, false));
}
}
}
TEST(Step114FastPathSafety, SourceRevisionRequiresRegistrationAndAsyncCompletionInvalidatesBuild) {
RenderSegmentRevisionBook book;
const RenderSegmentDependencies dependency{{"local-geojson"}, {}, false};
EXPECT_FALSE(book.revisionFor(dependency));
const auto missing = assessRenderSegmentSources(dependency, [](const std::string&)
-> std::optional<RenderSegmentSourceStatus> { return std::nullopt; });
EXPECT_FALSE(missing.ready);
EXPECT_FALSE(missing.stable);
EXPECT_STREQ(missing.reason, "missing-source");
book.advanceSource("local-geojson");
const auto initial = book.revisionFor(dependency);
ASSERT_TRUE(initial);
RetainedUnderlayCache cache;
EXPECT_EQ(cache.evaluate(key(), *initial, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(key(), *initial, true), RetainedUnderlayDecision::Reuse);
book.advanceSource("local-geojson"); // Immutable source data change.
const auto submitted = book.revisionFor(dependency);
ASSERT_TRUE(submitted);
EXPECT_NE(initial, submitted);
EXPECT_EQ(cache.evaluate(key(), *submitted, false, "source-not-loaded"), RetainedUnderlayDecision::Fallback);
EXPECT_FALSE(cache.isValid());
book.advanceSource("local-geojson"); // Models onTileChanged after asynchronous layout.
const auto completed = book.revisionFor(dependency);
ASSERT_TRUE(completed);
EXPECT_NE(submitted, completed);
EXPECT_EQ(cache.evaluate(key(), *completed, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(key(), *completed, true), RetainedUnderlayDecision::Reuse);
book.advanceGlobal(); // Image/glyph/style/context change remains global.
EXPECT_NE(completed, book.revisionFor(dependency));
EXPECT_EQ(cache.evaluate(key(), *book.revisionFor(dependency), true), RetainedUnderlayDecision::Build);
}
TEST(ParticleFastPath, RevisionCameraAndViewportMismatchReject) {
auto input = fastPathInput();
input.revisionMatches = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::RevisionMismatch);
input = fastPathInput();
input.cameraMatches = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::CameraChanged);
input = fastPathInput();
input.viewportMatches = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::ViewportChanged);
}
TEST(ParticleFastPath, NonParticleTransitionAndRepaintReject) {
auto input = fastPathInput();
input.hasNonParticleTransition = true;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::NonParticleTransition);
input = fastPathInput();
input.hasNonParticleRepaint = true;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::NonParticleRepaint);
}
TEST(ParticleFastPath, SettledCurrentPlacementAllowsCandidateAfterPlacementFullFrame) {
// A full frame may have run placement and captured placementChanged=true.
// That previous-frame event is deliberately not a fast-path gate input;
// only the controller's current transition state is authoritative.
auto input = fastPathInput();
input.hasPlacementTransition = false;
EXPECT_TRUE(evaluateParticleFastPath(input).candidate);
}
TEST(ParticleFastPath, ActivePlacementFadeAndFadingTilesReject) {
auto input = fastPathInput();
input.hasPlacementTransition = true;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::PlacementOrFade);
input = fastPathInput();
input.hasFadingTiles = true;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::FadingTiles);
}
TEST(ParticleFastPath, CacheDisabledAndSemanticMutationReject) {
auto input = fastPathInput();
input.retainedEnabled = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::RetainedDisabled);
input = fastPathInput();
input.cacheValid = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::CacheInvalid);
input = fastPathInput();
input.contentRevisionMatches = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::ContentMutation);
input = fastPathInput();
input.updateParametersMatch = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::UpdateParametersChanged);
input = fastPathInput();
input.topologyMatches = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::TopologyChanged);
input = fastPathInput();
input.styleLoaded = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::StyleNotLoaded);
}
TEST(ParticleFastPath, PendingStillAndUnsupportedPathsReject) {
auto input = fastPathInput();
input.hasPendingChanges = true;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::PendingChanges);
input = fastPathInput();
input.stillImageRequest = true;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::StillImageRequest);
input = fastPathInput();
input.supportedScene = false;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::UnsupportedScene);
input = fastPathInput();
input.unsupportedContinuousLayer = true;
EXPECT_EQ(evaluateParticleFastPath(input).reason, ParticleFastPathMissReason::UnsupportedContinuousLayer);
}
TEST(ParticleFastPath, FullFrameCanRestabilizeCandidate) {
auto input = fastPathInput();
input.contentRevisionMatches = false;
EXPECT_FALSE(evaluateParticleFastPath(input).candidate);
input.contentRevisionMatches = true;
input.revisionMatches = true;
input.cacheValid = true;
input.staticStable = true;
EXPECT_TRUE(evaluateParticleFastPath(input).candidate);
}
TEST(ParticleFastPath, PreparedTreeReplacementOwnsLifetime) {
struct TrackedTree {
TrackedTree(int value_, int& destroyed_)
: value(value_), destroyed(destroyed_) {}
~TrackedTree() { ++destroyed; }
int value;
int& destroyed;
};
int destroyed = 0;
ParticleFastPathPreparedTree<TrackedTree> slot;
slot.replace(std::make_unique<TrackedTree>(1, destroyed));
EXPECT_EQ(slot.get()->value, 1);
slot.replace(std::make_unique<TrackedTree>(2, destroyed));
EXPECT_EQ(destroyed, 1);
EXPECT_EQ(slot.get()->value, 2);
slot.reset();
EXPECT_EQ(destroyed, 2);
EXPECT_TRUE(slot.empty());
}
namespace {
RenderSegmentConfig explicitSegments(std::initializer_list<RenderSegmentDefinition> definitions) {
RenderSegmentConfig config;
config.source = RenderSegmentConfigSource::ExplicitPlan;
config.plan.segments.assign(definitions.begin(), definitions.end());
config.revision = 1;
return config;
}
std::vector<std::string> segmentLayerIds() {
return {"background", "water", "weather", "particle", "land", "ais"};
}
} // namespace
TEST(RenderSegmentPlan, SingleRetainedAndSingleContinuousCoverWholeStyle) {
auto retained = resolveRenderSegmentPlan(
explicitSegments({{RenderSegmentStrategy::Retained, std::nullopt}}), segmentLayerIds(), true);
ASSERT_TRUE(retained.valid);
ASSERT_EQ(retained.segments.size(), 1u);
EXPECT_EQ(retained.segments[0].beginIndex, 0);
EXPECT_EQ(retained.segments[0].endExclusive, 6);
EXPECT_TRUE(renderSegmentCoverageComplete(retained));
auto continuous = resolveRenderSegmentPlan(
explicitSegments({{RenderSegmentStrategy::Continuous, std::nullopt}}), segmentLayerIds(), true);
ASSERT_TRUE(continuous.valid);
EXPECT_EQ(continuous.segments[0].strategy, RenderSegmentStrategy::Continuous);
EXPECT_TRUE(renderSegmentCoverageComplete(continuous));
}
TEST(RenderSegmentPlan, PreservesCRRAndRRCWithoutMerging) {
auto crr = resolveRenderSegmentPlan(
explicitSegments({{RenderSegmentStrategy::Continuous, std::nullopt},
{RenderSegmentStrategy::Retained, "weather"},
{RenderSegmentStrategy::Retained, "land"}}),
segmentLayerIds(), true);
ASSERT_TRUE(crr.valid);
ASSERT_EQ(crr.segments.size(), 3u);
EXPECT_EQ(crr.segments[1].endExclusive, crr.segments[2].beginIndex);
EXPECT_EQ(crr.segments[1].strategy, RenderSegmentStrategy::Retained);
EXPECT_EQ(crr.segments[2].strategy, RenderSegmentStrategy::Retained);
auto rrc = resolveRenderSegmentPlan(
explicitSegments({{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Retained, "weather"},
{RenderSegmentStrategy::Continuous, "land"}}),
segmentLayerIds(), true);
ASSERT_TRUE(rrc.valid);
ASSERT_EQ(rrc.segments.size(), 3u);
EXPECT_EQ(rrc.segments[0].strategy, RenderSegmentStrategy::Retained);
EXPECT_EQ(rrc.segments[1].strategy, RenderSegmentStrategy::Retained);
}
TEST(RenderSegmentPlan, ResolvesFiveAlternatingSegmentsWithGlobalIndices) {
auto plan = resolveRenderSegmentPlan(
explicitSegments({{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "water"},
{RenderSegmentStrategy::Retained, "weather"},
{RenderSegmentStrategy::Continuous, "particle"},
{RenderSegmentStrategy::Retained, "land"}}),
segmentLayerIds(), true);
ASSERT_TRUE(plan.valid);
ASSERT_EQ(plan.segments.size(), 5u);
for (std::size_t i = 0; i < plan.segments.size(); ++i) {
EXPECT_EQ(plan.segments[i].segmentIndex, i);
}
EXPECT_EQ(plan.segments[3].beginIndex, 3);
EXPECT_EQ(plan.segments[4].endExclusive, 6);
EXPECT_TRUE(renderSegmentCoverageComplete(plan));
const auto continuousCount = std::count_if(plan.segments.begin(), plan.segments.end(), [](const auto& segment) {
return segment.strategy == RenderSegmentStrategy::Continuous;
});
ASSERT_EQ(continuousCount, 2);
auto fastInput = fastPathInput();
fastInput.unsupportedContinuousLayer = true;
const auto fastDecision = evaluateParticleFastPath(fastInput);
EXPECT_FALSE(fastDecision.candidate);
EXPECT_EQ(fastDecision.reason, ParticleFastPathMissReason::UnsupportedContinuousLayer);
}
TEST(RenderSegmentPlan, RejectsMissingDuplicateReversedAndEmptyDefinitions) {
auto empty = resolveRenderSegmentPlan(explicitSegments({}), segmentLayerIds(), true);
EXPECT_FALSE(empty.valid);
EXPECT_EQ(empty.reason, "empty-segments");
auto missing = resolveRenderSegmentPlan(
explicitSegments({{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "missing"}}),
segmentLayerIds(), true);
EXPECT_FALSE(missing.valid);
EXPECT_EQ(missing.reason, "boundary-layer-missing");
auto duplicate = resolveRenderSegmentPlan(
explicitSegments({{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"},
{RenderSegmentStrategy::Retained, "weather"}}),
segmentLayerIds(), true);
EXPECT_FALSE(duplicate.valid);
EXPECT_EQ(duplicate.reason, "boundary-layer-duplicate");
auto reversed = resolveRenderSegmentPlan(
explicitSegments({{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "land"},
{RenderSegmentStrategy::Retained, "weather"}}),
segmentLayerIds(), true);
EXPECT_FALSE(reversed.valid);
EXPECT_EQ(reversed.reason, "boundary-order-invalid");
}
TEST(RenderSegmentPlan, HiddenBoundaryDoesNotAffectResolution) {
const auto before = resolveRenderSegmentPlan(
explicitSegments({{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"}}),
segmentLayerIds(), true);
const auto after = resolveRenderSegmentPlan(
explicitSegments({{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"}}),
segmentLayerIds(), true);
EXPECT_EQ(before, after);
}
TEST(RenderSegmentPlan, ContinuousRangeCompatibilityMapsToOneTwoOrThreeSegments) {
RenderSegmentConfig config;
config.source = RenderSegmentConfigSource::ContinuousRangeCompat;
config.continuousStartLayerId = "weather";
config.continuousEndLayerId = "particle";
config.revision = 8;
auto rcr = resolveRenderSegmentPlan(config, segmentLayerIds(), true);
ASSERT_TRUE(rcr.valid);
ASSERT_EQ(rcr.segments.size(), 3u);
EXPECT_EQ(rcr.segments[0].strategy, RenderSegmentStrategy::Retained);
EXPECT_EQ(rcr.segments[1].strategy, RenderSegmentStrategy::Continuous);
EXPECT_EQ(rcr.segments[2].strategy, RenderSegmentStrategy::Retained);
EXPECT_EQ(rcr.configSource, "continuous-range-compat");
config.continuousStartLayerId = "background";
config.continuousEndLayerId = "ais";
auto continuousOnly = resolveRenderSegmentPlan(config, segmentLayerIds(), true);
ASSERT_TRUE(continuousOnly.valid);
ASSERT_EQ(continuousOnly.segments.size(), 1u);
EXPECT_EQ(continuousOnly.segments[0].strategy, RenderSegmentStrategy::Continuous);
}
TEST(RenderSegmentPlan, BoundaryDeletionFailsClosedAndRestores) {
const auto config = explicitSegments({{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"}});
auto valid = resolveRenderSegmentPlan(config, segmentLayerIds(), true);
ASSERT_TRUE(valid.valid);
auto missing = resolveRenderSegmentPlan(
config, {"background", "water", "particle", "land", "ais"}, true);
EXPECT_FALSE(missing.valid);
EXPECT_TRUE(missing.segments.empty());
EXPECT_EQ(missing.reason, "boundary-layer-missing");
EXPECT_TRUE(resolveRenderSegmentPlan(config, segmentLayerIds(), true).valid);
}
TEST(RenderSegmentPlan, InsertedLayerNaturallyJoinsContainingSegment) {
const auto config = explicitSegments({{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"},
{RenderSegmentStrategy::Retained, "land"}});
auto plan = resolveRenderSegmentPlan(
config, {"background", "water", "weather", "inserted", "particle", "land", "ais"}, true);
ASSERT_TRUE(plan.valid);
EXPECT_EQ(plan.segments[1].beginIndex, 2);
EXPECT_EQ(plan.segments[1].endExclusive, 5);
EXPECT_EQ(plan.segments[2].beginIndex, 5);
}
TEST(RenderSegmentDiagnostics, VisibilityChangesMetadataButNotTopologyRevision) {
RetainedUnderlayRuntimeState state;
RenderSegmentPlan configured;
configured.segments = {{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"}};
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const auto resolved = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
std::vector<StyleLayerDiagnostics> visible;
for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) {
visible.push_back({static_cast<int32_t>(i), segmentLayerIds()[i], "fill", true, std::nullopt, std::nullopt});
}
state.recordRenderSegmentResolution(resolved, visible);
const auto revision = state.renderSegmentSnapshot().topologyRevision;
visible[2].visible = false;
state.recordRenderSegmentResolution(resolved, visible);
const auto hidden = state.renderSegmentSnapshot();
EXPECT_EQ(hidden.topologyRevision, revision);
EXPECT_FALSE(hidden.styleLayers[2].visible);
EXPECT_EQ(hidden.segments[1].beginIndex, 2);
}
TEST(RenderSegmentDiagnostics, OldCacheReleaseCannotChargeReplacementAtSameIndex) {
RetainedUnderlayRuntimeState state;
RenderSegmentPlan configured;
configured.segments = {{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"}};
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const auto oldPlan = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
state.recordRenderSegmentResolution(oldPlan, {});
const auto oldRevision = state.renderSegmentSnapshot().topologyRevision;
configured.segments = {{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "land"}};
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const auto newPlan = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
state.recordRenderSegmentResolution(newPlan, {});
const auto newRevision = state.renderSegmentSnapshot().topologyRevision;
ASSERT_NE(oldRevision, newRevision);
state.recordSegmentResourceRelease(0, oldRevision);
auto snapshot = state.renderSegmentSnapshot();
ASSERT_TRUE(snapshot.segments[0].cache);
EXPECT_EQ(snapshot.segments[0].cache->resourceRelease, 0u);
state.recordSegmentResourceRelease(0, newRevision);
snapshot = state.renderSegmentSnapshot();
EXPECT_EQ(snapshot.segments[0].cache->resourceRelease, 1u);
}
TEST(RenderSegmentDiagnostics, CacheOffKeepsResolvedTopology) {
RetainedUnderlayRuntimeState state;
RenderSegmentPlan configured;
configured.segments = {{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"}};
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const auto resolved = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
std::vector<StyleLayerDiagnostics> layers;
for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) {
layers.push_back({static_cast<int32_t>(i), segmentLayerIds()[i], "fill", true, std::nullopt, std::nullopt});
}
state.recordRenderSegmentResolution(resolved, std::move(layers));
state.setEnabled(false);
const auto snapshot = state.renderSegmentSnapshot();
EXPECT_FALSE(snapshot.enabled);
EXPECT_TRUE(snapshot.valid);
EXPECT_TRUE(snapshot.coverageComplete);
EXPECT_EQ(snapshot.segmentCount, 2u);
}
TEST(RenderSegmentDiagnostics, InvalidPlanStillReturnsOrderedStyleLayers) {
RetainedUnderlayRuntimeState state;
RenderSegmentPlan configured;
configured.segments = {{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "missing"}};
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const auto resolved = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
ASSERT_FALSE(resolved.valid);
std::vector<StyleLayerDiagnostics> layers;
for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) {
layers.push_back({static_cast<int32_t>(i), segmentLayerIds()[i], "fill", true, std::nullopt, std::nullopt});
}
state.recordRenderSegmentResolution(resolved, layers);
const auto snapshot = state.renderSegmentSnapshot();
EXPECT_FALSE(snapshot.valid);
EXPECT_TRUE(snapshot.fallback);
EXPECT_TRUE(snapshot.segments.empty());
ASSERT_EQ(snapshot.styleLayers.size(), segmentLayerIds().size());
EXPECT_EQ(snapshot.styleLayers.front().layerId, "background");
EXPECT_EQ(snapshot.styleLayers.back().layerId, "ais");
}
TEST(RenderSegmentDiagnostics, SnapshotCoversStyleExactlyAndSerializesContinuousCacheAsNull) {
RetainedUnderlayRuntimeState state;
RenderSegmentPlan configured;
configured.segments = {{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"},
{RenderSegmentStrategy::Retained, "land"}};
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const auto config = state.renderSegmentPlanConfig();
const auto resolved = resolveRenderSegmentPlan(config, segmentLayerIds(), true);
std::vector<StyleLayerDiagnostics> layers;
for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) {
layers.push_back({static_cast<int32_t>(i), segmentLayerIds()[i], "fill", true, std::nullopt, std::nullopt});
}
layers[1].sourceId = "chart";
layers[1].sourceLayer = "water";
state.recordRenderSegmentResolution(resolved, layers);
const auto snapshot = state.renderSegmentSnapshot();
ASSERT_TRUE(snapshot.valid);
ASSERT_TRUE(snapshot.coverageComplete);
ASSERT_EQ(snapshot.styleLayers.size(), 6u);
ASSERT_EQ(snapshot.segments.size(), 3u);
EXPECT_TRUE(snapshot.segments[0].cache.has_value());
EXPECT_FALSE(snapshot.segments[1].cache.has_value());
std::vector<std::string> flattened;
for (const auto& segment : snapshot.segments) {
for (const auto& layer : segment.layers) flattened.push_back(layer.layerId);
}
EXPECT_EQ(flattened, segmentLayerIds());
EXPECT_EQ(snapshot.styleLayers[1].sourceId, std::optional<std::string>{"chart"});
EXPECT_EQ(snapshot.segments[0].layers[1].sourceLayer, std::optional<std::string>{"water"});
const auto json = renderSegmentDiagnosticsToJSON(snapshot);
EXPECT_NE(json.find("\"coverageComplete\":true"), std::string::npos);
EXPECT_NE(json.find("\"cache\":null"), std::string::npos);
}
TEST(RenderSegmentDiagnostics, ReadinessSnapshotMapsSourcesToSegmentsWithoutSideEffects) {
RetainedUnderlayRuntimeState state;
RenderSegmentPlan configured;
configured.segments = {{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"},
{RenderSegmentStrategy::Retained, "land"}};
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const auto resolved = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
std::vector<StyleLayerDiagnostics> layers;
for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) {
layers.push_back({static_cast<int32_t>(i), segmentLayerIds()[i], "fill", true, std::nullopt, std::nullopt});
}
layers[1].sourceId = "chart";
layers[2].sourceId = "dynamic";
layers[3].sourceId = "dynamic";
layers[4].sourceId = "chart";
layers[5].layerType = "symbol";
layers[5].sourceId = "ais";
state.recordRenderSegmentResolution(resolved, layers);
state.recordSegmentBuild(0);
state.recordSegmentCacheState(0, RetainedRangeCacheState::Ready, 41);
state.recordSegmentExecution(0, true, true, true, 17, "reuse", nullptr);
RendererReadinessDiagnostics readiness;
readiness.styleLoaded = true;
readiness.imageManagerLoaded = true;
readiness.renderOrchestratorLoaded = false;
readiness.spriteLoaded = true;
readiness.hasFadingTiles = false;
readiness.contentRevision = 42;
readiness.sources = {
{.sourceId = "chart",
.sourceType = "vector",
.enabled = true,
.isLoaded = true,
.tileBacked = true,
.tileCount = 4,
.incompleteTileCount = 0,
.loadedTileCount = 4},
{.sourceId = "dynamic",
.sourceType = "raster",
.enabled = true,
.isLoaded = false,
.tileBacked = true,
.tileCount = 1,
.incompleteTileCount = 1,
.loadedTileCount = 0,
.lastSourceMutationSequence = 11,
.lastTileChangedSequence = 12,
.lastTileActionSequence = 13,
.lastTileAction = "StartParse",
.lastMutationReason = "tile-mutation"}};
readiness.lastMutation = {.sequence = 12,
.contentRevision = 42,
.eventType = "TileMutation",
.reason = "tile-mutation",
.sourceId = "dynamic",
.affectedSegmentIndices = {},
.dependencyScope = "none"};
state.recordRendererReadiness(readiness);
const auto first = state.renderSegmentSnapshot();
ASSERT_EQ(first.sources.size(), 2u);
EXPECT_TRUE(first.styleLoaded);
EXPECT_TRUE(first.imageManagerLoaded);
EXPECT_FALSE(first.renderOrchestratorLoaded);
EXPECT_EQ(first.contentRevision, 42u);
EXPECT_EQ(first.sources[0].sourceId, "chart");
EXPECT_TRUE(first.sources[0].isLoaded);
EXPECT_EQ(first.sources[0].tileCount, std::optional<uint32_t>{4});
EXPECT_EQ(first.sources[1].sourceId, "dynamic");
EXPECT_FALSE(first.sources[1].isLoaded);
EXPECT_EQ(first.sources[1].incompleteTileCount, std::optional<uint32_t>{1});
ASSERT_EQ(first.segments.size(), 3u);
EXPECT_EQ(first.segments[0].dependentSourceIds, std::vector<std::string>({"chart"}));
EXPECT_EQ(first.segments[1].dependentSourceIds, std::vector<std::string>({"dynamic"}));
EXPECT_EQ(first.segments[2].dependentSourceIds, std::vector<std::string>({"ais", "chart"}));
EXPECT_TRUE(first.segments[0].unknownDependencies.empty());
EXPECT_EQ(first.segments[2].unknownDependencies,
std::vector<std::string>({"symbol/glyph/image/placement"}));
EXPECT_EQ(first.segments[0].dependentSourceIds.size(), 1u);
const auto backgroundLayer = std::find_if(first.segments[0].layers.begin(), first.segments[0].layers.end(),
[](const auto& layer) { return layer.layerId == "background"; });
ASSERT_NE(backgroundLayer, first.segments[0].layers.end());
EXPECT_FALSE(backgroundLayer->sourceId.has_value());
EXPECT_EQ(first.lastMutation.affectedSegmentIndices, std::vector<uint32_t>({1}));
EXPECT_EQ(first.lastMutation.dependencyScope, "source-local-diagnostic");
const auto topologyRevision = first.topologyRevision;
const auto cacheBuild = first.segments[0].cache->build;
state.recordRendererReadiness(readiness);
const auto second = state.renderSegmentSnapshot();
EXPECT_EQ(second.topologyRevision, topologyRevision);
ASSERT_TRUE(second.segments[0].cache);
EXPECT_EQ(second.segments[0].cache->build, cacheBuild);
EXPECT_EQ(second.segments[0].cache->contentRevision, 41u);
EXPECT_EQ(second.segments[0].cache->sourceRevision, 17u);
EXPECT_EQ(second.segments[0].cache->finalDecision, "reuse");
const auto third = state.renderSegmentSnapshot();
EXPECT_EQ(third.topologyRevision, second.topologyRevision);
EXPECT_EQ(third.contentRevision, second.contentRevision);
EXPECT_EQ(third.sources, second.sources);
EXPECT_EQ(third.lastMutation, second.lastMutation);
const auto json = renderSegmentDiagnosticsToJSON(second);
EXPECT_EQ(renderSegmentDiagnosticsToJSON(state.renderSegmentSnapshot()), json);
EXPECT_NE(json.find("\"renderOrchestratorLoaded\":false"), std::string::npos);
EXPECT_NE(json.find("\"incompleteTileCount\":1"), std::string::npos);
EXPECT_NE(json.find("\"dependentSourceIds\":[\"dynamic\"]"), std::string::npos);
EXPECT_NE(json.find("\"affectedSegmentIndices\":[1]"), std::string::npos);
EXPECT_NE(json.find("\"sourceRevision\":17"), std::string::npos);
}
TEST(RenderSegmentLocality, SourceRevisionsAreScopedAndRecreationUsesNewIdentity) {
const std::vector<StyleLayerDiagnostics> layers{
{0, "background", "background", true, std::nullopt, std::nullopt},
{1, "a0", "fill", true, "a", std::nullopt},
{2, "b", "circle", true, "b", std::nullopt},
{3, "a1", "line", true, "a", std::nullopt}};
const auto r0 = renderSegmentDependencies(layers, 0, 2);
const auto r1 = renderSegmentDependencies(layers, 2, 3);
const auto r2 = renderSegmentDependencies(layers, 3, 4);
const auto sourceLess = renderSegmentDependencies(layers, 0, 1);
EXPECT_EQ(r0.sourceIds, std::vector<std::string>({"a"}));
EXPECT_EQ(r2.sourceIds, r0.sourceIds);
EXPECT_TRUE(sourceLess.sourceIds.empty());
RenderSegmentRevisionBook book;
book.advanceSource("a");
book.advanceSource("b");
const auto beforeA = book.revisionFor(r0);
const auto beforeB = book.revisionFor(r1);
const auto beforeA2 = book.revisionFor(r2);
const auto beforeSourceLess = book.revisionFor(sourceLess);
book.advanceSource("a");
EXPECT_NE(book.revisionFor(r0), beforeA);
EXPECT_NE(book.revisionFor(r2), beforeA2);
EXPECT_EQ(book.revisionFor(r1), beforeB);
EXPECT_EQ(book.revisionFor(sourceLess), beforeSourceLess);
const auto beforeRecreate = book.revisionFor(r0);
book.advanceSource("a"); // removal
book.advanceSource("a"); // recreation with the same ID
EXPECT_NE(book.revisionFor(r0), beforeRecreate);
book.advanceGlobal(); // image, glyph, style, context or unknown mutation
EXPECT_NE(book.revisionFor(r1), beforeB);
EXPECT_NE(book.revisionFor(sourceLess), beforeSourceLess);
const auto unknown = renderSegmentDependencies(
{{0, "custom", "custom", true, std::nullopt, std::nullopt}}, 0, 1);
EXPECT_TRUE(unknown.unknown);
EXPECT_FALSE(book.revisionFor(unknown));
const auto symbol = renderSegmentDependencies(
{{0, "label", "symbol", true, "a", std::nullopt}}, 0, 1);
EXPECT_FALSE(symbol.unknown);
EXPECT_EQ(symbol.sourceIds, std::vector<std::string>({"a"}));
EXPECT_EQ(symbol.globalPrerequisites,
std::vector<std::string>({"symbol/glyph/image/placement"}));
}
TEST(RenderSegmentLocality, ReadinessAndFadeFollowOnlyDependencies) {
const RenderSegmentDependencies r0{{"static"}, {}, false};
const RenderSegmentDependencies r1{{"dynamic"}, {}, false};
const auto lookup = [](bool dynamicLoaded, bool dynamicFading) {
return [=](const std::string& id) -> std::optional<RenderSegmentSourceStatus> {
if (id == "static") return RenderSegmentSourceStatus{true, false};
if (id == "dynamic") return RenderSegmentSourceStatus{dynamicLoaded, dynamicFading};
return std::nullopt;
};
};
EXPECT_TRUE(assessRenderSegmentSources(r0, lookup(false, false)).stable);
EXPECT_FALSE(assessRenderSegmentSources(r1, lookup(false, false)).ready);
EXPECT_TRUE(assessRenderSegmentSources(r0, lookup(true, true)).stable);
const auto fading = assessRenderSegmentSources(r1, lookup(true, true));
EXPECT_TRUE(fading.ready);
EXPECT_FALSE(fading.stable);
EXPECT_STREQ(fading.reason, "source-fading");
EXPECT_FALSE(assessRenderSegmentSources({{"missing"}, {}, false}, lookup(true, false)).ready);
EXPECT_FALSE(assessRenderSegmentSources({{}, {}, true}, lookup(true, false)).ready);
}
TEST(Step116Safety, MissingSourceIsNotAnEmptyDependencyEvenWithRevisionTombstone) {
const RenderSegmentDependencies dependencies{{"chart", "tide"}, {"symbol/glyph/image/placement"}, false};
RenderSegmentRevisionBook book;
book.advanceSource("chart");
EXPECT_FALSE(book.revisionFor(dependencies));
bool exists = false;
bool loaded = false;
const auto lookup = [&](const std::string& id) -> std::optional<RenderSegmentSourceStatus> {
if (id == "chart") return RenderSegmentSourceStatus{true, false};
if (id == "tide" && exists) return RenderSegmentSourceStatus{loaded, false};
return std::nullopt;
};
auto assessment = assessRenderSegmentSources(dependencies, lookup);
EXPECT_FALSE(assessment.ready);
EXPECT_STREQ(assessment.reason, "missing-source");
exists = true;
book.advanceSource("tide"); // source addition, before async tile delivery
const auto created = book.revisionFor(dependencies);
ASSERT_TRUE(created);
EXPECT_FALSE(assessRenderSegmentSources(dependencies, lookup).ready);
loaded = true;
book.advanceSource("tide"); // async layout/tile mutation
EXPECT_NE(book.revisionFor(dependencies), created);
EXPECT_TRUE(assessRenderSegmentSources(dependencies, lookup).stable);
exists = false;
book.advanceSource("tide"); // tombstone survives removal; readiness must not
ASSERT_TRUE(book.revisionFor(dependencies));
EXPECT_FALSE(assessRenderSegmentSources(dependencies, lookup).ready);
exists = true;
book.advanceSource("tide"); // same-name restoration receives a new revision
EXPECT_NE(book.revisionFor(dependencies), created);
}
TEST(Step116Safety, MissingSourceAuditDoesNotHidePlacementOrMutateSnapshot) {
RenderSegmentDiagnostics snapshot;
RenderSegmentDiagnosticsEntry segment;
segment.strategy = RenderSegmentStrategy::Retained;
segment.dependentSourceIds = {"navsea_delivery", "tide"};
segment.placementCapable = true;
segment.placementGatePassed = false;
segment.cache.emplace();
segment.cache->lastFallbackReason = "missing-source";
snapshot.segments.push_back(segment);
RenderSourceReadinessDiagnostics source;
source.sourceId = "navsea_delivery";
source.sourceType = "vector";
snapshot.sources.push_back(source);
auto json = renderSegmentDiagnosticsToJSON(snapshot);
EXPECT_NE(json.find("\"missingSourceIds\":[\"tide\"]"), std::string::npos);
EXPECT_NE(json.find("\"placementGatePassed\":false"), std::string::npos);
EXPECT_EQ(renderSegmentDiagnosticsToJSON(snapshot), json);
source.sourceId = "tide";
snapshot.sources.push_back(source);
EXPECT_NE(renderSegmentDiagnosticsToJSON(snapshot).find("\"missingSourceIds\":[]"), std::string::npos);
// Presence alone grants neither a successful Build nor a placement pass.
EXPECT_EQ(snapshot.segments[0].cache->build, 0u);
EXPECT_FALSE(snapshot.segments[0].placementGatePassed);
}
TEST(Step116Safety, SymbolContinuousBranchesAndResourceChangesRemainProtected) {
const std::vector<StyleLayerDiagnostics> layers{
{0, "tide-spots", "symbol", false, "tide", std::nullopt}};
ASSERT_TRUE(renderSegmentHasPlacementCapableLayer(layers, 0, 1));
// Normal Full: recompute => changed; recent => stale/transition.
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, true, 0.0f, false));
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, false, 0.0f, true));
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, true, 1.0f, false)); // transitions disabled
const auto dependencies = renderSegmentDependencies(layers, 0, 1);
RenderSegmentRevisionBook book;
book.advanceSource("tide");
const auto unchanged = book.revisionFor(dependencies);
EXPECT_EQ(book.revisionFor(dependencies), unchanged);
// Test the revision contract, not fabricated glyph/tile events or GPU completion.
for (const auto* event : {"style", "glyph", "image", "context/reload"}) {
SCOPED_TRACE(event);
const auto before = book.revisionFor(dependencies);
book.advanceGlobal();
EXPECT_NE(book.revisionFor(dependencies), before);
}
for (const auto* event : {"source-update", "tile/layout"}) {
SCOPED_TRACE(event);
const auto before = book.revisionFor(dependencies);
book.advanceSource("tide");
EXPECT_NE(book.revisionFor(dependencies), before);
}
}
TEST(Step116Safety, AnnotationMutationAndLayerOrderDisableReloadKeepAllShadowFailures) {
auto input = fastPathInput();
input.cacheValid = false;
input.annotationMutation = true;
input.unsupportedContinuousLayer = true;
input.supportedScene = false;
input.topologyMatches = false; // added/reordered layer
input.retainedEnabled = false; // explicit disable
input.hasPreparedTree = false; // style reload/context replacement
const auto decision = evaluateParticleFastPath(input);
EXPECT_FALSE(decision.candidate);
EXPECT_EQ(decision.reason, ParticleFastPathMissReason::NoPreparedTree);
const auto failures = particleFastGateFailureMask(input);
for (const auto bit : {0, 1, 2, 10, 21, 22, 23}) EXPECT_NE(failures & (1u << bit), 0u);
auto unmodified = fastPathInput();
EXPECT_EQ(particleFastGateFailureMask(unmodified), 0u); // predicate-only; no rendered Fast claim
}
TEST(Step117Safety, RealPlacementControllerHasNoFirstSymbolBuildWindow) {
PlacementController controller;
RetainedUnderlayCache cache;
unsigned recomputed = 0;
unsigned recent = 0;
const auto start = TimePoint{} + Seconds(10);
for (unsigned frame = 0; frame < 120; ++frame) {
const auto now = start + Milliseconds(frame * 30);
const bool changed = !controller.placementIsRecent(now, 0.0f);
if (changed) {
++recomputed;
auto placement = Placement::create(step117PlacementParameters(now), controller.getPlacement());
// Exercise the real Continuous commit even with no buckets. This is
// a controller lifecycle regression, not a Symbol GPU equivalence test.
placement->placeLayers({});
controller.setPlacement(std::move(placement));
EXPECT_FLOAT_EQ(controller.getPlacement()->symbolFadeChange(now), 0.0f);
EXPECT_FALSE(controller.hasTransitions(now));
} else {
++recent;
controller.setPlacementStale();
EXPECT_TRUE(controller.hasTransitions(now));
EXPECT_GT(controller.getPlacement()->symbolFadeChange(now), 0.0f);
}
const bool supported = renderSegmentPlacementGatePassed(
true, changed, controller.getPlacement()->symbolFadeChange(now), controller.hasTransitions(now));
EXPECT_FALSE(supported);
EXPECT_EQ(cache.evaluate(key(), 1, supported, "static-placement"), RetainedUnderlayDecision::Fallback);
EXPECT_FALSE(cache.isValid());
}
EXPECT_GT(recomputed, 0u);
EXPECT_GT(recent, 0u);
EXPECT_EQ(cache.getBuildCount(), 0u);
EXPECT_EQ(cache.getReuseCount(), 0u);
}
TEST(Step117Safety, RealPlacementDisabledOrZeroDurationIsNotAResultCertificate) {
const auto now = TimePoint{} + Seconds(10);
for (const bool enabled : {false, true}) {
SCOPED_TRACE(enabled);
PlacementController controller;
auto placement = Placement::create(step117PlacementParameters(
now, style::TransitionOptions{Milliseconds(0), std::nullopt, enabled}), controller.getPlacement());
placement->placeLayers({});
controller.setPlacement(std::move(placement));
EXPECT_FLOAT_EQ(controller.getPlacement()->symbolFadeChange(now), 1.0f);
EXPECT_FALSE(controller.hasTransitions(now));
EXPECT_EQ(controller.placementIsRecent(now, 0.0f), enabled);
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, true, 1.0f, controller.hasTransitions(now)));
controller.setPlacementStale();
EXPECT_EQ(controller.hasTransitions(now + Seconds(5)), enabled);
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, false,
controller.getPlacement()->symbolFadeChange(now + Seconds(5)), controller.hasTransitions(now + Seconds(5))));
}
}
TEST(Step117Safety, MissingAndSameNameRestorationCannotReuseAnOldCache) {
// Test-only source status; no source is added to the production PBF style.
const RenderSegmentDependencies dependencies{{"step117-source"}, {}, false};
RenderSegmentRevisionBook book;
RetainedUnderlayCache cache;
bool exists = false;
const auto lookup = [&](const std::string&) -> std::optional<RenderSegmentSourceStatus> {
if (exists) return RenderSegmentSourceStatus{true, false};
return std::nullopt;
};
auto missing = assessRenderSegmentSources(dependencies, lookup);
EXPECT_FALSE(book.revisionFor(dependencies));
EXPECT_EQ(cache.evaluate(key(), 0, missing.stable, missing.reason), RetainedUnderlayDecision::Fallback);
EXPECT_EQ(cache.getBuildCount(), 0u);
exists = true;
book.advanceSource("step117-source");
const auto initial = book.revisionFor(dependencies);
ASSERT_TRUE(initial);
EXPECT_EQ(cache.evaluate(key(), *initial, assessRenderSegmentSources(dependencies, lookup).stable),
RetainedUnderlayDecision::Build);
cache.markBuildSucceeded(); // State-machine fixture only; no GPU draw is claimed.
EXPECT_EQ(cache.evaluate(key(), *initial, true), RetainedUnderlayDecision::Reuse);
const auto reuseBefore = cache.getReuseCount();
exists = false;
book.advanceSource("step117-source");
const auto removed = book.revisionFor(dependencies);
ASSERT_TRUE(removed);
missing = assessRenderSegmentSources(dependencies, lookup);
EXPECT_EQ(cache.evaluate(key(), *removed, missing.stable, missing.reason), RetainedUnderlayDecision::Fallback);
EXPECT_FALSE(cache.isValid());
EXPECT_EQ(cache.getBuildRevision(), 0u);
EXPECT_EQ(cache.getReuseCount(), reuseBefore);
exists = true;
book.advanceSource("step117-source");
const auto restored = book.revisionFor(dependencies);
ASSERT_TRUE(restored);
EXPECT_NE(restored, initial);
EXPECT_NE(restored, removed);
EXPECT_EQ(cache.evaluate(key(), *restored, true), RetainedUnderlayDecision::Build);
EXPECT_EQ(cache.getReuseCount(), reuseBefore);
}
TEST(Step117Safety, CameraStyleAnnotationAndContextRequireRebuildBeforeReuse) {
using Mutation = void (*)(RetainedUnderlayRevisionState&);
const std::array<Mutation, 5> mutations{{
[](auto& state) { state.cameraMatrix[12] += 1.0; },
[](auto& state) { state.viewport = {800, 600}; },
[](auto& state) { ++state.contentRevision; }, // style/glyph/image/context epoch
[](auto& state) { ++state.topologyRevision; }, // annotation/layer group
[](auto& state) { ++state.rangeConfigRevision; },
}};
for (std::size_t i = 0; i < mutations.size(); ++i) {
SCOPED_TRACE(i);
RetainedUnderlayRevisionTracker tracker;
RetainedUnderlayCache cache;
auto state = revisionState();
const auto cacheKey = [&] { return RetainedUnderlayKey{state.viewport, 0, *state.boundary, state.cameraMatrix}; };
const auto oldRevision = tracker.observe(state);
ASSERT_EQ(cache.evaluate(cacheKey(), oldRevision, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded(); // State-machine fixture only.
mutations[i](state);
const auto newRevision = tracker.observe(state);
EXPECT_NE(newRevision, oldRevision);
EXPECT_EQ(cache.evaluate(cacheKey(), newRevision, true), RetainedUnderlayDecision::Build);
EXPECT_FALSE(cache.isValid());
EXPECT_EQ(cache.getReuseCount(), 0u);
auto input = fastPathInput();
input.cacheValid = cache.isValid();
input.revisionMatches = cache.getBuildRevision() == newRevision;
EXPECT_FALSE(evaluateParticleFastPath(input).candidate);
EXPECT_NE(particleFastGateFailureMask(input) & (1u << 2), 0u);
EXPECT_NE(particleFastGateFailureMask(input) & (1u << 3), 0u);
}
}
TEST(RenderSegmentLocality, MixedExecutionDrawsEachLiveRangeOnceInStyleOrder) {
ResolvedRenderSegmentPlan plan;
plan.valid = true;
plan.layerCount = 5;
plan.segments = {{0, RenderSegmentStrategy::Retained, std::nullopt, 0, 1},
{1, RenderSegmentStrategy::Continuous, std::nullopt, 1, 3},
{2, RenderSegmentStrategy::Retained, std::nullopt, 3, 5}};
std::vector<std::string> events;
visitRenderSegmentExecution(plan,
[](uint32_t index) { return index == 0; },
[&](uint32_t index) { events.push_back("composite:" + std::to_string(index)); },
[&](int32_t begin, int32_t end) {
for (auto i = begin; i < end; ++i) events.push_back("live:" + std::to_string(i));
});
EXPECT_EQ(events, (std::vector<std::string>{"composite:0", "live:1", "live:2", "live:3", "live:4"}));
events.clear();
plan.valid = false;
visitRenderSegmentExecution(plan, [](uint32_t) { return true; },
[&](uint32_t) { ADD_FAILURE() << "invalid plan composite"; },
[&](int32_t begin, int32_t end) {
for (auto i = begin; i < end; ++i) events.push_back("live:" + std::to_string(i));
});
EXPECT_EQ(events.size(), 5u);
}
TEST(RenderSegmentLocality, FailedBuildCannotReuseOldTexture) {
RetainedUnderlayCache cache;
const auto viewportKey = key();
EXPECT_EQ(cache.evaluate(viewportKey, 7, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(viewportKey, 7, true), RetainedUnderlayDecision::Reuse);
EXPECT_EQ(cache.evaluate(viewportKey, 8, true), RetainedUnderlayDecision::Build);
cache.markBuildFailed("resource-or-compositor");
EXPECT_FALSE(cache.isValid());
EXPECT_EQ(cache.evaluate(viewportKey, 8, true), RetainedUnderlayDecision::Build);
}
TEST(RenderSegmentPlacementFree, StructuralCapabilityAndConservativeGate) {
const std::vector<StyleLayerDiagnostics> layers{
{0, "background", "background", true, std::nullopt, std::nullopt},
{1, "particle", "raster-particle", true, "field", std::nullopt},
{2, "label", "symbol", true, "chart", std::nullopt}};
EXPECT_FALSE(renderSegmentHasPlacementCapableLayer(layers, 0, 1));
EXPECT_TRUE(renderSegmentHasPlacementCapableLayer(layers, 2, 3));
// The symbol's current placementData may be empty; style structure still wins.
EXPECT_TRUE(renderSegmentHasPlacementCapableLayer(layers, 2, 3));
EXPECT_TRUE(renderSegmentHasPlacementCapableLayer(layers, -1, 1));
EXPECT_TRUE(renderSegmentPlacementGatePassed(false, true, 1.0f, true));
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, true, 0.0f, false));
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, false, 1.0f, false));
EXPECT_FALSE(renderSegmentPlacementGatePassed(true, false, 0.0f, true));
EXPECT_TRUE(renderSegmentPlacementGatePassed(true, false, 0.0f, false));
}
TEST(RenderSegmentPlacementFree, MixedExecutionKeepsFreeRangesInStyleOrder) {
ResolvedRenderSegmentPlan plan;
plan.valid = true;
plan.layerCount = 5;
plan.segments = {{0, RenderSegmentStrategy::Retained, std::nullopt, 0, 1},
{1, RenderSegmentStrategy::Continuous, std::nullopt, 1, 2},
{2, RenderSegmentStrategy::Retained, std::nullopt, 2, 3},
{3, RenderSegmentStrategy::Continuous, std::nullopt, 3, 4},
{4, RenderSegmentStrategy::Retained, std::nullopt, 4, 5}};
RetainedUnderlayCache r0, r1, r2;
const auto k0 = key({640, 480}, 1);
const auto k1 = key({640, 480}, 3);
const auto k2 = key({640, 480}, 5);
EXPECT_EQ(r0.evaluate(k0, 1, true), RetainedUnderlayDecision::Build);
EXPECT_EQ(r1.evaluate(k1, 1, true), RetainedUnderlayDecision::Build);
EXPECT_EQ(r2.evaluate(k2, 1, true), RetainedUnderlayDecision::Build);
r0.markBuildSucceeded(); r1.markBuildSucceeded(); r2.markBuildSucceeded();
const bool freeGate = renderSegmentPlacementGatePassed(false, true, 1.0f, true);
const bool symbolGate = renderSegmentPlacementGatePassed(true, true, 1.0f, true);
EXPECT_EQ(r0.evaluate(k0, 1, freeGate), RetainedUnderlayDecision::Reuse);
EXPECT_EQ(r1.evaluate(k1, 1, symbolGate, "static-placement"), RetainedUnderlayDecision::Fallback);
EXPECT_EQ(r1.getLastInvalidationReason(), "static-placement");
EXPECT_EQ(r2.evaluate(k2, 1, freeGate), RetainedUnderlayDecision::Reuse);
EXPECT_NE(r0.getLastInvalidationReason(), "static-placement");
EXPECT_NE(r2.getLastInvalidationReason(), "static-placement");
std::vector<std::string> events;
visitRenderSegmentExecution(plan,
[](uint32_t index) { return index == 0 || index == 4; },
[&](uint32_t index) { events.push_back("composite:" + std::to_string(index)); },
[&](int32_t begin, int32_t end) {
for (auto i = begin; i < end; ++i) events.push_back("live:" + std::to_string(i));
});
EXPECT_EQ(events, (std::vector<std::string>{"composite:0", "live:1", "live:2",
"live:3", "composite:4"}));
// Source-local mutation/fade in R1 leaves both independent retained caches reusable.
EXPECT_EQ(r0.evaluate(k0, 1, freeGate), RetainedUnderlayDecision::Reuse);
EXPECT_EQ(r2.evaluate(k2, 1, freeGate), RetainedUnderlayDecision::Reuse);
}
TEST(RenderSegmentPlacementFree, RealGlobalGatesStillForceGlobalLive) {
RenderSegmentGlobalLiveGates gates{true, false, false, true, true, true, false};
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "none");
gates.has3D = true;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "3d");
gates.has3D = false; gates.debugOptions = true;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "debug-options");
gates.debugOptions = false; gates.continuousMode = false;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "map-mode");
gates.continuousMode = true; gates.spriteLoaded = false;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "sprite-not-loaded");
gates.spriteLoaded = true; gates.globalResourcesReady = false;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "global-resource-not-ready");
gates.globalResourcesReady = true; gates.globalLightTransition = true;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "global-light-transition");
gates.globalLightTransition = false; gates.retainedMode = false;
EXPECT_STREQ(renderSegmentGlobalLiveFallbackReason(gates), "no-boundary");
}
TEST(RenderSegmentPlacementFree, TopologyAndCameraChangesCannotReuseOldRetainedKey) {
RetainedUnderlayTopologyTracker tracker;
RetainedUnderlayTopologySnapshot topology;
topology.staticPrefix = {{0, "RETAINED:1", 0}, {1, "CONTINUOUS:2", 1},
{2, "RETAINED:3", 0}, {3, "CONTINUOUS:4", 1}};
const auto firstRevision = tracker.observe(topology);
RetainedUnderlayCache cache;
const auto firstKey = key({640, 480}, 3);
EXPECT_EQ(cache.evaluate(firstKey, firstRevision, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(firstKey, firstRevision, true), RetainedUnderlayDecision::Reuse);
topology.staticPrefix.resize(2); // R-C-R-C becomes R-C.
EXPECT_GT(tracker.observe(topology), firstRevision);
EXPECT_EQ(cache.evaluate(firstKey, tracker.current(), true, nullptr, "layer-group-mutation"),
RetainedUnderlayDecision::Build);
EXPECT_FALSE(cache.isValid());
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(key({640, 480}, 4), tracker.current(), true), RetainedUnderlayDecision::Build);
EXPECT_FALSE(cache.isValid());
}
TEST(RenderSegmentPlacementFree, DiagnosticsRecordActualMainBufferAndDoNotMutateOnRead) {
RetainedUnderlayRuntimeState state;
RenderSegmentPlan configured;
configured.segments = {{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "particle"},
{RenderSegmentStrategy::Retained, "label"}};
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const std::vector<std::string> ids{"background", "particle", "label"};
const auto plan = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), ids, true);
ASSERT_TRUE(plan.valid);
state.recordRenderSegmentResolution(plan, {
{0, "background", "background", true, std::nullopt, std::nullopt},
{1, "particle", "raster-particle", true, "field", std::nullopt},
{2, "label", "symbol", true, "chart", std::nullopt}});
auto snapshot = state.renderSegmentSnapshot();
ASSERT_EQ(snapshot.segments.size(), 3u);
EXPECT_FALSE(snapshot.segments[0].placementCapable);
EXPECT_TRUE(snapshot.segments[2].placementCapable);
state.recordSegmentExecution(0, true, true, true, 7, "reuse", nullptr);
state.recordMainBufferExecution(snapshot.topologyRevision, true, 1.0f, true, true,
"global-live", "global-light-transition",
{true, true, false}, {"live", "live", "live"});
snapshot = state.renderSegmentSnapshot();
ASSERT_TRUE(snapshot.segments[0].cache);
EXPECT_EQ(snapshot.segments[0].cache->finalDecision, "reuse");
EXPECT_EQ(snapshot.segments[0].actualExecution, "live");
EXPECT_EQ(snapshot.mainBufferExecutionMode, "global-live");
EXPECT_EQ(snapshot.globalLiveFallbackReason, "global-light-transition");
EXPECT_TRUE(snapshot.placementChanged);
EXPECT_FLOAT_EQ(snapshot.symbolFadeChange, 1.0f);
EXPECT_TRUE(snapshot.placementTransitionActive);
EXPECT_TRUE(snapshot.crossSourceCollisions);
state.recordMainBufferExecution(snapshot.topologyRevision, true, 1.0f, true, true,
"mixed", "none", {true, true, false},
{"composite", "live", "live"});
const auto mixed = state.renderSegmentSnapshot();
EXPECT_EQ(mixed.segments[0].actualExecution, "composite");
EXPECT_EQ(mixed.segments[2].actualExecution, "live");
const auto json = renderSegmentDiagnosticsToJSON(mixed);
EXPECT_EQ(renderSegmentDiagnosticsToJSON(state.renderSegmentSnapshot()), json);
EXPECT_NE(json.find("\"placementCapable\":true"), std::string::npos);
EXPECT_NE(json.find("\"placementGatePassed\":false"), std::string::npos);
EXPECT_NE(json.find("\"actualExecution\":\"composite\""), std::string::npos);
EXPECT_NE(json.find("\"mainBufferExecutionMode\":\"mixed\""), std::string::npos);
EXPECT_EQ(state.renderSegmentSnapshot().topologyRevision, mixed.topologyRevision);
}
TEST(RenderSegmentPlan, StrictJSONRejectsUnknownFieldsStrategyAndInvalidFirstBoundary) {
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED","unexpected":true}]})"));
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"DYNAMIC"}]})"));
auto parsed = parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED","startLayerId":"water"}]})");
ASSERT_TRUE(parsed);
RetainedUnderlayRuntimeState state;
EXPECT_FALSE(state.setRenderSegmentPlan(std::move(*parsed)));
}
TEST(RenderSegmentPlan, StrictJSONRejectsNulDuplicateKeysAndUnknownSchema) {
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":2,"segments":[{"strategy":"RETAINED"}]})"));
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"schemaVersion":1,"segments":[{"strategy":"RETAINED"}]})"));
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED","strategy":"CONTINUOUS"}]})"));
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED\u0000suffix"}]})"));
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED"},{"strategy":"CONTINUOUS","startLayerId":"water\u0000suffix"}]})"));
std::string rawNul = R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED"}]})";
rawNul.insert(rawNul.end() - 2, '\0');
rawNul.insert(rawNul.end() - 2, 'x');
EXPECT_FALSE(parseRenderSegmentPlanJSON(rawNul));
}
TEST(RenderSegmentPlan, InvalidJSONClearsRendererPlanAndValidPlanRecovers) {
constexpr auto validJSON =
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED"},{"strategy":"CONTINUOUS","startLayerId":"water"}]})";
RetainedUnderlayRuntimeState state;
auto valid = renderSegmentPlanFromJSONOrInvalid(validJSON);
ASSERT_TRUE(state.setRenderSegmentPlan(std::move(valid)));
auto resolved = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
ASSERT_TRUE(resolved.valid);
auto invalid = renderSegmentPlanFromJSONOrInvalid(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED","strategy":"CONTINUOUS"}]})");
EXPECT_FALSE(state.setRenderSegmentPlan(std::move(invalid)));
auto cleared = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
EXPECT_FALSE(cleared.valid);
EXPECT_EQ(cleared.reason, "unsupported-schema-version");
auto missingBoundary = renderSegmentPlanFromJSONOrInvalid(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED"},{"strategy":"CONTINUOUS"}]})");
EXPECT_FALSE(state.setRenderSegmentPlan(std::move(missingBoundary)));
EXPECT_FALSE(resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true).valid);
auto reversedBoundary = renderSegmentPlanFromJSONOrInvalid(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED"},{"strategy":"CONTINUOUS","startLayerId":"water"},{"strategy":"RETAINED","startLayerId":"background"}]})");
ASSERT_TRUE(state.setRenderSegmentPlan(std::move(reversedBoundary)));
auto reversed = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
EXPECT_FALSE(reversed.valid);
EXPECT_EQ(reversed.reason, "boundary-order-invalid");
auto recovered = renderSegmentPlanFromJSONOrInvalid(validJSON);
ASSERT_TRUE(state.setRenderSegmentPlan(std::move(recovered)));
EXPECT_TRUE(resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true).valid);
}
TEST(FastGateTrace, EveryGateBitMatchesProductionDecisionOrder) {
using Field = bool ParticleFastPathGateInput::*;
const std::array<Field, 24> fields{{
&ParticleFastPathGateInput::hasPreparedTree, &ParticleFastPathGateInput::retainedEnabled,
&ParticleFastPathGateInput::cacheValid, &ParticleFastPathGateInput::revisionMatches,
&ParticleFastPathGateInput::contentRevisionMatches, &ParticleFastPathGateInput::viewportMatches,
&ParticleFastPathGateInput::cameraMatches, &ParticleFastPathGateInput::updateParametersMatch,
&ParticleFastPathGateInput::styleLoaded, &ParticleFastPathGateInput::boundaryMatches,
&ParticleFastPathGateInput::topologyMatches, &ParticleFastPathGateInput::staticStable,
&ParticleFastPathGateInput::hasPlacementTransition, &ParticleFastPathGateInput::hasPendingChanges,
&ParticleFastPathGateInput::hasNonParticleTransition, &ParticleFastPathGateInput::hasNonParticleRepaint,
&ParticleFastPathGateInput::hasFadingTiles, &ParticleFastPathGateInput::particleNeedsRepaint,
&ParticleFastPathGateInput::stillImageRequest, &ParticleFastPathGateInput::continuousMode,
&ParticleFastPathGateInput::debugDisabled, &ParticleFastPathGateInput::unsupportedContinuousLayer,
&ParticleFastPathGateInput::supportedScene, &ParticleFastPathGateInput::annotationMutation,
}};
EXPECT_EQ(particleFastGateFailureMask(fastPathInput()), 0u);
auto allFail = fastPathInput();
for (std::size_t i = 0; i < fields.size(); ++i) {
auto input = fastPathInput();
input.*fields[i] = !(input.*fields[i]);
allFail.*fields[i] = input.*fields[i];
EXPECT_EQ(particleFastGateFailureMask(input), uint32_t{1} << i);
EXPECT_EQ(evaluateParticleFastPath(input).reason, particleFastGateReasons[i]);
EXPECT_FALSE(evaluateParticleFastPath(input).candidate);
}
EXPECT_EQ(particleFastGateFailureMask(allFail), 0x00ffffffu);
}
TEST(FastGateTrace, CacheMissRetainsLaterFailuresAndBoundedOrderedWindow) {
RetainedUnderlayRuntimeState state;
state.resetFastGateTrace();
auto input = fastPathInput();
input.cacheValid = false;
input.unsupportedContinuousLayer = true;
input.supportedScene = false;
const auto decision = evaluateParticleFastPath(input);
for (uint64_t i = 0; i < 137; ++i) {
state.recordFastGateAttempt(i, static_cast<int64_t>(i * 33333333), input, decision, true);
if (i == 0) state.completeFastGateAttempt("no-render-tree");
else {
state.recordFullCpuFrame();
state.completeFastGateAttempt("full-rendered");
}
}
EXPECT_FALSE(state.renderSegmentSnapshot().fastGateTrace); // No ring copy on internal render hot path.
const auto snapshot = state.renderSegmentSnapshot(true);
ASSERT_TRUE(snapshot.fastGateTrace);
const auto& t = *snapshot.fastGateTrace;
EXPECT_EQ(t.attempts, 137u);
EXPECT_EQ(t.overwritten, 17u);
EXPECT_EQ(t.decisionMismatch, 0u);
EXPECT_EQ(t.fullRendered, 136u);
EXPECT_EQ(t.noRender, 1u);
ASSERT_EQ(t.frames.size(), 120u);
EXPECT_EQ(t.frames.front().sequence, 18u);
EXPECT_EQ(t.frames.back().sequence, 137u);
EXPECT_EQ(t.frames.back().firstFailure, "cache-invalid");
EXPECT_EQ(t.frames.back().failMask, (1u << 2) | (1u << 21) | (1u << 22));
EXPECT_EQ(t.frames.back().pre.fullCpuFrame, 135u);
EXPECT_EQ(t.frames.back().post.fullCpuFrame, 136u);
EXPECT_EQ(t.failureCounts[2], 137u);
EXPECT_EQ(t.failureCounts[21], 137u);
EXPECT_EQ(t.failureCounts[22], 137u);
EXPECT_EQ(t.firstFailureCounts[2], 137u);
const auto json = renderSegmentDiagnosticsToJSON(snapshot);
rapidjson::Document document;
ASSERT_FALSE(document.Parse(json.c_str()).HasParseError());
const auto& last = document["fastGateTrace"]["frames"][119];
ASSERT_EQ(last["gateStatus"].Size(), 24u);
EXPECT_STREQ(last["gateStatus"][2].GetString(), "FAIL");
EXPECT_STREQ(last["gateStatus"][21].GetString(), "FAIL");
EXPECT_STREQ(last["gateStatus"][22].GetString(), "FAIL");
EXPECT_STREQ(last["pre"]["phase"].GetString(), "pre-fast");
EXPECT_STREQ(last["post"]["phase"].GetString(), "post-render");
state.resetFastGateTrace();
const auto reset = state.renderSegmentSnapshot(true);
ASSERT_TRUE(reset.fastGateTrace);
EXPECT_EQ(reset.fastGateTrace->lifecycle, t.lifecycle + 1);
EXPECT_TRUE(reset.fastGateTrace->frames.empty());
EXPECT_EQ(reset.fastGateTrace->attempts, 0u);
EXPECT_EQ(reset.fastGateTrace->failureCounts[2], 0u);
}
TEST(FastGateTrace, PendingAttemptAndInconsistentShadowAreExplicit) {
RetainedUnderlayRuntimeState state;
auto input = fastPathInput();
state.recordFastGateAttempt(8, 123, input, {false, ParticleFastPathMissReason::CacheInvalid}, false);
auto snapshot = state.renderSegmentSnapshot(true);
ASSERT_TRUE(snapshot.fastGateTrace);
ASSERT_EQ(snapshot.fastGateTrace->frames.size(), 1u);
EXPECT_EQ(snapshot.fastGateTrace->decisionMismatch, 1u);
EXPECT_FALSE(snapshot.fastGateTrace->frames[0].completed);
EXPECT_FALSE(snapshot.fastGateTrace->frames[0].placementTransitionsEnabled);
const auto json = renderSegmentDiagnosticsToJSON(snapshot);
rapidjson::Document document;
ASSERT_FALSE(document.Parse(json.c_str()).HasParseError());
EXPECT_TRUE(document["fastGateTrace"]["frames"][rapidjson::SizeType{0}]["post"].IsNull());
state.completeFastGateAttempt("fast-rendered");
state.completeFastGateAttempt("fast-rendered"); // Completion must be idempotent.
snapshot = state.renderSegmentSnapshot(true);
EXPECT_EQ(snapshot.fastGateTrace->fastRendered, 1u);
}
TEST(FastGateTrace, RetainedSamplesHaveDistinctPhasesAndExplicitLimit) {
RetainedUnderlayRuntimeState state;
RenderSegmentPlan configured;
std::vector<std::string> ids;
std::vector<StyleLayerDiagnostics> layers;
for (int32_t i = 0; i < 18; ++i) {
ids.push_back("r-" + std::to_string(i));
configured.segments.push_back({RenderSegmentStrategy::Retained,
i ? std::optional<std::string>(ids.back()) : std::nullopt});
layers.push_back({i, ids.back(), "background", true, std::nullopt, std::nullopt});
}
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const auto resolved = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), ids, true);
ASSERT_TRUE(resolved.valid);
state.recordRenderSegmentResolution(resolved, layers);
const auto input = fastPathInput();
state.recordFastGateAttempt(0, 0, input, evaluateParticleFastPath(input), true);
state.recordSegmentBuild(0);
state.recordSegmentExecution(0, true, true, true, 7, "build", nullptr);
state.completeFastGateAttempt("full-rendered");
const auto snapshot = state.renderSegmentSnapshot(true);
const auto& frame = snapshot.fastGateTrace->frames.front();
ASSERT_EQ(frame.pre.retained.size(), 16u);
ASSERT_EQ(frame.post.retained.size(), 16u);
EXPECT_TRUE(frame.pre.retainedSamplesTruncated);
EXPECT_TRUE(frame.post.retainedSamplesTruncated);
EXPECT_EQ(frame.pre.retained[0].cache.build, 0u);
EXPECT_EQ(frame.post.retained[0].cache.build, 1u);
EXPECT_EQ(frame.post.retained[0].cache.sourceRevision, 7u);
}