feat(iot): B17 SceneRule → DAG 自动转换工具 + dry-run/execute
- SceneRuleToChainMapper:v1→v2 纯转换逻辑
· trigger type 映射(1→device_state 等 4 种 + timer)
· action type 映射(1→device_property_set / 2→device_service_invoke / 100→alarm_trigger / 101→alarm_clear)
· SpEL→Aviator:#root.x → ${data.x};含 T(/instanceof/new 标记 WARNING 不中断
· 线性 DAG:Trigger → [Condition] → Action×N,临时 key -1/-2/-3...
- SceneRuleMigrator:干运行 + 分批执行(50条/批)+ 幂等(force 覆盖重迁)
- SceneRuleMigrationController:3 端点 dry-run/execute/mapping
- MigrationDryRunResultVO / MigrationExecuteReqVO
- 8 单元测试全绿(含 spel→aviator / unsupported_spel / idempotent / force)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
package com.viewsh.module.iot.migration;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.viewsh.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.viewsh.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import com.viewsh.module.iot.dal.mysql.rule.IotSceneRuleMapper;
|
||||
import com.viewsh.module.iot.enums.rule.IotSceneRuleActionTypeEnum;
|
||||
import com.viewsh.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
|
||||
import com.viewsh.module.iot.migration.mapping.SceneRuleToChainMapper;
|
||||
import com.viewsh.module.iot.migration.vo.MigrationDryRunResultVO;
|
||||
import com.viewsh.module.iot.migration.vo.MigrationExecuteReqVO;
|
||||
import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainSaveReqVO;
|
||||
import com.viewsh.module.iot.rule.service.IotRuleChainService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* B17 — {@link SceneRuleMigrator} 单元测试
|
||||
*
|
||||
* <p>6 个测试用例:
|
||||
* <ol>
|
||||
* <li>simple_device_property — trigger=device_property, cond, action=alarm_trigger</li>
|
||||
* <li>spel_to_aviator — #root.temp > 40 → ${data.temp} > 40</li>
|
||||
* <li>unsupported_spel — 含 T(java.lang.Math) → issues 列表</li>
|
||||
* <li>idempotent_rerun — 已迁移 → 跳过(not force)</li>
|
||||
* <li>execute_force — 已迁移 + force=true → 重新迁移</li>
|
||||
* <li>multi_action — 多个 actions → 多个 action 节点</li>
|
||||
* </ol>
|
||||
*/
|
||||
class SceneRuleMigratorTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Mock
|
||||
private IotSceneRuleMapper sceneRuleMapper;
|
||||
|
||||
@Mock
|
||||
private IotRuleChainService ruleChainService;
|
||||
|
||||
@Mock
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private SceneRuleToChainMapper chainMapper;
|
||||
private SceneRuleMigrator migrator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
chainMapper = new SceneRuleToChainMapper(new ObjectMapper());
|
||||
migrator = new SceneRuleMigrator(sceneRuleMapper, chainMapper, ruleChainService, jdbcTemplate);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 1:simple_device_property
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("simple_device_property: trigger=device_property, cond, action=alarm_trigger → v2 chain SCENE")
|
||||
void testSimpleDeviceProperty() {
|
||||
// Given
|
||||
IotSceneRuleDO rule = buildRule(1L, "温度报警",
|
||||
buildTrigger(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType(), 10L, 0L, "temperature", null, null),
|
||||
List.of(buildAction(IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType(), null, null, null, null, 100L))
|
||||
);
|
||||
|
||||
when(sceneRuleMapper.selectList(null)).thenReturn(List.of(rule));
|
||||
mockNotMigrated(1L);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(200L);
|
||||
mockInsertMapping();
|
||||
|
||||
// When
|
||||
MigrationExecuteReqVO opts = buildOpts("test", false);
|
||||
SceneRuleMigrator.ExecuteResult result = migrator.execute(opts);
|
||||
|
||||
// Then
|
||||
assertThat(result.totalRules()).isEqualTo(1);
|
||||
assertThat(result.migratedCount()).isEqualTo(1);
|
||||
assertThat(result.skippedCount()).isEqualTo(0);
|
||||
assertThat(result.errors()).isEmpty();
|
||||
|
||||
ArgumentCaptor<IotRuleChainSaveReqVO> captor = ArgumentCaptor.forClass(IotRuleChainSaveReqVO.class);
|
||||
verify(ruleChainService).createRuleChain(captor.capture());
|
||||
IotRuleChainSaveReqVO req = captor.getValue();
|
||||
|
||||
assertThat(req.getType()).isEqualTo("SCENE");
|
||||
assertThat(req.getPriority()).isEqualTo(100);
|
||||
|
||||
// Trigger 节点
|
||||
IotRuleChainSaveReqVO.NodeVO trigger = findNode(req, "trigger");
|
||||
assertThat(trigger).isNotNull();
|
||||
assertThat(trigger.getType()).isEqualTo("device_property");
|
||||
|
||||
// Action 节点
|
||||
IotRuleChainSaveReqVO.NodeVO action = findNode(req, "action");
|
||||
assertThat(action).isNotNull();
|
||||
assertThat(action.getType()).isEqualTo("alarm_trigger");
|
||||
|
||||
// Links
|
||||
assertThat(req.getLinks()).hasSize(1);
|
||||
assertThat(req.getLinks().get(0).getRelationType()).isEqualTo("Success");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 2:spel_to_aviator
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("spel_to_aviator: #root.temp > 40 && #root.hum < 20 → ${data.temp} > 40 && ${data.hum} < 20")
|
||||
void testSpelToAviator() {
|
||||
SceneRuleToChainMapper mapper = new SceneRuleToChainMapper(new ObjectMapper());
|
||||
|
||||
// 简单字段引用转换
|
||||
SceneRuleToChainMapper.SpelConversionResult r1 =
|
||||
mapper.convertSpelToAviator("#root.temp > 40 && #root.hum < 20");
|
||||
assertThat(r1.hasWarning()).isFalse();
|
||||
assertThat(r1.aviatorExpr()).isEqualTo("${data.temp} > 40 && ${data.hum} < 20");
|
||||
|
||||
// 单字段
|
||||
SceneRuleToChainMapper.SpelConversionResult r2 =
|
||||
mapper.convertSpelToAviator("#root.temperature");
|
||||
assertThat(r2.hasWarning()).isFalse();
|
||||
assertThat(r2.aviatorExpr()).isEqualTo("${data.temperature}");
|
||||
|
||||
// null 输入
|
||||
SceneRuleToChainMapper.SpelConversionResult r3 =
|
||||
mapper.convertSpelToAviator(null);
|
||||
assertThat(r3.hasWarning()).isFalse();
|
||||
assertThat(r3.aviatorExpr()).isNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 3:unsupported_spel
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("unsupported_spel: 含 T(java.lang.Math) → hasWarning=true, warningReason 不为空")
|
||||
void testUnsupportedSpel() {
|
||||
SceneRuleToChainMapper mapper = new SceneRuleToChainMapper(new ObjectMapper());
|
||||
|
||||
SceneRuleToChainMapper.SpelConversionResult result =
|
||||
mapper.convertSpelToAviator("T(java.lang.Math).abs(#root.value) > 10");
|
||||
assertThat(result.hasWarning()).isTrue();
|
||||
assertThat(result.warningReason()).isNotBlank();
|
||||
assertThat(result.warningReason()).contains("T(");
|
||||
|
||||
// instanceof 也不支持(另一种高级 SpEL 语法)
|
||||
SceneRuleToChainMapper.SpelConversionResult r2 =
|
||||
mapper.convertSpelToAviator("#root.value instanceof T(java.lang.Number)");
|
||||
assertThat(r2.hasWarning()).isTrue();
|
||||
assertThat(r2.warningReason()).isNotBlank();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 4:idempotent_rerun
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("idempotent_rerun: 已迁移的规则在 force=false 时跳过")
|
||||
void testIdempotentRerun() {
|
||||
// Given
|
||||
IotSceneRuleDO rule = buildRule(4L, "已迁移规则",
|
||||
buildTrigger(IotSceneRuleTriggerTypeEnum.TIMER.getType(), null, null, null, "0 0/5 * * * ?", null),
|
||||
List.of(buildAction(IotSceneRuleActionTypeEnum.DEVICE_PROPERTY_SET.getType(), 10L, 101L, "switch", "{\"switch\":1}", null))
|
||||
);
|
||||
|
||||
when(sceneRuleMapper.selectList(null)).thenReturn(List.of(rule));
|
||||
|
||||
// 模拟:已迁移(COUNT=1)
|
||||
when(jdbcTemplate.queryForObject(
|
||||
contains("COUNT(1)"),
|
||||
eq(Integer.class),
|
||||
eq(4L)
|
||||
)).thenReturn(1);
|
||||
|
||||
// When
|
||||
MigrationExecuteReqVO opts = buildOpts("test", false);
|
||||
SceneRuleMigrator.ExecuteResult result = migrator.execute(opts);
|
||||
|
||||
// Then: skipped=1, createRuleChain 不被调用
|
||||
assertThat(result.skippedCount()).isEqualTo(1);
|
||||
assertThat(result.migratedCount()).isEqualTo(0);
|
||||
verify(ruleChainService, never()).createRuleChain(any());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 5:execute_force
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("execute_force: 已迁移 + force=true → 重新迁移(DELETE + INSERT)")
|
||||
void testExecuteForce() {
|
||||
// Given
|
||||
IotSceneRuleDO rule = buildRule(5L, "强制重迁移",
|
||||
buildTrigger(IotSceneRuleTriggerTypeEnum.DEVICE_EVENT_POST.getType(), 20L, 201L, "overTemp", null, null),
|
||||
List.of(buildAction(IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType(), null, null, null, null, 200L))
|
||||
);
|
||||
|
||||
when(sceneRuleMapper.selectList(null)).thenReturn(List.of(rule));
|
||||
|
||||
// 模拟:已迁移(COUNT=1)
|
||||
when(jdbcTemplate.queryForObject(
|
||||
contains("COUNT(1)"),
|
||||
eq(Integer.class),
|
||||
eq(5L)
|
||||
)).thenReturn(1);
|
||||
|
||||
// 模拟 DELETE 和 INSERT 成功
|
||||
when(jdbcTemplate.update(anyString(), any(Object[].class))).thenReturn(1);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(999L);
|
||||
|
||||
// When
|
||||
MigrationExecuteReqVO opts = buildOpts("admin", true); // force=true
|
||||
SceneRuleMigrator.ExecuteResult result = migrator.execute(opts);
|
||||
|
||||
// Then: migrated=1(强制重新创建)
|
||||
assertThat(result.migratedCount()).isEqualTo(1);
|
||||
assertThat(result.skippedCount()).isEqualTo(0);
|
||||
|
||||
// createRuleChain 被调用
|
||||
verify(ruleChainService, times(1)).createRuleChain(any());
|
||||
|
||||
// DELETE 被调用(force 模式)
|
||||
verify(jdbcTemplate).update(
|
||||
contains("DELETE FROM iot_scene_rule_migration"),
|
||||
eq(5L));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 6:multi_action
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("multi_action: 多个 actions → chain 中有多个 action 节点,每个都有 link")
|
||||
void testMultiAction() {
|
||||
// Given
|
||||
IotSceneRuleDO rule = buildRule(6L, "多动作规则",
|
||||
buildTrigger(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType(), 10L, 0L, "temperature", null, null),
|
||||
List.of(
|
||||
buildAction(IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType(), null, null, null, null, 100L),
|
||||
buildAction(IotSceneRuleActionTypeEnum.DEVICE_PROPERTY_SET.getType(), 10L, 101L, "fan", "{\"speed\":3}", null),
|
||||
buildAction(IotSceneRuleActionTypeEnum.DEVICE_SERVICE_INVOKE.getType(), 10L, 101L, "cooling", "{}", null)
|
||||
)
|
||||
);
|
||||
|
||||
when(sceneRuleMapper.selectList(null)).thenReturn(List.of(rule));
|
||||
mockNotMigrated(6L);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(300L);
|
||||
mockInsertMapping();
|
||||
|
||||
// When
|
||||
SceneRuleMigrator.ExecuteResult result = migrator.execute(buildOpts("test", false));
|
||||
|
||||
// Then
|
||||
assertThat(result.migratedCount()).isEqualTo(1);
|
||||
|
||||
ArgumentCaptor<IotRuleChainSaveReqVO> captor = ArgumentCaptor.forClass(IotRuleChainSaveReqVO.class);
|
||||
verify(ruleChainService).createRuleChain(captor.capture());
|
||||
IotRuleChainSaveReqVO req = captor.getValue();
|
||||
|
||||
// 1 trigger + 3 actions = 4 nodes
|
||||
long triggerCount = req.getNodes().stream().filter(n -> "trigger".equals(n.getCategory())).count();
|
||||
long actionCount = req.getNodes().stream().filter(n -> "action".equals(n.getCategory())).count();
|
||||
assertThat(triggerCount).isEqualTo(1);
|
||||
assertThat(actionCount).isEqualTo(3);
|
||||
|
||||
// 验证 action 类型
|
||||
List<String> actionTypes = req.getNodes().stream()
|
||||
.filter(n -> "action".equals(n.getCategory()))
|
||||
.map(IotRuleChainSaveReqVO.NodeVO::getType)
|
||||
.toList();
|
||||
assertThat(actionTypes).containsExactlyInAnyOrder("alarm_trigger", "device_property_set", "device_service_invoke");
|
||||
|
||||
// 3 links (trigger→action1, action1→action2, action2→action3)
|
||||
assertThat(req.getLinks()).hasSize(3);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 追加:SceneRuleToChainMapper 直接测试(trigger/action type mapping)
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@DisplayName("triggerType_mapping: 各 v1 Trigger.type 正确映射到 v2 type 字符串")
|
||||
void testTriggerTypeMapping() {
|
||||
assertThat(SceneRuleToChainMapper.mapTriggerType(
|
||||
IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE.getType())).isEqualTo("device_state");
|
||||
assertThat(SceneRuleToChainMapper.mapTriggerType(
|
||||
IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType())).isEqualTo("device_property");
|
||||
assertThat(SceneRuleToChainMapper.mapTriggerType(
|
||||
IotSceneRuleTriggerTypeEnum.DEVICE_EVENT_POST.getType())).isEqualTo("device_event");
|
||||
assertThat(SceneRuleToChainMapper.mapTriggerType(
|
||||
IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType())).isEqualTo("device_service");
|
||||
assertThat(SceneRuleToChainMapper.mapTriggerType(
|
||||
IotSceneRuleTriggerTypeEnum.TIMER.getType())).isEqualTo("timer");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("actionType_mapping: 各 v1 Action.type 正确映射到 v2 type 字符串")
|
||||
void testActionTypeMapping() {
|
||||
assertThat(SceneRuleToChainMapper.mapActionType(
|
||||
IotSceneRuleActionTypeEnum.DEVICE_PROPERTY_SET.getType())).isEqualTo("device_property_set");
|
||||
assertThat(SceneRuleToChainMapper.mapActionType(
|
||||
IotSceneRuleActionTypeEnum.DEVICE_SERVICE_INVOKE.getType())).isEqualTo("device_service_invoke");
|
||||
assertThat(SceneRuleToChainMapper.mapActionType(
|
||||
IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType())).isEqualTo("alarm_trigger");
|
||||
assertThat(SceneRuleToChainMapper.mapActionType(
|
||||
IotSceneRuleActionTypeEnum.ALERT_RECOVER.getType())).isEqualTo("alarm_clear");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Mock helpers
|
||||
// =========================================================================
|
||||
|
||||
/** 模拟该规则未迁移(COUNT=0) */
|
||||
private void mockNotMigrated(Long ruleId) {
|
||||
when(jdbcTemplate.queryForObject(
|
||||
contains("COUNT(1)"),
|
||||
eq(Integer.class),
|
||||
eq(ruleId)
|
||||
)).thenReturn(0);
|
||||
}
|
||||
|
||||
/** 模拟 INSERT 映射表正常写入 */
|
||||
private void mockInsertMapping() {
|
||||
when(jdbcTemplate.update(anyString(), any(Object[].class))).thenReturn(1);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DO builders
|
||||
// =========================================================================
|
||||
|
||||
private IotSceneRuleDO buildRule(Long id, String name,
|
||||
IotSceneRuleDO.Trigger trigger,
|
||||
List<IotSceneRuleDO.Action> actions) {
|
||||
IotSceneRuleDO rule = new IotSceneRuleDO();
|
||||
rule.setId(id);
|
||||
rule.setName(name);
|
||||
rule.setStatus(0);
|
||||
rule.setTriggers(List.of(trigger));
|
||||
rule.setActions(actions);
|
||||
return rule;
|
||||
}
|
||||
|
||||
private IotSceneRuleDO.Trigger buildTrigger(Integer type, Long productId, Long deviceId,
|
||||
String identifier, String cronExpression,
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups) {
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(type);
|
||||
trigger.setProductId(productId);
|
||||
trigger.setDeviceId(deviceId);
|
||||
trigger.setIdentifier(identifier);
|
||||
trigger.setCronExpression(cronExpression);
|
||||
trigger.setConditionGroups(conditionGroups);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
private IotSceneRuleDO.Action buildAction(Integer type, Long productId, Long deviceId,
|
||||
String identifier, String params, Long alertConfigId) {
|
||||
IotSceneRuleDO.Action action = new IotSceneRuleDO.Action();
|
||||
action.setType(type);
|
||||
action.setProductId(productId);
|
||||
action.setDeviceId(deviceId);
|
||||
action.setIdentifier(identifier);
|
||||
action.setParams(params);
|
||||
action.setAlertConfigId(alertConfigId);
|
||||
return action;
|
||||
}
|
||||
|
||||
private MigrationExecuteReqVO buildOpts(String migrator, boolean force) {
|
||||
MigrationExecuteReqVO opts = new MigrationExecuteReqVO();
|
||||
opts.setMigrator(migrator);
|
||||
opts.setForce(force);
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** 从 req 中按 category 取第一个节点 */
|
||||
private IotRuleChainSaveReqVO.NodeVO findNode(IotRuleChainSaveReqVO req, String category) {
|
||||
return req.getNodes().stream()
|
||||
.filter(n -> category.equals(n.getCategory()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user