Add business loop validation and coverage audit

This commit is contained in:
王鹏
2026-06-29 10:35:22 +08:00
parent 8c63b8d024
commit 39c363069d
4 changed files with 761 additions and 0 deletions

View File

@@ -0,0 +1,257 @@
package com.ruoyi.generator.service.front;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.generator.domain.front.dto.BusinessActionDesign;
import com.ruoyi.generator.domain.front.dto.BusinessActionEffectDesign;
import com.ruoyi.generator.domain.front.dto.BusinessLoopAuditResult;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopAction;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopQuantityEffect;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopRecordEffect;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopStateMachine;
import com.ruoyi.generator.domain.front.dto.DatabaseColumnDesign;
import com.ruoyi.generator.domain.front.dto.DatabaseDesignResponse;
import com.ruoyi.generator.domain.front.dto.DatabaseTableDesign;
@Component
public class BusinessLoopCoverageValidator
{
public BusinessLoopAuditResult auditDatabase(BusinessLoopPlan plan, DatabaseDesignResponse database)
{
BusinessLoopAuditResult result = newResult(plan);
Map<String, Set<String>> columnsByTable = collectColumnsByTable(database);
if (plan == null)
{
result.getErrors().add("Business loop plan is required");
finish(result);
return result;
}
for (BusinessLoopStateMachine stateMachine : safe(plan.getStateMachines()))
{
if (stateMachine == null)
{
continue;
}
if (!hasColumn(columnsByTable, stateMachine.getTableName(), stateMachine.getStatusField()))
{
result.getErrors().add("Missing state status field: "
+ formatField(stateMachine.getTableName(), stateMachine.getStatusField()));
}
}
for (BusinessLoopQuantityEffect quantityRule : safe(plan.getQuantityRules()))
{
if (quantityRule == null)
{
continue;
}
if (!hasColumn(columnsByTable, quantityRule.getTableName(), quantityRule.getQuantityField()))
{
result.getErrors().add("Missing quantity field: "
+ formatField(quantityRule.getTableName(), quantityRule.getQuantityField()));
}
}
Set<String> tableNames = columnsByTable.keySet();
for (BusinessLoopRecordEffect recordRule : safe(plan.getRecordRules()))
{
if (recordRule == null)
{
continue;
}
if (!tableNames.contains(normalize(recordRule.getTableName())))
{
result.getErrors().add("Missing record table: " + StringUtils.defaultString(recordRule.getTableName()));
}
}
finish(result);
return result;
}
public BusinessLoopAuditResult auditBusinessActions(BusinessLoopPlan plan, List<BusinessActionDesign> actions)
{
BusinessLoopAuditResult result = newResult(plan);
Map<String, BusinessActionDesign> actionsByCode = collectActionsByCode(actions);
if (plan == null)
{
result.getErrors().add("Business loop plan is required");
finish(result);
return result;
}
for (BusinessLoopAction planAction : safe(plan.getActions()))
{
if (planAction == null || !planAction.isRequired())
{
continue;
}
BusinessActionDesign action = actionsByCode.get(planAction.getCode());
if (action == null)
{
result.getMissingRequiredActions().add(planAction.getCode());
continue;
}
validateRequiredEffects(result, planAction, action);
}
finish(result);
return result;
}
private void validateRequiredEffects(BusinessLoopAuditResult result, BusinessLoopAction planAction,
BusinessActionDesign action)
{
if (size(planAction.getStateTransitions()) > 0 && !hasAnyEffect(action, "SET_STATUS", "UPDATE_FIELDS", "UPDATE_FIELD"))
{
result.getErrors().add("Missing status effect for required action: " + planAction.getCode());
}
if (size(planAction.getQuantityEffects()) > 0 && !hasAnyEffect(action, "INCREASE_NUMBER", "DECREASE_NUMBER"))
{
result.getErrors().add("Missing quantity effect for required action: " + planAction.getCode());
}
if (size(planAction.getRecordEffects()) > 0 && !hasAnyEffect(action, "INSERT_ROW", "UPDATE_FIELDS"))
{
result.getErrors().add("Missing record effect for required action: " + planAction.getCode());
}
}
private boolean hasAnyEffect(BusinessActionDesign action, String... types)
{
if (action == null || action.getEffects() == null)
{
return false;
}
Set<String> expectedTypes = new HashSet<String>();
for (String type : types)
{
expectedTypes.add(type);
}
for (BusinessActionEffectDesign effect : action.getEffects())
{
if (effect == null)
{
continue;
}
String type = StringUtils.defaultString(effect.getType()).toUpperCase(Locale.ENGLISH);
if (expectedTypes.contains(type))
{
return true;
}
}
return false;
}
private Map<String, BusinessActionDesign> collectActionsByCode(List<BusinessActionDesign> actions)
{
Map<String, BusinessActionDesign> actionsByCode = new HashMap<String, BusinessActionDesign>();
if (actions == null)
{
return actionsByCode;
}
for (BusinessActionDesign action : actions)
{
if (action != null && StringUtils.isNotBlank(action.getCode()))
{
actionsByCode.put(action.getCode(), action);
}
}
return actionsByCode;
}
private Map<String, Set<String>> collectColumnsByTable(DatabaseDesignResponse database)
{
Map<String, Set<String>> columnsByTable = new HashMap<String, Set<String>>();
if (database == null || database.getTables() == null)
{
return columnsByTable;
}
for (DatabaseTableDesign table : database.getTables())
{
if (table == null || StringUtils.isBlank(table.getTableName()))
{
continue;
}
Set<String> columns = new HashSet<String>();
if (table.getColumns() != null)
{
for (DatabaseColumnDesign column : table.getColumns())
{
if (column != null && StringUtils.isNotBlank(column.getColumnName()))
{
columns.add(normalize(column.getColumnName()));
}
}
}
columnsByTable.put(normalize(table.getTableName()), columns);
}
return columnsByTable;
}
private boolean hasColumn(Map<String, Set<String>> columnsByTable, String tableName, String columnName)
{
Set<String> columns = columnsByTable.get(normalize(tableName));
return columns != null && columns.contains(normalize(columnName));
}
private String formatField(String tableName, String fieldName)
{
return StringUtils.defaultString(tableName) + "." + StringUtils.defaultString(fieldName);
}
private BusinessLoopAuditResult newResult(BusinessLoopPlan plan)
{
BusinessLoopAuditResult result = new BusinessLoopAuditResult();
result.setLoopActions(size(plan == null ? null : plan.getActions()));
result.setStateTransitions(countStateTransitions(plan));
result.setQuantityRules(size(plan == null ? null : plan.getQuantityRules()));
result.setRecordRules(size(plan == null ? null : plan.getRecordRules()));
return result;
}
private void finish(BusinessLoopAuditResult result)
{
result.setComplete(result.getErrors().isEmpty() && result.getMissingRequiredActions().isEmpty());
}
private int countStateTransitions(BusinessLoopPlan plan)
{
if (plan == null)
{
return 0;
}
int count = 0;
for (BusinessLoopStateMachine stateMachine : safe(plan.getStateMachines()))
{
if (stateMachine != null)
{
count += size(stateMachine.getTransitions());
}
}
for (BusinessLoopAction action : safe(plan.getActions()))
{
if (action != null)
{
count += size(action.getStateTransitions());
}
}
return count;
}
private String normalize(String value)
{
return StringUtils.defaultString(value).toLowerCase(Locale.ENGLISH);
}
private <T> List<T> safe(List<T> items)
{
return items == null ? java.util.Collections.<T>emptyList() : items;
}
private int size(List<?> items)
{
return items == null ? 0 : items.size();
}
}

