Added mock tests for Geofencing CF state and utils logic to/from proto

This commit is contained in:
dshvaika 2025-08-11 17:44:40 +03:00
parent baba433f0f
commit efc20a93aa
9 changed files with 494 additions and 21 deletions

View File

@ -49,7 +49,7 @@ import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalc
public class GeofencingCalculatedFieldState implements CalculatedFieldState { public class GeofencingCalculatedFieldState implements CalculatedFieldState {
private List<String> requiredArguments; private List<String> requiredArguments;
private Map<String, ArgumentEntry> arguments; Map<String, ArgumentEntry> arguments;
private boolean sizeExceedsLimit; private boolean sizeExceedsLimit;
private long latestTimestamp = -1; private long latestTimestamp = -1;
@ -91,14 +91,16 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState {
entryUpdated = switch (key) { entryUpdated = switch (key) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> { case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> {
if (!(newEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry)) { if (!(newEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry)) {
throw new IllegalArgumentException(key + " argument must be a single value argument."); throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " +
"Only SINGLE_VALUE type is allowed.");
} }
arguments.put(key, singleValueArgumentEntry); arguments.put(key, singleValueArgumentEntry);
yield true; yield true;
} }
default -> { default -> {
if (!(newEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) { if (!(newEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) {
throw new IllegalArgumentException(key + " argument must be a geofencing argument entry."); throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " +
"Only GEOFENCING type is allowed.");
} }
arguments.put(key, geofencingArgumentEntry); arguments.put(key, geofencingArgumentEntry);
yield true; yield true;

View File

@ -25,7 +25,6 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto; import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto;
import java.util.UUID; import java.util.UUID;
@ -44,13 +43,11 @@ public class GeofencingZoneState {
public GeofencingZoneState(EntityId zoneId, KvEntry entry) { public GeofencingZoneState(EntityId zoneId, KvEntry entry) {
this.zoneId = zoneId; this.zoneId = zoneId;
if (entry instanceof TsKvEntry tsKvEntry) { if (!(entry instanceof AttributeKvEntry attributeKvEntry)) {
this.ts = tsKvEntry.getTs(); throw new IllegalArgumentException("Unsupported KvEntry type for geofencing zone state: " + entry.getClass().getSimpleName());
this.version = tsKvEntry.getVersion();
} else if (entry instanceof AttributeKvEntry attributeKvEntry) {
this.ts = attributeKvEntry.getLastUpdateTs();
this.version = attributeKvEntry.getVersion();
} }
this.ts = attributeKvEntry.getLastUpdateTs();
this.version = attributeKvEntry.getVersion();
this.perimeterDefinition = JacksonUtil.fromString(entry.getJsonValue().orElseThrow(), PerimeterDefinition.class); this.perimeterDefinition = JacksonUtil.fromString(entry.getJsonValue().orElseThrow(), PerimeterDefinition.class);
} }

View File

@ -121,7 +121,6 @@ public class CalculatedFieldUtils {
return builder.build(); return builder.build();
} }
private static GeofencingArgumentProto toGeofencingArgumentProto(String argName, GeofencingArgumentEntry geofencingArgumentEntry) { private static GeofencingArgumentProto toGeofencingArgumentProto(String argName, GeofencingArgumentEntry geofencingArgumentEntry) {
Map<EntityId, GeofencingZoneState> zoneStates = geofencingArgumentEntry.getZoneStates(); Map<EntityId, GeofencingZoneState> zoneStates = geofencingArgumentEntry.getZoneStates();
GeofencingArgumentProto.Builder builder = GeofencingArgumentProto.newBuilder() GeofencingArgumentProto.Builder builder = GeofencingArgumentProto.newBuilder()

View File

@ -0,0 +1,360 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf.ctx.state;
import com.google.common.util.concurrent.Futures;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
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.CFArgumentDynamicSourceType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.GeofencingEvent;
import org.thingsboard.server.common.data.cf.configuration.GeofencingZoneGroupConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
@ExtendWith(MockitoExtension.class)
public class GeofencingCalculatedFieldStateTest {
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("8f83eeca-b5cd-4955-9241-09d1393768c6"));
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("688b529d-cfbe-4430-91c5-60b4f4e5d3cf"));
private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714"));
private final AssetId ZONE_2_ID = new AssetId(UUID.fromString("e7da6200-2096-4038-a343-ade9ea4fa3e4"));
private final SingleValueArgumentEntry latitudeArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new DoubleDataEntry("latitude", 50.4730), 145L);
private final SingleValueArgumentEntry longitudeArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 6, new DoubleDataEntry("longitude", 30.5050), 165L);
private final JsonDataEntry allowedZoneDataEntry = new JsonDataEntry("zone", """
{"type":"POLYGON","polygonsDefinition":"[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"}""");
private final BaseAttributeKvEntry allowedZoneAttributeKvEntry = new BaseAttributeKvEntry(allowedZoneDataEntry, System.currentTimeMillis(), 0L);
private final GeofencingArgumentEntry geofencingAllowedZoneArgEntry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry));
private final JsonDataEntry restrictedZoneDataEntry = new JsonDataEntry("zone", """
{"type":"POLYGON","polygonsDefinition":"[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"}""");
private final BaseAttributeKvEntry restrictedZoneAttributeKvEntry = new BaseAttributeKvEntry(restrictedZoneDataEntry, System.currentTimeMillis(), 0L);
private final GeofencingArgumentEntry geofencingRestrictedZoneArgEntry = new GeofencingArgumentEntry(Map.of(ZONE_2_ID, restrictedZoneAttributeKvEntry));
private GeofencingCalculatedFieldState state;
private CalculatedFieldCtx ctx;
@Mock
private ApiLimitService apiLimitService;
@Mock
private RelationService relationService;
@BeforeEach
void setUp() {
when(apiLimitService.getLimit(any(), any())).thenReturn(1000L);
ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService, relationService);
ctx.init();
state = new GeofencingCalculatedFieldState(ctx.getArgNames());
}
@Test
void testType() {
assertThat(state.getType()).isEqualTo(CalculatedFieldType.GEOFENCING);
}
@Test
void testUpdateState() {
state.arguments = new HashMap<>(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry
));
Map<String, ArgumentEntry> newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry);
boolean stateUpdated = state.updateState(ctx, newArgs);
assertThat(stateUpdated).isTrue();
assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf(
Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
"allowedZones", geofencingAllowedZoneArgEntry
)
);
}
@Test
void testUpdateStateWithInvalidArgumentTypeForLatitudeArgument() {
assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for latitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed.");
}
@Test
void testUpdateStateWithInvalidArgumentTypeForLongitudeArgument() {
assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for longitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed.");
}
@Test
void testUpdateStateWithInvalidArgumentTypeForGeofencingArgument() {
assertThatThrownBy(() -> state.updateState(ctx, Map.of("someArgumentName", latitudeArgEntry)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for someArgumentName argument: SINGLE_VALUE. Only GEOFENCING type is allowed.");
}
@Test
void testUpdateStateWhenUpdateExistingSingleValueArgumentEntry() {
state.arguments = new HashMap<>(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry));
SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 190L);
Map<String, ArgumentEntry> newArgs = Map.of("latitude", newArgEntry);
boolean stateUpdated = state.updateState(ctx, newArgs);
assertThat(stateUpdated).isTrue();
assertThat(state.getArguments()).isEqualTo(newArgs);
}
// TODO: write opposite test for this. See TODO in the GeofencingZoneState class.
@Test
void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithTheSameValue() {
state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry));
Map<String, ArgumentEntry> newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry);
boolean stateUpdated = state.updateState(ctx, newArgs);
assertThat(stateUpdated).isFalse();
assertThat(state.getArguments()).isEqualTo(newArgs);
}
@Test
void testUpdateStateWhenUpdateExistingSingleValueArgumentEntryWithValueOfAnotherType() {
state.arguments = new HashMap<>(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry));
assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for single value argument entry: GEOFENCING");
}
@Test
void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithValueOfAnotherType() {
state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry));
assertThatThrownBy(() -> state.updateState(ctx, Map.of("allowedZones", latitudeArgEntry)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE");
}
@Test
void testIsReadyWhenNotAllArgPresent() {
assertThat(state.isReady()).isFalse();
}
@Test
void testIsReadyWhenAllArgPresent() {
state.arguments = new HashMap<>(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
"allowedZones", geofencingAllowedZoneArgEntry,
"restrictedZones", geofencingRestrictedZoneArgEntry
));
assertThat(state.isReady()).isTrue();
}
@Test
void testIsReadyWhenEmptyEntryPresents() {
state.arguments = new HashMap<>(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
"allowedZones", geofencingAllowedZoneArgEntry,
"restrictedZones", geofencingRestrictedZoneArgEntry
));
state.getArguments().put("noParkingZones", new GeofencingArgumentEntry());
assertThat(state.isReady()).isFalse();
}
@Test
void testPerformCalculation() throws ExecutionException, InterruptedException {
state.arguments = new HashMap<>(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
"allowedZones", geofencingAllowedZoneArgEntry,
"restrictedZones", geofencingRestrictedZoneArgEntry
));
Output output = ctx.getOutput();
var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope());
assertThat(result.getResult()).isEqualTo(
JacksonUtil.newObjectNode()
.put("allowedZoneEvent", "ENTERED")
.put("restrictedZoneEvent", "OUTSIDE")
);
SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L);
SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L);
// move the device to new coordinates leaves allowed, enters restricted
state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude));
CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result2).isNotNull();
assertThat(result2.getType()).isEqualTo(output.getType());
assertThat(result2.getScope()).isEqualTo(output.getScope());
assertThat(result2.getResult()).isEqualTo(
JacksonUtil.newObjectNode()
.put("allowedZoneEvent", "LEFT")
.put("restrictedZoneEvent", "ENTERED")
);
// Check relations are created and deleted correctly for both iterations.
ArgumentCaptor<EntityRelation> saveCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture());
List<EntityRelation> saveValues = saveCaptor.getAllValues();
assertThat(saveValues).hasSize(2);
EntityRelation relationFromFirstIteration = saveValues.get(0);
assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID);
assertThat(relationFromFirstIteration.getType()).isEqualTo(configuration.getZoneRelationType());
EntityRelation relationFromSecondIteration = saveValues.get(1);
assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID);
assertThat(relationFromSecondIteration.getType()).isEqualTo(configuration.getZoneRelationType());
ArgumentCaptor<EntityRelation> deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture());
EntityRelation leftRelation = deleteCaptor.getValue();
assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID);
assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId());
}
private CalculatedField getCalculatedField() {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setTenantId(TENANT_ID);
calculatedField.setEntityId(DEVICE_ID);
calculatedField.setType(CalculatedFieldType.GEOFENCING);
calculatedField.setName("Test Geofencing Calculated Field");
calculatedField.setConfigurationVersion(1);
calculatedField.setConfiguration(getCalculatedFieldConfig());
calculatedField.setVersion(1L);
return calculatedField;
}
private CalculatedFieldConfiguration getCalculatedFieldConfig() {
var config = new GeofencingCalculatedFieldConfiguration();
Argument argument1 = new Argument();
argument1.setRefEntityId(DEVICE_ID);
var refEntityKey1 = new ReferencedEntityKey("latitude", ArgumentType.TS_LATEST, null);
argument1.setRefEntityKey(refEntityKey1);
Argument argument2 = new Argument();
argument2.setRefEntityId(DEVICE_ID);
var refEntityKey2 = new ReferencedEntityKey("longitude", ArgumentType.TS_LATEST, null);
argument2.setRefEntityKey(refEntityKey2);
Argument argument3 = new Argument();
var refEntityKey3 = new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, null);
var refDynamicSourceConfiguration3 = new RelationQueryDynamicSourceConfiguration();
refDynamicSourceConfiguration3.setDirection(EntitySearchDirection.TO);
refDynamicSourceConfiguration3.setRelationType("AllowedZone");
refDynamicSourceConfiguration3.setMaxLevel(1);
refDynamicSourceConfiguration3.setFetchLastLevelOnly(true);
argument3.setRefEntityKey(refEntityKey3);
argument3.setRefDynamicSource(CFArgumentDynamicSourceType.RELATION_QUERY);
argument3.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration3);
Argument argument4 = new Argument();
var refEntityKey4 = new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, null);
var refDynamicSourceConfiguration4 = new RelationQueryDynamicSourceConfiguration();
refDynamicSourceConfiguration4.setDirection(EntitySearchDirection.TO);
refDynamicSourceConfiguration4.setRelationType("RestrictedZone");
refDynamicSourceConfiguration4.setMaxLevel(1);
refDynamicSourceConfiguration4.setFetchLastLevelOnly(true);
argument4.setRefEntityKey(refEntityKey4);
argument4.setRefDynamicSource(CFArgumentDynamicSourceType.RELATION_QUERY);
argument4.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration4);
config.setArguments(Map.of("latitude", argument1, "longitude", argument2, "allowedZones", argument3, "restrictedZones", argument4));
List<GeofencingEvent> reportEvents = Arrays.stream(GeofencingEvent.values()).toList();
GeofencingZoneGroupConfiguration allowedZoneGroupConfiguration = new GeofencingZoneGroupConfiguration("allowedZone", reportEvents);
GeofencingZoneGroupConfiguration restrictedZoneGroupConfiguration = new GeofencingZoneGroupConfiguration("restrictedZone", reportEvents);
config.setGeofencingZoneGroupConfigurations(Map.of("allowedZones", allowedZoneGroupConfiguration, "restrictedZones", restrictedZoneGroupConfiguration));
config.setCreateRelationsWithMatchedZones(true);
config.setZoneRelationType("CurrentZone");
config.setZoneRelationDirection(EntitySearchDirection.TO);
// TODO: Does CF possible to save with null?
config.setExpression("latitude + longitude");
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
return config;
}
}

