Added missing tests for new class + refactoring of geofencing calculation logic

This commit is contained in:
dshvaika 2025-08-28 12:01:38 +03:00
parent dd53892df2
commit 87a27e95ef
3 changed files with 150 additions and 57 deletions

View File

@ -44,7 +44,6 @@ import org.thingsboard.script.api.tbel.TbelInvokeService;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.EventInfo;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.event.EventType;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@ -279,17 +278,15 @@ public class CalculatedFieldController extends BaseController {
}
private void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException {
if (calculatedFieldConfig instanceof ArgumentsBasedCalculatedFieldConfiguration config) {
List<EntityId> referencedEntityIds = config.getReferencedEntities();
for (EntityId referencedEntityId : referencedEntityIds) {
EntityType entityType = referencedEntityId.getEntityType();
switch (entityType) {
case TENANT -> {
return;
}
case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ);
default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities.");
List<EntityId> referencedEntityIds = calculatedFieldConfig.getReferencedEntities();
for (EntityId referencedEntityId : referencedEntityIds) {
EntityType entityType = referencedEntityId.getEntityType();
switch (entityType) {
case TENANT -> {
return;
}
case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ);
default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities.");
}
}
}

View File

@ -21,6 +21,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.geo.Coordinates;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@ -39,12 +40,13 @@ import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingPresenceStatus.INSIDE;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingPresenceStatus.OUTSIDE;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
@Data
@Slf4j
@EqualsAndHashCode(callSuper = true)
public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
@ -116,18 +118,8 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
double longitude = (double) arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY).getValue();
Coordinates entityCoordinates = new Coordinates(latitude, longitude);
var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
// TODO: refactor
return calculate(entityId, ctx, entityCoordinates, configuration);
}
private ListenableFuture<CalculatedFieldResult> calculate(
EntityId entityId,
CalculatedFieldCtx ctx,
Coordinates entityCoordinates,
GeofencingCalculatedFieldConfiguration configuration) {
Map<String, ZoneGroupConfiguration> zoneGroups = configuration
var geofencingCfg = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
Map<String, ZoneGroupConfiguration> zoneGroups = geofencingCfg
.getZoneGroups()
.stream()
.collect(Collectors.toMap(ZoneGroupConfiguration::getName, Function.identity()));
@ -136,41 +128,40 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
List<ListenableFuture<Boolean>> relationFutures = new ArrayList<>();
getGeofencingArguments().forEach((argumentKey, argumentEntry) -> {
ZoneGroupConfiguration zoneGroupConfiguration = zoneGroups.get(argumentKey);
if (zoneGroupConfiguration.isCreateRelationsWithMatchedZones()) {
List<GeofencingEvalResult> zoneResults = new ArrayList<>();
argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> {
GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates);
zoneResults.add(eval);
ZoneGroupConfiguration zoneGroupCfg = zoneGroups.get(argumentKey);
if (zoneGroupCfg == null) {
log.error("[{}][{}] Zone group config is missing for the {}", entityId, ctx.getCalculatedField().getId(), argumentKey);
return;
}
boolean createRelationsWithMatchedZones = zoneGroupCfg.isCreateRelationsWithMatchedZones();
List<GeofencingEvalResult> zoneResults = new ArrayList<>(argumentEntry.getZoneStates().size());
argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> {
GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates);
zoneResults.add(eval);
if (createRelationsWithMatchedZones) {
GeofencingTransitionEvent transitionEvent = eval.transition();
if (transitionEvent == null) {
return;
}
EntityRelation relation = toRelation(zoneId, entityId, zoneGroupConfiguration);
EntityRelation relation = switch (zoneGroupCfg.getDirection()) {
case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType());
case FROM -> new EntityRelation(entityId, zoneId, zoneGroupCfg.getRelationType());
};
ListenableFuture<Boolean> f = switch (transitionEvent) {
case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation);
case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation);
};
relationFutures.add(f);
});
updateResultNode(argumentKey, zoneResults, zoneGroupConfiguration.getReportStrategy(), resultNode);
} else {
List<GeofencingEvalResult> zoneResults = argumentEntry.getZoneStates().values().stream()
.map(zs -> zs.evaluate(entityCoordinates))
.toList();
updateResultNode(argumentKey, zoneResults, zoneGroupConfiguration.getReportStrategy(), resultNode);
}
}
});
updateResultNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), resultNode);
});
var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode);
if (relationFutures.isEmpty()) {
return Futures.immediateFuture(result);
}
return Futures.whenAllComplete(relationFutures)
.call(() -> new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode),
MoreExecutors.directExecutor());
return Futures.whenAllComplete(relationFutures).call(() -> result, MoreExecutors.directExecutor());
}
private Map<String, GeofencingArgumentEntry> getGeofencingArguments() {
@ -194,12 +185,6 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
}
}
private void addTransitionEventIfExists(ObjectNode resultNode, GeofencingEvalResult aggregationResult, String eventKey) {
if (aggregationResult.transition() != null) {
resultNode.put(eventKey, aggregationResult.transition().name());
}
}
private GeofencingEvalResult aggregateZoneGroup(List<GeofencingEvalResult> zoneResults) {
boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status()));
boolean prevInside = zoneResults.stream()
@ -213,11 +198,10 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE);
}
private EntityRelation toRelation(EntityId zoneId, EntityId entityId, ZoneGroupConfiguration configuration) {
return switch (configuration.getDirection()) {
case TO -> new EntityRelation(zoneId, entityId, configuration.getRelationType());
case FROM -> new EntityRelation(entityId, zoneId, configuration.getRelationType());
};
private void addTransitionEventIfExists(ObjectNode resultNode, GeofencingEvalResult aggregationResult, String eventKey) {
if (aggregationResult.transition() != null) {
resultNode.put(eventKey, aggregationResult.transition().name());
}
}
}