View File

@@ -0,0 +1,116 @@
package com.ruoyi.generator.service.front;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopAction;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopObject;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopStateMachine;
@Component
public class BusinessLoopPlanValidator
{
private static final int MAX_CORE_OBJECTS = 20;
private static final int MAX_ACTIONS = 30;
private static final Pattern ACTION_CODE = Pattern.compile("^[a-z][a-z0-9_]{1,63}$");
public void validate(BusinessLoopPlan plan)
{
if (plan == null)
{
throw new ServiceException("Business loop plan is required");
}
validateCoreObjects(plan.getCoreObjects());
validateActions(plan.getActions());
if (countStateTransitions(plan) == 0)
{
throw new ServiceException("Business loop must contain at least one state transition");
}
if (size(plan.getQuantityRules()) == 0 && size(plan.getRecordRules()) == 0)
{
throw new ServiceException("Business loop must contain at least one quantity or record rule");
}
}
private void validateCoreObjects(List<BusinessLoopObject> coreObjects)
{
if (size(coreObjects) == 0)
{
throw new ServiceException("Business loop core objects cannot be empty");
}
if (coreObjects.size() > MAX_CORE_OBJECTS)
{
throw new ServiceException("Business loop core object count exceeds limit");
}
}
private void validateActions(List<BusinessLoopAction> actions)
{
if (size(actions) == 0)
{
throw new ServiceException("Business loop actions cannot be empty");
}
if (actions.size() > MAX_ACTIONS)
{
throw new ServiceException("Business loop action count exceeds limit");
}
Set<String> actionCodes = new HashSet<String>();
boolean hasRequiredAction = false;
for (BusinessLoopAction action : actions)
{
if (action == null)
{
throw new ServiceException("Business loop action cannot be empty");
}
if (StringUtils.isBlank(action.getCode()) || !ACTION_CODE.matcher(action.getCode()).matches())
{
throw new ServiceException("Business loop action code is invalid");
}
if (!actionCodes.add(action.getCode()))
{
throw new ServiceException("Business loop action code duplicated: " + action.getCode());
}
hasRequiredAction = hasRequiredAction || action.isRequired();
}
if (!hasRequiredAction)
{
throw new ServiceException("Business loop must contain at least one required action");
}
}
private int countStateTransitions(BusinessLoopPlan plan)
{
int count = 0;
if (plan.getStateMachines() != null)
{
for (BusinessLoopStateMachine stateMachine : plan.getStateMachines())
{
if (stateMachine != null)
{
count += size(stateMachine.getTransitions());
}
}
}
if (plan.getActions() != null)
{
for (BusinessLoopAction action : plan.getActions())
{
if (action != null)
{
count += size(action.getStateTransitions());
}
}
}
return count;
}
private int size(List<?> items)
{
return items == null ? 0 : items.size();
}
}