View File

@ -0,0 +1,108 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.utils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.GeofencingZoneState;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@ExtendWith(MockitoExtension.class)
class CalculatedFieldUtilsTest {
private static final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("0a69e1e2-fcbc-4234-a4cd-3844bf54035c"));
private static final CalculatedFieldId CF_ID = CalculatedFieldId.fromString("ec0e91b9-6f27-4e93-946a-5fbc2707d8bc");
private static final DeviceId DEVICE_ID = DeviceId.fromString("1e03bd38-2010-4739-9362-160c288e36c4");
@Test
void toProtoAndFromProto_shouldMapGeofencingArgumentsAndZones() {
// given
CalculatedFieldEntityCtxId stateId = mock(CalculatedFieldEntityCtxId.class);
given(stateId.tenantId()).willReturn(TENANT_ID);
given(stateId.cfId()).willReturn(CF_ID);
given(stateId.entityId()).willReturn(DEVICE_ID);
// Build a geofencing argument with two zones (one with inside=true, one with inside=null)
GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry();
Map<EntityId, GeofencingZoneState> zoneStates = new LinkedHashMap<>();
UUID zoneId1 = UUID.fromString("624a8fff-71a2-4847-a100-ff1cf52dbe71");
UUID zoneId2 = UUID.fromString("e2adf6ce-9478-40b1-b0e9-4a6860cc46bb");
AssetId z1 = new AssetId(zoneId1);
AssetId z2 = new AssetId(zoneId2);
JsonDataEntry zone1 = new JsonDataEntry("zone", "{\"type\":\"POLYGON\",\"polygonsDefinition\":\"[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]\"}");
JsonDataEntry zone2 = new JsonDataEntry("zone", "{\"type\":\"POLYGON\",\"polygonsDefinition\":\"[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]\"}");
BaseAttributeKvEntry zone1PerimeterAttribute = new BaseAttributeKvEntry(zone1, System.currentTimeMillis(), 0L);
BaseAttributeKvEntry zone2PerimeterAttribute = new BaseAttributeKvEntry(zone2, System.currentTimeMillis(), 0L);
GeofencingZoneState s1 = new GeofencingZoneState(z1, zone1PerimeterAttribute);
s1.setInside(true);
GeofencingZoneState s2 = new GeofencingZoneState(z2, zone2PerimeterAttribute);
zoneStates.put(z1, s1);
zoneStates.put(z2, s2);
geofencingArgumentEntry.setZoneStates(zoneStates);
// Create cf state with the geofencing argument and add it to the state map
CalculatedFieldState state = new GeofencingCalculatedFieldState(List.of("geofencingArgumentTest"));
state.updateState(mock(CalculatedFieldCtx.class), Map.of("geofencingArgumentTest", geofencingArgumentEntry));
// when
CalculatedFieldStateProto proto = toProto(stateId, state);
// then
CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(proto);
assertThat(fromProto)
.usingRecursiveComparison()
.ignoringFields("requiredArguments")
.isEqualTo(state);
ArgumentEntry fromProtoArgument = fromProto.getArguments().get("geofencingArgumentTest");
assertThat(fromProtoArgument).isInstanceOf(GeofencingArgumentEntry.class);
GeofencingArgumentEntry fromProtoGeoArgument = (GeofencingArgumentEntry) fromProtoArgument;
assertThat(fromProtoGeoArgument.getZoneStates()).hasSize(2);
assertThat(fromProtoGeoArgument.getZoneStates().get(z1).getInside()).isTrue();
assertThat(fromProtoGeoArgument.getZoneStates().get(z2).getInside()).isNull();
}
}

