|
|
|
@ -15,10 +15,12 @@ |
|
|
|
*/ |
|
|
|
package org.thingsboard.rule.engine.action; |
|
|
|
|
|
|
|
import org.junit.jupiter.api.BeforeAll; |
|
|
|
import org.junit.jupiter.api.BeforeEach; |
|
|
|
import org.junit.jupiter.api.Test; |
|
|
|
import org.junit.jupiter.api.extension.ExtendWith; |
|
|
|
import org.junit.jupiter.params.ParameterizedTest; |
|
|
|
import org.junit.jupiter.params.provider.Arguments; |
|
|
|
import org.junit.jupiter.params.provider.MethodSource; |
|
|
|
import org.mockito.ArgumentCaptor; |
|
|
|
import org.mockito.Mock; |
|
|
|
import org.mockito.junit.jupiter.MockitoExtension; |
|
|
|
@ -31,18 +33,20 @@ import org.thingsboard.server.common.data.Device; |
|
|
|
import org.thingsboard.server.common.data.id.AssetId; |
|
|
|
import org.thingsboard.server.common.data.id.DeviceId; |
|
|
|
import org.thingsboard.server.common.data.id.DeviceProfileId; |
|
|
|
import org.thingsboard.server.common.data.id.RuleNodeId; |
|
|
|
import org.thingsboard.server.common.data.id.TenantId; |
|
|
|
import org.thingsboard.server.common.data.msg.TbMsgType; |
|
|
|
import org.thingsboard.server.common.msg.TbMsg; |
|
|
|
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|
|
|
import org.thingsboard.server.gen.transport.TransportProtos; |
|
|
|
import org.thingsboard.server.queue.TbQueueCallback; |
|
|
|
|
|
|
|
import java.util.UUID; |
|
|
|
import java.util.stream.Stream; |
|
|
|
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat; |
|
|
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|
|
|
import static org.assertj.core.api.Assertions.fail; |
|
|
|
import static org.mockito.ArgumentMatchers.any; |
|
|
|
import static org.mockito.ArgumentMatchers.eq; |
|
|
|
import static org.mockito.BDDMockito.given; |
|
|
|
import static org.mockito.BDDMockito.then; |
|
|
|
@ -51,9 +55,6 @@ import static org.mockito.Mockito.times; |
|
|
|
@ExtendWith(MockitoExtension.class) |
|
|
|
public class TbDeviceStateNodeTest { |
|
|
|
|
|
|
|
private static TenantId DUMMY_TENANT_ID; |
|
|
|
private static TbMsg DUMMY_MSG; |
|
|
|
private static DeviceId DUMMY_MSG_ORIGINATOR; |
|
|
|
@Mock |
|
|
|
private TbContext ctxMock; |
|
|
|
@Mock |
|
|
|
@ -61,26 +62,26 @@ public class TbDeviceStateNodeTest { |
|
|
|
private TbDeviceStateNode node; |
|
|
|
private TbDeviceStateNodeConfiguration config; |
|
|
|
|
|
|
|
@BeforeAll |
|
|
|
public static void init() { |
|
|
|
DUMMY_TENANT_ID = TenantId.fromUUID(UUID.randomUUID()); |
|
|
|
private static final TenantId TENANT_ID = TenantId.fromUUID(UUID.randomUUID()); |
|
|
|
private static final DeviceId DEVICE_ID = new DeviceId(UUID.randomUUID()); |
|
|
|
private static final long METADATA_TS = System.currentTimeMillis(); |
|
|
|
private TbMsg msg; |
|
|
|
|
|
|
|
@BeforeEach |
|
|
|
public void setup() { |
|
|
|
var device = new Device(); |
|
|
|
device.setTenantId(DUMMY_TENANT_ID); |
|
|
|
device.setId(new DeviceId(UUID.randomUUID())); |
|
|
|
device.setTenantId(TENANT_ID); |
|
|
|
device.setId(DEVICE_ID); |
|
|
|
device.setName("My humidity sensor"); |
|
|
|
device.setType("Humidity sensor"); |
|
|
|
device.setDeviceProfileId(new DeviceProfileId(UUID.randomUUID())); |
|
|
|
var metaData = new TbMsgMetaData(); |
|
|
|
metaData.putValue("deviceName", device.getName()); |
|
|
|
metaData.putValue("deviceType", device.getType()); |
|
|
|
metaData.putValue("ts", String.valueOf(System.currentTimeMillis())); |
|
|
|
metaData.putValue("ts", String.valueOf(METADATA_TS)); |
|
|
|
var data = JacksonUtil.newObjectNode(); |
|
|
|
data.put("humidity", 58.3); |
|
|
|
DUMMY_MSG = TbMsg.newMsg( |
|
|
|
TbMsgType.POST_TELEMETRY_REQUEST, device.getId(), metaData, JacksonUtil.toString(data) |
|
|
|
); |
|
|
|
DUMMY_MSG_ORIGINATOR = device.getId(); |
|
|
|
msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, device.getId(), metaData, JacksonUtil.toString(data)); |
|
|
|
} |
|
|
|
|
|
|
|
@BeforeEach |
|
|
|
@ -128,15 +129,15 @@ public class TbDeviceStateNodeTest { |
|
|
|
@Test |
|
|
|
public void givenNonDeviceOriginator_whenOnMsg_thenTellsSuccessAndNoActivityActionsTriggered() { |
|
|
|
// GIVEN
|
|
|
|
var originator = new AssetId(UUID.randomUUID()); |
|
|
|
var msg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, originator, new TbMsgMetaData(), "{}"); |
|
|
|
given(ctxMock.isLocalEntity(originator)).willReturn(true); |
|
|
|
var asset = new AssetId(UUID.randomUUID()); |
|
|
|
var msg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, asset, new TbMsgMetaData(), "{}"); |
|
|
|
given(ctxMock.isLocalEntity(asset)).willReturn(true); |
|
|
|
|
|
|
|
// WHEN
|
|
|
|
node.onMsg(ctxMock, msg); |
|
|
|
|
|
|
|
// THEN
|
|
|
|
then(ctxMock).should(times(1)).isLocalEntity(originator); |
|
|
|
then(ctxMock).should(times(1)).isLocalEntity(asset); |
|
|
|
then(ctxMock).should(times(1)).tellSuccess(msg); |
|
|
|
then(ctxMock).shouldHaveNoMoreInteractions(); |
|
|
|
} |
|
|
|
@ -144,178 +145,97 @@ public class TbDeviceStateNodeTest { |
|
|
|
@Test |
|
|
|
public void givenNonLocalOriginator_whenOnMsg_thenTellsSuccessAndNoActivityActionsTriggered() { |
|
|
|
// GIVEN
|
|
|
|
given(ctxMock.isLocalEntity(DUMMY_MSG.getOriginator())).willReturn(false); |
|
|
|
given(ctxMock.isLocalEntity(msg.getOriginator())).willReturn(false); |
|
|
|
given(ctxMock.getSelfId()).willReturn(new RuleNodeId(UUID.randomUUID())); |
|
|
|
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|
|
|
|
|
|
|
// WHEN
|
|
|
|
node.onMsg(ctxMock, DUMMY_MSG); |
|
|
|
node.onMsg(ctxMock, msg); |
|
|
|
|
|
|
|
// THEN
|
|
|
|
then(ctxMock).should(times(1)).isLocalEntity(DUMMY_MSG_ORIGINATOR); |
|
|
|
then(ctxMock).should(times(1)).isLocalEntity(DEVICE_ID); |
|
|
|
then(ctxMock).should().getTenantId(); |
|
|
|
then(ctxMock).should().getSelfId(); |
|
|
|
then(ctxMock).should(times(1)).ack(msg); |
|
|
|
then(ctxMock).shouldHaveNoMoreInteractions(); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
public void givenConnectEventInConfig_whenOnMsg_thenOnDeviceConnectCalledAndTellsSuccess() { |
|
|
|
@ParameterizedTest |
|
|
|
@MethodSource("provideSupportedEventsAndExpectedMessages") |
|
|
|
public void givenSupportedEvent_whenOnMsg_thenCorrectMsgIsSent( |
|
|
|
TbMsgType event, TransportProtos.ToCoreMsg expectedToCoreMsg |
|
|
|
) { |
|
|
|
// GIVEN
|
|
|
|
config.setEvent(TbMsgType.CONNECT_EVENT); |
|
|
|
config.setEvent(event); |
|
|
|
var nodeConfig = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|
|
|
try { |
|
|
|
node.init(ctxMock, nodeConfig); |
|
|
|
} catch (TbNodeException e) { |
|
|
|
fail("Node failed to initialize!", e); |
|
|
|
} |
|
|
|
given(ctxMock.getTenantId()).willReturn(DUMMY_TENANT_ID); |
|
|
|
given(ctxMock.getTenantId()).willReturn(TENANT_ID); |
|
|
|
given(ctxMock.getClusterService()).willReturn(tbClusterServiceMock); |
|
|
|
given(ctxMock.isLocalEntity(DUMMY_MSG.getOriginator())).willReturn(true); |
|
|
|
given(ctxMock.isLocalEntity(msg.getOriginator())).willReturn(true); |
|
|
|
|
|
|
|
// WHEN
|
|
|
|
node.onMsg(ctxMock, DUMMY_MSG); |
|
|
|
|
|
|
|
// THEN
|
|
|
|
var protoCaptor = ArgumentCaptor.forClass(TransportProtos.ToCoreMsg.class); |
|
|
|
then(tbClusterServiceMock).should(times(1)) |
|
|
|
.pushMsgToCore(eq(DUMMY_TENANT_ID), eq(DUMMY_MSG_ORIGINATOR), protoCaptor.capture(), any()); |
|
|
|
|
|
|
|
TransportProtos.DeviceConnectProto expectedDeviceConnectMsg = TransportProtos.DeviceConnectProto.newBuilder() |
|
|
|
.setTenantIdMSB(DUMMY_TENANT_ID.getId().getMostSignificantBits()) |
|
|
|
.setTenantIdLSB(DUMMY_TENANT_ID.getId().getLeastSignificantBits()) |
|
|
|
.setDeviceIdMSB(DUMMY_MSG_ORIGINATOR.getId().getMostSignificantBits()) |
|
|
|
.setDeviceIdLSB(DUMMY_MSG_ORIGINATOR.getId().getLeastSignificantBits()) |
|
|
|
.build(); |
|
|
|
TransportProtos.ToCoreMsg expectedToCoreMsg = TransportProtos.ToCoreMsg.newBuilder() |
|
|
|
.setDeviceConnectMsg(expectedDeviceConnectMsg) |
|
|
|
.build(); |
|
|
|
assertThat(expectedToCoreMsg).isEqualTo(protoCaptor.getValue()); |
|
|
|
|
|
|
|
then(ctxMock).should(times(1)).tellSuccess(DUMMY_MSG); |
|
|
|
then(tbClusterServiceMock).shouldHaveNoMoreInteractions(); |
|
|
|
then(ctxMock).shouldHaveNoMoreInteractions(); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
public void givenActivityEventInConfig_whenOnMsg_thenOnDeviceActivityCalledWithCorrectTimeAndTellsSuccess() |
|
|
|
throws TbNodeException { |
|
|
|
// GIVEN
|
|
|
|
config.setEvent(TbMsgType.ACTIVITY_EVENT); |
|
|
|
var nodeConfig = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|
|
|
node.init(ctxMock, nodeConfig); |
|
|
|
given(ctxMock.getTenantId()).willReturn(DUMMY_TENANT_ID); |
|
|
|
given(ctxMock.getClusterService()).willReturn(tbClusterServiceMock); |
|
|
|
given(ctxMock.isLocalEntity(DUMMY_MSG.getOriginator())).willReturn(true); |
|
|
|
|
|
|
|
|
|
|
|
// WHEN
|
|
|
|
long timeBeforeCall = System.currentTimeMillis(); |
|
|
|
node.onMsg(ctxMock, DUMMY_MSG); |
|
|
|
long timeAfterCall = System.currentTimeMillis(); |
|
|
|
node.onMsg(ctxMock, msg); |
|
|
|
|
|
|
|
// THEN
|
|
|
|
var protoCaptor = ArgumentCaptor.forClass(TransportProtos.ToCoreMsg.class); |
|
|
|
var callbackCaptor = ArgumentCaptor.forClass(TbQueueCallback.class); |
|
|
|
then(tbClusterServiceMock).should(times(1)) |
|
|
|
.pushMsgToCore(eq(DUMMY_TENANT_ID), eq(DUMMY_MSG_ORIGINATOR), protoCaptor.capture(), any()); |
|
|
|
|
|
|
|
TransportProtos.ToCoreMsg actualToCoreMsg = protoCaptor.getValue(); |
|
|
|
long actualLastActivityTime = actualToCoreMsg.getDeviceActivityMsg().getLastActivityTime(); |
|
|
|
|
|
|
|
assertThat(actualLastActivityTime).isGreaterThanOrEqualTo(timeBeforeCall); |
|
|
|
assertThat(actualLastActivityTime).isLessThanOrEqualTo(timeAfterCall); |
|
|
|
|
|
|
|
TransportProtos.DeviceActivityProto updatedActivityMsg = actualToCoreMsg.getDeviceActivityMsg().toBuilder() |
|
|
|
.setLastActivityTime(123L) |
|
|
|
.build(); |
|
|
|
TransportProtos.ToCoreMsg updatedToCoreMsg = actualToCoreMsg.toBuilder() |
|
|
|
.setDeviceActivityMsg(updatedActivityMsg) |
|
|
|
.build(); |
|
|
|
.pushMsgToCore(eq(TENANT_ID), eq(DEVICE_ID), protoCaptor.capture(), callbackCaptor.capture()); |
|
|
|
|
|
|
|
TransportProtos.DeviceActivityProto expectedDeviceActivityMsg = TransportProtos.DeviceActivityProto.newBuilder() |
|
|
|
.setTenantIdMSB(DUMMY_TENANT_ID.getId().getMostSignificantBits()) |
|
|
|
.setTenantIdLSB(DUMMY_TENANT_ID.getId().getLeastSignificantBits()) |
|
|
|
.setDeviceIdMSB(DUMMY_MSG_ORIGINATOR.getId().getMostSignificantBits()) |
|
|
|
.setDeviceIdLSB(DUMMY_MSG_ORIGINATOR.getId().getLeastSignificantBits()) |
|
|
|
.setLastActivityTime(123L) |
|
|
|
.build(); |
|
|
|
TransportProtos.ToCoreMsg expectedToCoreMsg = TransportProtos.ToCoreMsg.newBuilder() |
|
|
|
.setDeviceActivityMsg(expectedDeviceActivityMsg) |
|
|
|
.build(); |
|
|
|
TbQueueCallback actualCallback = callbackCaptor.getValue(); |
|
|
|
|
|
|
|
assertThat(updatedToCoreMsg).isEqualTo(expectedToCoreMsg); |
|
|
|
|
|
|
|
then(ctxMock).should(times(1)).tellSuccess(DUMMY_MSG); |
|
|
|
then(tbClusterServiceMock).shouldHaveNoMoreInteractions(); |
|
|
|
then(ctxMock).shouldHaveNoMoreInteractions(); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
public void givenInactivityEventInConfig_whenOnMsg_thenOnDeviceInactivityCalledAndTellsSuccess() |
|
|
|
throws TbNodeException { |
|
|
|
// GIVEN
|
|
|
|
config.setEvent(TbMsgType.INACTIVITY_EVENT); |
|
|
|
var nodeConfig = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|
|
|
node.init(ctxMock, nodeConfig); |
|
|
|
given(ctxMock.getTenantId()).willReturn(DUMMY_TENANT_ID); |
|
|
|
given(ctxMock.getClusterService()).willReturn(tbClusterServiceMock); |
|
|
|
given(ctxMock.isLocalEntity(DUMMY_MSG.getOriginator())).willReturn(true); |
|
|
|
|
|
|
|
// WHEN
|
|
|
|
node.onMsg(ctxMock, DUMMY_MSG); |
|
|
|
actualCallback.onSuccess(null); |
|
|
|
then(ctxMock).should(times(1)).tellSuccess(msg); |
|
|
|
|
|
|
|
// THEN
|
|
|
|
var protoCaptor = ArgumentCaptor.forClass(TransportProtos.ToCoreMsg.class); |
|
|
|
then(tbClusterServiceMock).should(times(1)) |
|
|
|
.pushMsgToCore(eq(DUMMY_TENANT_ID), eq(DUMMY_MSG_ORIGINATOR), protoCaptor.capture(), any()); |
|
|
|
var throwable = new Throwable(); |
|
|
|
actualCallback.onFailure(throwable); |
|
|
|
then(ctxMock).should(times(1)).tellFailure(msg, throwable); |
|
|
|
|
|
|
|
TransportProtos.DeviceInactivityProto expectedDeviceInactivityMsg = |
|
|
|
TransportProtos.DeviceInactivityProto.newBuilder() |
|
|
|
.setTenantIdMSB(DUMMY_TENANT_ID.getId().getMostSignificantBits()) |
|
|
|
.setTenantIdLSB(DUMMY_TENANT_ID.getId().getLeastSignificantBits()) |
|
|
|
.setDeviceIdMSB(DUMMY_MSG_ORIGINATOR.getId().getMostSignificantBits()) |
|
|
|
.setDeviceIdLSB(DUMMY_MSG_ORIGINATOR.getId().getLeastSignificantBits()) |
|
|
|
.build(); |
|
|
|
TransportProtos.ToCoreMsg expectedToCoreMsg = TransportProtos.ToCoreMsg.newBuilder() |
|
|
|
.setDeviceInactivityMsg(expectedDeviceInactivityMsg) |
|
|
|
.build(); |
|
|
|
assertThat(expectedToCoreMsg).isEqualTo(protoCaptor.getValue()); |
|
|
|
|
|
|
|
then(ctxMock).should(times(1)).tellSuccess(DUMMY_MSG); |
|
|
|
then(tbClusterServiceMock).shouldHaveNoMoreInteractions(); |
|
|
|
then(ctxMock).shouldHaveNoMoreInteractions(); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
public void givenDisconnectEventInConfig_whenOnMsg_thenOnDeviceDisconnectCalledAndTellsSuccess() |
|
|
|
throws TbNodeException { |
|
|
|
// GIVEN
|
|
|
|
config.setEvent(TbMsgType.DISCONNECT_EVENT); |
|
|
|
var nodeConfig = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); |
|
|
|
node.init(ctxMock, nodeConfig); |
|
|
|
given(ctxMock.getTenantId()).willReturn(DUMMY_TENANT_ID); |
|
|
|
given(ctxMock.getClusterService()).willReturn(tbClusterServiceMock); |
|
|
|
given(ctxMock.isLocalEntity(DUMMY_MSG.getOriginator())).willReturn(true); |
|
|
|
|
|
|
|
// WHEN
|
|
|
|
node.onMsg(ctxMock, DUMMY_MSG); |
|
|
|
|
|
|
|
// THEN
|
|
|
|
var protoCaptor = ArgumentCaptor.forClass(TransportProtos.ToCoreMsg.class); |
|
|
|
then(tbClusterServiceMock).should(times(1)) |
|
|
|
.pushMsgToCore(eq(DUMMY_TENANT_ID), eq(DUMMY_MSG_ORIGINATOR), protoCaptor.capture(), any()); |
|
|
|
|
|
|
|
TransportProtos.DeviceDisconnectProto expectedDeviceDisconnectMsg = |
|
|
|
TransportProtos.DeviceDisconnectProto.newBuilder() |
|
|
|
.setTenantIdMSB(DUMMY_TENANT_ID.getId().getMostSignificantBits()) |
|
|
|
.setTenantIdLSB(DUMMY_TENANT_ID.getId().getLeastSignificantBits()) |
|
|
|
.setDeviceIdMSB(DUMMY_MSG_ORIGINATOR.getId().getMostSignificantBits()) |
|
|
|
.setDeviceIdLSB(DUMMY_MSG_ORIGINATOR.getId().getLeastSignificantBits()) |
|
|
|
.build(); |
|
|
|
TransportProtos.ToCoreMsg expectedToCoreMsg = TransportProtos.ToCoreMsg.newBuilder() |
|
|
|
.setDeviceDisconnectMsg(expectedDeviceDisconnectMsg) |
|
|
|
.build(); |
|
|
|
assertThat(expectedToCoreMsg).isEqualTo(protoCaptor.getValue()); |
|
|
|
|
|
|
|
then(ctxMock).should(times(1)).tellSuccess(DUMMY_MSG); |
|
|
|
then(tbClusterServiceMock).shouldHaveNoMoreInteractions(); |
|
|
|
then(ctxMock).shouldHaveNoMoreInteractions(); |
|
|
|
private static Stream<Arguments> provideSupportedEventsAndExpectedMessages() { |
|
|
|
return Stream.of( |
|
|
|
Arguments.of(TbMsgType.CONNECT_EVENT, TransportProtos.ToCoreMsg.newBuilder().setDeviceConnectMsg( |
|
|
|
TransportProtos.DeviceConnectProto.newBuilder() |
|
|
|
.setTenantIdMSB(TENANT_ID.getId().getMostSignificantBits()) |
|
|
|
.setTenantIdLSB(TENANT_ID.getId().getLeastSignificantBits()) |
|
|
|
.setDeviceIdMSB(DEVICE_ID.getId().getMostSignificantBits()) |
|
|
|
.setDeviceIdLSB(DEVICE_ID.getId().getLeastSignificantBits()) |
|
|
|
.setLastConnectTime(METADATA_TS) |
|
|
|
.build()).build()), |
|
|
|
Arguments.of(TbMsgType.ACTIVITY_EVENT, TransportProtos.ToCoreMsg.newBuilder().setDeviceActivityMsg( |
|
|
|
TransportProtos.DeviceActivityProto.newBuilder() |
|
|
|
.setTenantIdMSB(TENANT_ID.getId().getMostSignificantBits()) |
|
|
|
.setTenantIdLSB(TENANT_ID.getId().getLeastSignificantBits()) |
|
|
|
.setDeviceIdMSB(DEVICE_ID.getId().getMostSignificantBits()) |
|
|
|
.setDeviceIdLSB(DEVICE_ID.getId().getLeastSignificantBits()) |
|
|
|
.setLastActivityTime(METADATA_TS) |
|
|
|
.build()).build()), |
|
|
|
Arguments.of(TbMsgType.DISCONNECT_EVENT, TransportProtos.ToCoreMsg.newBuilder().setDeviceDisconnectMsg( |
|
|
|
TransportProtos.DeviceDisconnectProto.newBuilder() |
|
|
|
.setTenantIdMSB(TENANT_ID.getId().getMostSignificantBits()) |
|
|
|
.setTenantIdLSB(TENANT_ID.getId().getLeastSignificantBits()) |
|
|
|
.setDeviceIdMSB(DEVICE_ID.getId().getMostSignificantBits()) |
|
|
|
.setDeviceIdLSB(DEVICE_ID.getId().getLeastSignificantBits()) |
|
|
|
.setLastDisconnectTime(METADATA_TS) |
|
|
|
.build()).build()), |
|
|
|
Arguments.of(TbMsgType.INACTIVITY_EVENT, TransportProtos.ToCoreMsg.newBuilder().setDeviceInactivityMsg( |
|
|
|
TransportProtos.DeviceInactivityProto.newBuilder() |
|
|
|
.setTenantIdMSB(TENANT_ID.getId().getMostSignificantBits()) |
|
|
|
.setTenantIdLSB(TENANT_ID.getId().getLeastSignificantBits()) |
|
|
|
.setDeviceIdMSB(DEVICE_ID.getId().getMostSignificantBits()) |
|
|
|
.setDeviceIdLSB(DEVICE_ID.getId().getLeastSignificantBits()) |
|
|
|
.setLastInactivityTime(METADATA_TS) |
|
|
|
.build()).build()) |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|