View File

@ -15,7 +15,119 @@
*/
package org.thingsboard.server.common.data.cf.configuration.geofencing;
// TODO: add tests
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
public class ZoneGroupConfigurationTest {
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenNameIsNullEmptyOrBlank(String name) {
var zoneGroupConfiguration = new ZoneGroupConfiguration(name, "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
assertThatThrownBy(zoneGroupConfiguration::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Zone group name must be specified!");
}
@ParameterizedTest
@ValueSource(strings = {EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY, EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY})
void validateShouldThrowWhenUsedReservedEntityCoordinateNames(String name) {
var zoneGroupConfiguration = new ZoneGroupConfiguration(name, "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
assertThatThrownBy(zoneGroupConfiguration::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Name '" + name + "' is reserved and cannot be used for zone group!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenPerimeterKeyNameIsNullEmptyOrBlank(String perimeterKeyName) {
var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", perimeterKeyName, REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
assertThatThrownBy(zoneGroupConfiguration::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Perimeter key name must be specified for 'allowedZonesGroup' zone group!");
}
@Test
void validateShouldThrowWhenReportStrategyIsNull() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", null, false);
assertThatThrownBy(zoneGroupConfiguration::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Report strategy must be specified for 'allowedZonesGroup' zone group!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenRelationCreationEnabledAndRelationTypeIsNullEmptyOrBlank(String relationType) {
var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true);
zoneGroupConfiguration.setRelationType(relationType);
assertThatThrownBy(zoneGroupConfiguration::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Relation type must be specified for 'allowedZonesGroup' zone group!");
}
@Test
void validateShouldThrowWhenRelationCreationEnabledAndDirectionIsNull() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true);
zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
zoneGroupConfiguration.setDirection(null);
assertThatThrownBy(zoneGroupConfiguration::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Relation direction must be specified for 'allowedZonesGroup' zone group!");
}
@Test
void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationDisabledAndConfigValid() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
assertThatCode(zoneGroupConfiguration::validate).doesNotThrowAnyException();
}
@Test
void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationEnabledAndConfigValid() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true);
zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
zoneGroupConfiguration.setDirection(EntitySearchDirection.TO);
assertThatCode(zoneGroupConfiguration::validate).doesNotThrowAnyException();
}
@Test
void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNotNull() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration());
assertThat(zoneGroupConfiguration.hasDynamicSource()).isTrue();
}
@Test
void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNull() {
var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class);
assertThat(zoneGroupConfiguration.getRefDynamicSourceConfiguration()).isNull();
assertThat(zoneGroupConfiguration.hasDynamicSource()).isFalse();
}
@Test
void validateToArgumentsMethodCallWithoutRefEntityId() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("allowedZonesGroup", "perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
Argument zoneGroupArgument = zoneGroupConfiguration.toArgument();
assertThat(zoneGroupArgument).isNotNull();
assertThat(zoneGroupArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
}
}