View File

@ -33,8 +33,6 @@ public abstract class BaseCalculatedFieldConfiguration implements CalculatedFiel
protected String expression; protected String expression;
protected Output output; protected Output output;
protected int scheduledUpdateIntervalSec;
@Override @Override
public List<EntityId> getReferencedEntities() { public List<EntityId> getReferencedEntities() {
return arguments.values().stream() return arguments.values().stream()
@ -71,8 +69,4 @@ public abstract class BaseCalculatedFieldConfiguration implements CalculatedFiel
} }
} }
public boolean isScheduledUpdateEnabled() {
return scheduledUpdateIntervalSec > 0 && arguments.values().stream().anyMatch(arg -> arg.getRefDynamicSource() != null);
}
} }

View File

@ -66,8 +66,13 @@ public interface CalculatedFieldConfiguration {
return false; return false;
} }
void setScheduledUpdateIntervalSec(int scheduledUpdateIntervalSec); @JsonIgnore
default void setScheduledUpdateIntervalSec(int scheduledUpdateIntervalSec) {
}
int getScheduledUpdateIntervalSec(); @JsonIgnore
default int getScheduledUpdateIntervalSec() {
return 0;
}
} }

View File

@ -36,11 +36,13 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC
public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude"; public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude";
public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude"; public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude";
public static final Set<String> coordinateKeys = Set.of( private static final Set<String> coordinateKeys = Set.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LATITUDE_ARGUMENT_KEY,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY ENTITY_ID_LONGITUDE_ARGUMENT_KEY
); );
private int scheduledUpdateIntervalSec;
private boolean createRelationsWithMatchedZones; private boolean createRelationsWithMatchedZones;
private String zoneRelationType; private String zoneRelationType;
private EntitySearchDirection zoneRelationDirection; private EntitySearchDirection zoneRelationDirection;
@ -51,6 +53,11 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC
return CalculatedFieldType.GEOFENCING; return CalculatedFieldType.GEOFENCING;
} }
@Override
public boolean isScheduledUpdateEnabled() {
return scheduledUpdateIntervalSec > 0 && arguments.values().stream().anyMatch(arg -> arg.getRefDynamicSource() != null);
}
// TODO: update validate method in PE version. // TODO: update validate method in PE version.
@Override @Override
public void validate() { public void validate() {

View File

@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.relation.EntityRelationsQuery;
import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters; import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
import org.thingsboard.server.common.data.util.CollectionsUtil;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -56,7 +57,7 @@ public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynami
@Override @Override
public boolean isSimpleRelation() { public boolean isSimpleRelation() {
return maxLevel == 1 && (entityTypes == null || entityTypes.isEmpty()); return maxLevel == 1 && CollectionsUtil.isEmpty(entityTypes);
} }
@Override @Override