View File

@@ -0,0 +1,193 @@
package com.ruoyi.generator.service.front;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import java.util.Arrays;
import java.util.Collections;
import org.junit.Test;
import com.ruoyi.generator.domain.front.dto.BusinessActionDesign;
import com.ruoyi.generator.domain.front.dto.BusinessActionEffectDesign;
import com.ruoyi.generator.domain.front.dto.BusinessLoopAuditResult;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopAction;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopObject;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopQuantityEffect;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopRecordEffect;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopStateMachine;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopStateTransition;
import com.ruoyi.generator.domain.front.dto.DatabaseColumnDesign;
import com.ruoyi.generator.domain.front.dto.DatabaseDesignResponse;
import com.ruoyi.generator.domain.front.dto.DatabaseTableDesign;
public class BusinessLoopCoverageValidatorTest
{
private final BusinessLoopCoverageValidator validator = new BusinessLoopCoverageValidator();
@Test
public void databaseAuditPassesWhenTablesAndFieldsCoverPlan()
{
BusinessLoopAuditResult result = validator.auditDatabase(completePlan(), completeDatabase());
assertTrue(result.isComplete());
assertTrue(result.getErrors().isEmpty());
assertEquals(1, result.getLoopActions());
assertEquals(2, result.getStateTransitions());
assertEquals(1, result.getQuantityRules());
assertEquals(1, result.getRecordRules());
}
@Test
public void databaseAuditFailsWhenQuantityFieldMissing()
{
DatabaseDesignResponse database = completeDatabase();
database.getTables().get(1).setColumns(Collections.<DatabaseColumnDesign>emptyList());
BusinessLoopAuditResult result = validator.auditDatabase(completePlan(), database);
assertFalse(result.isComplete());
assertTrue(contains(result.getErrors().toString(), "available_count"));
}
@Test
public void businessActionAuditFailsWhenRequiredActionMissing()
{
BusinessLoopAuditResult result = validator.auditBusinessActions(completePlan(),
Collections.<BusinessActionDesign>emptyList());
assertFalse(result.isComplete());
assertTrue(result.getMissingRequiredActions().contains("confirm_borrow"));
}
@Test
public void businessActionAuditPassesWhenRequiredStatusAndQuantityEffectsExist()
{
BusinessActionDesign action = new BusinessActionDesign();
action.setCode("confirm_borrow");
action.setEffects(Arrays.asList(effect("SET_STATUS"), effect("DECREASE_NUMBER")));
BusinessLoopAuditResult result = validator.auditBusinessActions(completePlan(), Arrays.asList(action));
assertTrue(result.isComplete());
assertTrue(result.getMissingRequiredActions().isEmpty());
assertTrue(result.getErrors().isEmpty());
}
private BusinessLoopPlan completePlan()
{
BusinessLoopPlan plan = new BusinessLoopPlan();
plan.setDomainName("library");
plan.setCoreObjects(Arrays.asList(coreObject()));
plan.setStateMachines(Arrays.asList(stateMachine()));
plan.setActions(Arrays.asList(requiredAction()));
plan.setQuantityRules(Arrays.asList(quantityRule()));
plan.setRecordRules(Arrays.asList(recordRule()));
return plan;
}
private BusinessLoopObject coreObject()
{
BusinessLoopObject object = new BusinessLoopObject();
object.setCode("borrow_order");
object.setName("Borrow order");
object.setTableName("borrow_order");
return object;
}
private BusinessLoopStateMachine stateMachine()
{
BusinessLoopStateMachine stateMachine = new BusinessLoopStateMachine();
stateMachine.setObjectCode("borrow_order");
stateMachine.setTableName("borrow_order");
stateMachine.setStatusField("borrow_status");
stateMachine.setStates(Arrays.asList("submitted", "borrowed"));
stateMachine.setTransitions(Arrays.asList(transition()));
return stateMachine;
}
private BusinessLoopAction requiredAction()
{
BusinessLoopAction action = new BusinessLoopAction();
action.setCode("confirm_borrow");
action.setName("Confirm borrow");
action.setOwnerTable("borrow_order");
action.setRequired(true);
action.setStateTransitions(Arrays.asList(transition()));
action.setQuantityEffects(Arrays.asList(quantityRule()));
return action;
}
private BusinessLoopStateTransition transition()
{
BusinessLoopStateTransition transition = new BusinessLoopStateTransition();
transition.setActionCode("confirm_borrow");
transition.setFromState("submitted");
transition.setToState("borrowed");
return transition;
}
private BusinessLoopQuantityEffect quantityRule()
{
BusinessLoopQuantityEffect effect = new BusinessLoopQuantityEffect();
effect.setActionCode("confirm_borrow");
effect.setObjectCode("book_stock");
effect.setTableName("book_stock");
effect.setQuantityField("available_count");
effect.setEffectType("decrease");
effect.setAmount("1");
return effect;
}
private BusinessLoopRecordEffect recordRule()
{
BusinessLoopRecordEffect effect = new BusinessLoopRecordEffect();
effect.setActionCode("confirm_borrow");
effect.setObjectCode("borrow_record");
effect.setTableName("borrow_record");
effect.setOperation("insert");
return effect;
}
private DatabaseDesignResponse completeDatabase()
{
DatabaseDesignResponse response = new DatabaseDesignResponse();
response.setTables(Arrays.asList(
table("borrow_order", "id", "borrow_status"),
table("book_stock", "id", "available_count"),
table("borrow_record", "id")));
return response;
}
private DatabaseTableDesign table(String tableName, String... columnNames)
{
DatabaseTableDesign table = new DatabaseTableDesign();
table.setTableName(tableName);
DatabaseColumnDesign[] columns = new DatabaseColumnDesign[columnNames.length];
for (int i = 0; i < columnNames.length; i++)
{
columns[i] = column(columnNames[i]);
}
table.setColumns(Arrays.asList(columns));
return table;
}
private DatabaseColumnDesign column(String columnName)
{
DatabaseColumnDesign column = new DatabaseColumnDesign();
column.setColumnName(columnName);
return column;
}
private BusinessActionEffectDesign effect(String type)
{
BusinessActionEffectDesign effect = new BusinessActionEffectDesign();
effect.setType(type);
return effect;
}
private boolean contains(String text, String needle)
{
return text != null && text.contains(needle);
}
}

