#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 step117PlacementParameters(TimePoint now, style::TransitionOptions transitions = {}) { TransformState state; state.setSize({640, 480}); return std::make_shared(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>>(), .sources = makeMutable>>(), .layers = makeMutable>>(), .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(opacity * 2 + 1); const float packedOut = static_cast(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::infinity())); EXPECT_FALSE(symbolOpacityIsSettled(1, std::numeric_limits::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 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(); const auto& attribute = attributes->set(shaders::idSymbolFadeOpacityAttribute); ASSERT_TRUE(attribute); auto opacity = std::make_shared(); 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(1), newAtlas = std::make_shared(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 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 indices{0, 1, 3, 5, 8}; std::vector beforeForward; std::vector fromForward; std::vector beforeReverse; std::vector 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{0, 1, 2})); EXPECT_EQ(fromForward, (std::vector{3, 4})); EXPECT_EQ(beforeReverse, (std::vector{2, 1, 0})); EXPECT_EQ(fromReverse, (std::vector{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 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(layers.size()))); } TEST(ContinuousLayerRange, RejectsMissingAndReversedEndpointsWithoutStaleIndices) { const std::vector 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 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(key()) : std::nullopt, 1, true); const auto topDecision = top.evaluate(topPresent ? std::optional(key()) : std::nullopt, 1, true); return static_cast(bottomDecision == RetainedUnderlayDecision::Build) + static_cast(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(); 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 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 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 { 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 slot; slot.replace(std::make_unique(1, destroyed)); EXPECT_EQ(slot.get()->value, 1); slot.replace(std::make_unique(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 definitions) { RenderSegmentConfig config; config.source = RenderSegmentConfigSource::ExplicitPlan; config.plan.segments.assign(definitions.begin(), definitions.end()); config.revision = 1; return config; } std::vector 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 visible; for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) { visible.push_back({static_cast(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 layers; for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) { layers.push_back({static_cast(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 layers; for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) { layers.push_back({static_cast(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 layers; for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) { layers.push_back({static_cast(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 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{"chart"}); EXPECT_EQ(snapshot.segments[0].layers[1].sourceLayer, std::optional{"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 layers; for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) { layers.push_back({static_cast(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{4}); EXPECT_EQ(first.sources[1].sourceId, "dynamic"); EXPECT_FALSE(first.sources[1].isLoaded); EXPECT_EQ(first.sources[1].incompleteTileCount, std::optional{1}); ASSERT_EQ(first.segments.size(), 3u); EXPECT_EQ(first.segments[0].dependentSourceIds, std::vector({"chart"})); EXPECT_EQ(first.segments[1].dependentSourceIds, std::vector({"dynamic"})); EXPECT_EQ(first.segments[2].dependentSourceIds, std::vector({"ais", "chart"})); EXPECT_TRUE(first.segments[0].unknownDependencies.empty()); EXPECT_EQ(first.segments[2].unknownDependencies, std::vector({"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({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 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({"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({"a"})); EXPECT_EQ(symbol.globalPrerequisites, std::vector({"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 { 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 { 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 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 { 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 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 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{"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 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 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{"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 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 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(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 ids; std::vector 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(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); }