View File

@@ -0,0 +1,195 @@
package com.ruoyi.generator.service.front;
import static org.junit.Assert.assertTrue;
import java.util.Arrays;
import java.util.Collections;
import org.junit.Test;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopAction;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopObject;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopQuantityEffect;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopRecordEffect;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopStateMachine;
import com.ruoyi.generator.domain.front.dto.BusinessLoopPlan.BusinessLoopStateTransition;
public class BusinessLoopPlanValidatorTest
{
private final BusinessLoopPlanValidator validator = new BusinessLoopPlanValidator();
@Test
public void acceptsCompleteLoopPlan()
{
validator.validate(completePlan());
}
@Test
public void rejectsPlanWithoutStateTransition()
{
BusinessLoopPlan plan = completePlan();
plan.getStateMachines().get(0).setTransitions(Collections.<BusinessLoopStateTransition>emptyList());
plan.getActions().get(0).setStateTransitions(Collections.<BusinessLoopStateTransition>emptyList());
ServiceException exception = expectServiceException(new ThrowingRunnable()
{
@Override
public void run()
{
validator.validate(plan);
}
});
assertTrue(exception.getMessage().contains("state transition"));
}
@Test
public void rejectsPlanWithoutQuantityOrRecordRule()
{
BusinessLoopPlan plan = completePlan();
plan.setQuantityRules(Collections.<BusinessLoopQuantityEffect>emptyList());
plan.setRecordRules(Collections.<BusinessLoopRecordEffect>emptyList());
ServiceException exception = expectServiceException(new ThrowingRunnable()
{
@Override
public void run()
{
validator.validate(plan);
}
});
assertTrue(exception.getMessage().contains("quantity or record"));
}
@Test
public void rejectsDuplicatedActionCode()
{
BusinessLoopPlan plan = completePlan();
plan.setActions(Arrays.asList(requiredAction("confirm_borrow"), requiredAction("confirm_borrow")));
ServiceException exception = expectServiceException(new ThrowingRunnable()
{
@Override
public void run()
{
validator.validate(plan);
}
});
assertTrue(exception.getMessage().contains("duplicated"));
}
@Test
public void rejectsPlanWithoutRequiredAction()
{
BusinessLoopPlan plan = completePlan();
plan.getActions().get(0).setRequired(false);
ServiceException exception = expectServiceException(new ThrowingRunnable()
{
@Override
public void run()
{
validator.validate(plan);
}
});
assertTrue(exception.getMessage().contains("required action"));
}
private BusinessLoopPlan completePlan()
{
BusinessLoopPlan plan = new BusinessLoopPlan();
plan.setDomainName("library");
plan.setCoreObjects(Arrays.asList(coreObject()));
plan.setStateMachines(Arrays.asList(stateMachine()));
plan.setActions(Arrays.asList(requiredAction("confirm_borrow")));
plan.setQuantityRules(Arrays.asList(quantityRule()));
plan.setRecordRules(Arrays.asList(recordRule()));
return plan;
}
private BusinessLoopObject coreObject()
{
BusinessLoopObject object = new BusinessLoopObject();
object.setCode("borrow_order");
object.setName("Borrow order");
object.setTableName("borrow_order");
return object;
}
private BusinessLoopStateMachine stateMachine()
{
BusinessLoopStateMachine stateMachine = new BusinessLoopStateMachine();
stateMachine.setObjectCode("borrow_order");
stateMachine.setTableName("borrow_order");
stateMachine.setStatusField("borrow_status");
stateMachine.setStates(Arrays.asList("submitted", "borrowed"));
stateMachine.setTransitions(Arrays.asList(transition()));
return stateMachine;
}
private BusinessLoopAction requiredAction(String code)
{
BusinessLoopAction action = new BusinessLoopAction();
action.setCode(code);
action.setName("Confirm borrow");
action.setActor("librarian");
action.setOwnerObject("borrow_order");
action.setOwnerTable("borrow_order");
action.setRequired(true);
action.setStateTransitions(Arrays.asList(transition()));
action.setQuantityEffects(Arrays.asList(quantityRule()));
return action;
}
private BusinessLoopStateTransition transition()
{
BusinessLoopStateTransition transition = new BusinessLoopStateTransition();
transition.setActionCode("confirm_borrow");
transition.setFromState("submitted");
transition.setToState("borrowed");
return transition;
}
private BusinessLoopQuantityEffect quantityRule()
{
BusinessLoopQuantityEffect effect = new BusinessLoopQuantityEffect();
effect.setActionCode("confirm_borrow");
effect.setObjectCode("book_stock");
effect.setTableName("book_stock");
effect.setQuantityField("available_count");
effect.setEffectType("decrease");
effect.setAmount("1");
return effect;
}
private BusinessLoopRecordEffect recordRule()
{
BusinessLoopRecordEffect effect = new BusinessLoopRecordEffect();
effect.setActionCode("confirm_borrow");
effect.setObjectCode("borrow_record");
effect.setTableName("borrow_record");
effect.setOperation("insert");
return effect;
}
private ServiceException expectServiceException(ThrowingRunnable runnable)
{
try
{
runnable.run();
}
catch (ServiceException e)
{
return e;
}
throw new AssertionError("Expected ServiceException");
}
private interface ThrowingRunnable
{
void run();
}
}