Sfoglia il codice sorgente

feat: 完善梯形图显式连接模型

main
suyu 3 settimane fa
parent
commit
f1af345409
16 ha cambiato i file con 1485 aggiunte e 139 eliminazioni
  1. +94
    -1
      app/src/domain/control_logic_model.cpp
  2. +18
    -2
      app/src/domain/control_logic_model.h
  3. +25
    -2
      app/src/infrastructure/json_project_storage.cpp
  4. +1
    -1
      app/src/infrastructure/json_project_storage.h
  5. +2
    -0
      app/src/main.cpp
  6. +637
    -34
      app/src/services/logic_editor_service.cpp
  7. +30
    -1
      app/src/services/logic_editor_service.h
  8. +10
    -1
      app/src/services/software_logic_executor.cpp
  9. +175
    -35
      app/src/ui/logic_editor_widget.cpp
  10. +3
    -0
      app/src/ui/logic_editor_widget.h
  11. +75
    -12
      app/tests/domain_tests.cpp
  12. +238
    -28
      app/tests/logic_editor_service_tests.cpp
  13. +42
    -8
      app/tests/offline_simulation_service_tests.cpp
  14. +27
    -2
      app/tests/performance_tests.cpp
  15. +87
    -8
      app/tests/project_management_tests.cpp
  16. +21
    -4
      app/tests/runtime_mode_service_tests.cpp

+ 94
- 1
app/src/domain/control_logic_model.cpp Vedi File

@@ -164,7 +164,8 @@ void normalizeExpression(ConditionExpression *expression)
{
if (expression == nullptr
|| expression->kind == ConditionExpressionKind::Node
|| expression->kind == ConditionExpressionKind::Wire)
|| expression->kind == ConditionExpressionKind::Wire
|| expression->kind == ConditionExpressionKind::Gap)
{
return;
}
@@ -243,6 +244,7 @@ bool validateConditionExpression(
if (expression.kind == ConditionExpressionKind::Node)
{
if (!expression.node.has_value() || expression.wire.has_value()
|| expression.gap.has_value()
|| !expression.children.empty() || !expression.node->isCondition())
{
setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式");
@@ -255,6 +257,7 @@ bool validateConditionExpression(
if (expression.kind == ConditionExpressionKind::Wire)
{
if (expression.node.has_value() || !expression.wire.has_value()
|| expression.gap.has_value()
|| !expression.children.empty())
{
setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式");
@@ -268,6 +271,22 @@ bool validateConditionExpression(
*rows = 1;
return true;
}
if (expression.kind == ConditionExpressionKind::Gap)
{
if (expression.node.has_value() || expression.wire.has_value()
|| !expression.gap.has_value() || !expression.children.empty())
{
setError(error, "断路叶节点必须包含断路配置且不能包含逻辑节点或子表达式");
return false;
}
if (!expression.gap->validate(error))
{
return false;
}
*columns = expression.gap->columnSpan;
*rows = 1;
return true;
}
if (expression.kind != ConditionExpressionKind::Series
&& expression.kind != ConditionExpressionKind::Parallel)
{
@@ -275,6 +294,7 @@ bool validateConditionExpression(
return false;
}
if (expression.node.has_value() || expression.wire.has_value()
|| expression.gap.has_value()
|| expression.children.size() < 2U)
{
setError(error, "串联和并联表达式至少需要两个子表达式");
@@ -288,6 +308,7 @@ bool validateConditionExpression(

int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1;
int parallel_columns = -1;
for (const ConditionExpression &child : expression.children)
{
if (child.kind == expression.kind)
@@ -316,6 +337,12 @@ bool validateConditionExpression(
}
else
{
if (parallel_columns >= 0 && child_columns != parallel_columns)
{
setError(error, "并联各支路必须使用显式横线或断路补齐到相同列宽");
return false;
}
parallel_columns = child_columns;
total_columns = std::max(total_columns, child_columns);
if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows)
{
@@ -336,6 +363,30 @@ bool validateConditionExpression(
return true;
}

int conditionColumnCount(const ConditionExpression &expression)
{
if (expression.kind == ConditionExpressionKind::Node)
{
return 1;
}
if (expression.kind == ConditionExpressionKind::Wire)
{
return expression.wire->columnSpan;
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return expression.gap->columnSpan;
}
int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
for (const ConditionExpression &child : expression.children)
{
const int child_columns = conditionColumnCount(child);
columns = expression.kind == ConditionExpressionKind::Series
? columns + child_columns : std::max(columns, child_columns);
}
return columns;
}

} // namespace

bool WordOperand::validate(std::string *error) const
@@ -481,6 +532,16 @@ bool WireSegment::validate(std::string *error) const
return true;
}

bool GapSegment::validate(std::string *error) const
{
if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan)
{
setError(error, "断路跨度必须在 1~10 列范围内");
return false;
}
return true;
}

ConditionExpression ConditionExpression::fromWire(
std::string expression_id, int column_span)
{
@@ -491,6 +552,16 @@ ConditionExpression ConditionExpression::fromWire(
return expression;
}

ConditionExpression ConditionExpression::fromGap(
std::string expression_id, int column_span)
{
ConditionExpression expression;
expression.id = std::move(expression_id);
expression.kind = ConditionExpressionKind::Gap;
expression.gap = GapSegment{column_span};
return expression;
}

bool ConditionExpression::validate(std::string *error) const
{
std::size_t node_count = 0U;
@@ -519,6 +590,11 @@ bool ConditionExpression::validateForRunning(std::string *error) const
{
return true;
}
if (kind == ConditionExpressionKind::Gap)
{
setError(error, "梯形图条件区存在未连接的空白网格");
return false;
}
for (const ConditionExpression &child : children)
{
if (!child.validateForRunning(error))
@@ -539,6 +615,7 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression
normalizeExpression(&expression->value());
if (expression->value().kind != ConditionExpressionKind::Node
&& expression->value().kind != ConditionExpressionKind::Wire
&& expression->value().kind != ConditionExpressionKind::Gap
&& expression->value().children.empty())
{
expression->reset();
@@ -552,6 +629,10 @@ const LogicNode *findConditionNode(
{
return nullptr;
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return nullptr;
}
if (expression.kind == ConditionExpressionKind::Node)
{
return expression.node->id == node_id ? &*expression.node : nullptr;
@@ -614,6 +695,10 @@ void collectConditionNodes(
{
return;
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return;
}
for (const ConditionExpression &child : expression.children)
{
collectConditionNodes(child, nodes);
@@ -707,6 +792,14 @@ bool LadderRung::validateStructure(std::string *error) const
node_ids.push_back(node->id);
}
}
if (output.has_value()
&& (!condition.has_value()
|| conditionColumnCount(*condition)
!= ProjectLimits::kMaximumConditionColumns))
{
setError(error, "带输出的梯形图网络必须用条件、横线或断路显式占满前 10 列");
return false;
}
if (output.has_value())
{
if (!output->validate(error) || !output->isOutput())


+ 18
- 2
app/src/domain/control_logic_model.h Vedi File

@@ -152,6 +152,7 @@ enum class ConditionExpressionKind
{
Node, // 一个实际的条件节点
Wire, // 一段横线
Gap, // 一段明确断开的空白网格
Series, // 多个条件串联,必须全部满足
Parallel // 多个条件并联,满足任意一条即可
};
@@ -168,6 +169,18 @@ struct WireSegment
bool validate(std::string *error = nullptr) const;
};

// 条件区中的持久化断路;columnSpan 表示连续空白网格列数
struct GapSegment
{
static constexpr int kMinimumColumnSpan = 1;
static constexpr int kMaximumColumnSpan =
ProjectLimits::kMaximumConditionColumns;

int columnSpan = 1; // 断路占用的网格列数

bool validate(std::string *error = nullptr) const;
};

// 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接
struct ConditionExpression
{
@@ -175,12 +188,15 @@ struct ConditionExpression
ConditionExpressionKind kind = ConditionExpressionKind::Node; // 表达式类型
std::optional<LogicNode> node; // kind 为 Node 时保存的节点
std::optional<WireSegment> wire; // kind 为 Wire 时保存的横线
std::optional<GapSegment> gap; // kind 为 Gap 时保存的断路
std::vector<ConditionExpression> children; // 串联或并联的子表达式

// 把一个逻辑节点包装成条件表达式
static ConditionExpression fromNode(LogicNode node);
// 创建一段指定列数的横线表达式
static ConditionExpression fromWire(std::string id, int column_span = 1);
// 创建一段指定列数的断路表达式
static ConditionExpression fromGap(std::string id, int column_span = 1);
// 编辑态校验允许空配置节点,但必须保持树结构合法
bool validate(std::string *error = nullptr) const;
// 运行态校验额外要求每个节点都已经配置完成
@@ -214,13 +230,13 @@ void collectConditionNodes(
void collectConditionExpressionIds(
const ConditionExpression &expression, std::vector<std::string> *ids);

// 条件表达式为空时表示恒真网络,输出指令固定在最右侧
// 输出指令固定在第 11 列;有输出时前 10 列必须由显式条件、横线或断路占满
struct LadderRung
{
std::string id; // 网络唯一 ID
std::string name; // 网络名称
std::string comment; // 网络注释
std::optional<ConditionExpression> condition; // 条件表达式,没有时表示恒真
std::optional<ConditionExpression> condition; // 条件区表达式;空值只用于空网络草稿
std::optional<LogicNode> output; // 网络右侧的输出指令

// 编辑态允许没有条件或输出,便于逐步搭建网络


+ 25
- 2
app/src/infrastructure/json_project_storage.cpp Vedi File

@@ -1308,6 +1308,8 @@ QString expressionKindText(ConditionExpressionKind kind)
return QStringLiteral("node");
case ConditionExpressionKind::Wire:
return QStringLiteral("wire");
case ConditionExpressionKind::Gap:
return QStringLiteral("gap");
case ConditionExpressionKind::Series:
return QStringLiteral("series");
case ConditionExpressionKind::Parallel:
@@ -1329,6 +1331,10 @@ QJsonObject serializeConditionExpression(const ConditionExpression &expression)
{
object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan);
}
else if (expression.kind == ConditionExpressionKind::Gap)
{
object.insert(QStringLiteral("columnSpan"), expression.gap->columnSpan);
}
else
{
QJsonArray children;
@@ -1404,11 +1410,29 @@ bool parseConditionExpression(
expression->wire = WireSegment{column_span};
return true;
}
if (kind == "gap")
{
int column_span = 0;
if (!readInt(
object,
"columnSpan",
context,
GapSegment::kMinimumColumnSpan,
GapSegment::kMaximumColumnSpan,
&column_span,
state))
{
return false;
}
expression->kind = ConditionExpressionKind::Gap;
expression->gap = GapSegment{column_span};
return true;
}
if (kind != "series" && kind != "parallel")
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".kind 必须是 node、wire、series 或 parallel");
context + ".kind 必须是 node、wire、gap、series 或 parallel");
}
QJsonArray children;
if (!readArray(
@@ -1875,7 +1899,6 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path)
{
return {false, {}, state.error, state.message};
}

// JSON 字段合法不代表业务关系合法,还需执行领域层整体校验
std::string validation_error;
if (!project.validate(&validation_error))


+ 1
- 1
app/src/infrastructure/json_project_storage.h Vedi File

@@ -2,7 +2,7 @@

#include "domain/project_storage.h"

// 使用严格 1.0 JSON 文件保存和加载工程;加载成功后才替换调用方工程
// 严格读写当前 1.0 JSON 工程格式
class JsonProjectStorage final : public ProjectStorage
{
public:


+ 2
- 0
app/src/main.cpp Vedi File

@@ -29,6 +29,8 @@
// 程序从这里开始,argc 和 argv 保存用户启动程序时附带的命令行参数
int main(int argc, char *argv[])
{
// Windows 默认会给对话框添加上下文帮助按钮,应用内统一隐藏
QApplication::setAttribute(Qt::AA_DisableWindowContextHelpButton);
// 创建整个 Qt 应用,后面的窗口、按钮和消息循环都要依靠它
QApplication application(argc, argv);
// 设置程序名称,Qt 的窗口信息和配置中会用到这个名字


+ 637
- 34
app/src/services/logic_editor_service.cpp Vedi File

@@ -147,6 +147,10 @@ int expressionColumns(const ConditionExpression &expression)
{
return expression.wire->columnSpan;
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return expression.gap->columnSpan;
}
int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
for (const ConditionExpression &child : expression.children)
{
@@ -369,7 +373,8 @@ bool addParallelForWireCellRange(
return true;
}
if (expression->kind == ConditionExpressionKind::Node
|| expression->kind == ConditionExpressionKind::Wire)
|| expression->kind == ConditionExpressionKind::Wire
|| expression->kind == ConditionExpressionKind::Gap)
{
return false;
}
@@ -405,7 +410,8 @@ bool findWireCellLocation(
WireCellLocation *location)
{
if (expression.kind == ConditionExpressionKind::Node
|| expression.kind == ConditionExpressionKind::Wire)
|| expression.kind == ConditionExpressionKind::Wire
|| expression.kind == ConditionExpressionKind::Gap)
{
return false;
}
@@ -517,7 +523,8 @@ bool removeExpressionRecursive(
{
if (expression == nullptr
|| expression->kind == ConditionExpressionKind::Node
|| expression->kind == ConditionExpressionKind::Wire)
|| expression->kind == ConditionExpressionKind::Wire
|| expression->kind == ConditionExpressionKind::Gap)
{
return false;
}
@@ -553,7 +560,8 @@ std::optional<int> selectedExpressionColumns(
return expressionColumns(expression);
}
if (expression.kind == ConditionExpressionKind::Node
|| expression.kind == ConditionExpressionKind::Wire)
|| expression.kind == ConditionExpressionKind::Wire
|| expression.kind == ConditionExpressionKind::Gap)
{
return std::nullopt;
}
@@ -620,6 +628,52 @@ LadderRung *findEditableRung(
return rung == logic->rungs.end() ? nullptr : &*rung;
}

bool trimTrailingGridColumns(ConditionExpression *expression, int *columns)
{
if (expression == nullptr || columns == nullptr || *columns <= 0)
{
return true;
}
auto trim_leaf = [columns](int *span)
{
const int removed = std::min(*span, *columns);
*span -= removed;
*columns -= removed;
};
if (expression->kind == ConditionExpressionKind::Wire)
{
trim_leaf(&expression->wire->columnSpan);
return *columns == 0 && expression->wire->columnSpan > 0;
}
if (expression->kind == ConditionExpressionKind::Gap)
{
trim_leaf(&expression->gap->columnSpan);
return *columns == 0 && expression->gap->columnSpan > 0;
}
if (expression->kind != ConditionExpressionKind::Series)
{
return false;
}
while (*columns > 0 && !expression->children.empty())
{
ConditionExpression &last = expression->children.back();
int *span = last.kind == ConditionExpressionKind::Wire
? &last.wire->columnSpan
: last.kind == ConditionExpressionKind::Gap
? &last.gap->columnSpan : nullptr;
if (span == nullptr)
{
return false;
}
trim_leaf(span);
if (*span == 0)
{
expression->children.pop_back();
}
}
return *columns == 0 && !expression->children.empty();
}

} // namespace

LogicEditorService::LogicEditorService(ProjectService &project_service)
@@ -705,6 +759,7 @@ bool LogicEditorService::expressionsEqual(
if (left.id != right.id || left.kind != right.kind
|| left.node.has_value() != right.node.has_value()
|| left.wire.has_value() != right.wire.has_value()
|| left.gap.has_value() != right.gap.has_value()
|| left.children.size() != right.children.size())
{
return false;
@@ -718,6 +773,11 @@ bool LogicEditorService::expressionsEqual(
{
return false;
}
if (left.gap.has_value()
&& left.gap->columnSpan != right.gap->columnSpan)
{
return false;
}
for (std::size_t index = 0; index < left.children.size(); ++index)
{
if (!expressionsEqual(left.children[index], right.children[index]))
@@ -1194,7 +1254,69 @@ LogicEditorResult LogicEditorService::appendCondition(
std::nullopt});
}
LadderRung *rung = findEditableRung(project, logic_id, target_rung_id);
if (!rung->condition.has_value())
if (rung->output.has_value() && rung->condition.has_value())
{
ConditionExpression *span = &*rung->condition;
std::vector<ConditionExpression> *siblings = nullptr;
std::size_t span_index = 0U;
if (span->kind == ConditionExpressionKind::Series)
{
siblings = &span->children;
span_index = siblings->size();
while (span_index > 0U)
{
const ConditionExpressionKind kind =
siblings->at(span_index - 1U).kind;
if (kind != ConditionExpressionKind::Wire
&& kind != ConditionExpressionKind::Gap)
{
break;
}
--span_index;
}
span = span_index < siblings->size()
? &siblings->at(span_index) : nullptr;
}
if (span == nullptr
|| (span->kind != ConditionExpressionKind::Wire
&& span->kind != ConditionExpressionKind::Gap))
{
rollbackEdit(std::move(before), modified_before);
return failure(
LogicEditorError::InvalidOperation,
"输出网络的 10 列条件区已经没有可替换的横线或断路");
}
const bool wire_span = span->kind == ConditionExpressionKind::Wire;
const int span_columns = wire_span
? span->wire->columnSpan : span->gap->columnSpan;
const std::string span_id = span->id;
if (span_columns == 1)
{
*span = std::move(leaf);
}
else if (siblings != nullptr)
{
*span = std::move(leaf);
siblings->insert(
siblings->begin() + static_cast<std::ptrdiff_t>(span_index + 1U),
wire_span
? ConditionExpression::fromWire(span_id, span_columns - 1)
: ConditionExpression::fromGap(span_id, span_columns - 1));
}
else
{
const std::string container_id = makeUniqueExpressionId(*editable_logic);
ConditionExpression remaining = wire_span
? ConditionExpression::fromWire(span_id, span_columns - 1)
: ConditionExpression::fromGap(span_id, span_columns - 1);
*span = makeContainer(
container_id,
ConditionExpressionKind::Series,
std::move(leaf),
std::move(remaining));
}
}
else if (!rung->condition.has_value())
{
rung->condition = std::move(leaf);
}
@@ -1205,14 +1327,15 @@ LogicEditorResult LogicEditorService::appendCondition(
else
{
rung->condition = makeContainer(
makeUniqueExpressionId(*logic),
makeUniqueExpressionId(*editable_logic),
ConditionExpressionKind::Series,
std::move(*rung->condition),
std::move(leaf));
}
std::string validation_error;
// 编辑服务修改后立即做整条网络校验;失败就恢复包括脏标记在内的完整快照
if (!rung->validate(&validation_error))
if (!repairExplicitLayout(*editable_logic, *rung, &validation_error)
|| !rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
@@ -1259,6 +1382,9 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn(
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
LadderRung *rung = findEditableRung(project, logic_id, rung_id);

if (leading_wire_columns > 0)
@@ -1308,7 +1434,8 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn(

std::string validation_error;
// 原子提交要求候选表达式整体通过校验,否则撤销本次所有局部拼接
if (!rung->validate(&validation_error))
if (!repairExplicitLayout(*editable_logic, *rung, &validation_error)
|| !rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
@@ -1369,8 +1496,12 @@ LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn(
makeNode(node_id, config));
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
LadderRung *rung = findEditableRung(
project_service_.editProject(), logic_id, rung_id);
project, logic_id, rung_id);
ConditionExpression *editable_branch = findConditionExpression(
*rung->condition, branch_expression_id);

@@ -1399,7 +1530,8 @@ LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn(
}

std::string validation_error;
if (!rung->validate(&validation_error))
if (!repairExplicitLayout(*editable_logic, *rung, &validation_error)
|| !rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
@@ -1514,6 +1646,9 @@ LogicEditorResult LogicEditorService::insertConditionAfter(
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
ConditionExpression *target = findConditionExpression(*rung->condition, target_node_id);
ConditionExpression *parent = findParentExpression(*rung->condition, target_node_id);
@@ -1527,16 +1662,20 @@ LogicEditorResult LogicEditorService::insertConditionAfter(
});
const auto insertion_position = target_iterator + 1;
if (insertion_position != parent->children.end()
&& insertion_position->kind == ConditionExpressionKind::Wire)
{
// 横线代表已占用的网格位置,连续插入时先消费一格而不是扩展网络
if (insertion_position->wire->columnSpan == 1)
&& (insertion_position->kind == ConditionExpressionKind::Wire
|| insertion_position->kind == ConditionExpressionKind::Gap))
{
int &span = insertion_position->kind == ConditionExpressionKind::Wire
? insertion_position->wire->columnSpan
: insertion_position->gap->columnSpan;
// 连续插入优先消费后方显式横线或断路的一格
if (span == 1)
{
*insertion_position = std::move(leaf);
}
else
{
--insertion_position->wire->columnSpan;
--span;
parent->children.insert(insertion_position, std::move(leaf));
}
}
@@ -1549,13 +1688,14 @@ LogicEditorResult LogicEditorService::insertConditionAfter(
{
ConditionExpression original = std::move(*target);
*target = makeContainer(
makeUniqueExpressionId(*logic),
makeUniqueExpressionId(*editable_logic),
ConditionExpressionKind::Series,
std::move(original),
std::move(leaf));
}
std::string validation_error;
if (!rung->validate(&validation_error))
if (!repairExplicitLayout(*editable_logic, *rung, &validation_error)
|| !rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
@@ -1592,8 +1732,12 @@ LogicEditorResult LogicEditorService::insertWireAfter(
ConditionExpression leaf = ConditionExpression::fromWire(wire_id, column_span);
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
LadderRung *rung = findEditableRung(
project_service_.editProject(), logic_id, rung_id);
project, logic_id, rung_id);
ConditionExpression *editable_target = findConditionExpression(
*rung->condition, target_expression_id);
ConditionExpression *parent = findParentExpression(
@@ -1612,13 +1756,14 @@ LogicEditorResult LogicEditorService::insertWireAfter(
{
ConditionExpression original = std::move(*editable_target);
*editable_target = makeContainer(
makeUniqueExpressionId(*logic),
makeUniqueExpressionId(*editable_logic),
ConditionExpressionKind::Series,
std::move(original),
std::move(leaf));
}
std::string validation_error;
if (!rung->validate(&validation_error))
if (!repairExplicitLayout(*editable_logic, *rung, &validation_error)
|| !rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
@@ -1645,6 +1790,11 @@ LogicEditorResult LogicEditorService::replaceWireWithCondition(
{
return failure(LogicEditorError::InvalidNode, "横线只能替换为条件节点");
}
if (wire->wire->columnSpan > 1)
{
return replaceWireColumnWithCondition(
logic_id, rung_id, wire_expression_id, 0, config);
}
const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config));
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
@@ -1715,12 +1865,85 @@ LogicEditorResult LogicEditorService::replaceWireColumnWithCondition(
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
ConditionExpression *editable = findConditionExpression(
*rung->condition, wire_expression_id);
*editable = std::move(replacement);
normalizeConditionExpression(&rung->condition);
std::string validation_error;
if (!repairExplicitLayout(*editable_logic, *rung, &validation_error)
|| !rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidNode, validation_error);
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::replaceGapColumnWithCondition(
const std::string &logic_id,
const std::string &rung_id,
const std::string &gap_expression_id,
int column_offset,
const LogicNodeConfig &config)
{
const ControlLogic *logic = findLogic(logic_id);
const ConditionExpression *gap = findExpression(
logic_id, rung_id, gap_expression_id);
if (logic == nullptr || gap == nullptr
|| gap->kind != ConditionExpressionKind::Gap)
{
return failure(LogicEditorError::ExpressionNotFound, "未找到要填充的断路网格");
}
if (!isConditionConfig(config))
{
return failure(LogicEditorError::InvalidNode, "断路网格只能填入条件节点");
}
if (column_offset < 0 || column_offset >= gap->gap->columnSpan)
{
return failure(LogicEditorError::InvalidOperation, "断路网格偏移超出有效范围");
}

const int leading_columns = column_offset;
const int trailing_columns = gap->gap->columnSpan - column_offset - 1;
const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config));
ConditionExpression replacement;
if (leading_columns == 0 && trailing_columns == 0)
{
replacement = ConditionExpression::fromNode(makeNode(node_id, config));
}
else
{
replacement.id = makeUniqueExpressionId(*logic);
replacement.kind = ConditionExpressionKind::Series;
if (leading_columns > 0)
{
replacement.children.push_back(ConditionExpression::fromGap(
gap_expression_id, leading_columns));
}
replacement.children.push_back(
ConditionExpression::fromNode(makeNode(node_id, config)));
if (trailing_columns > 0)
{
replacement.children.push_back(ConditionExpression::fromGap(
leading_columns > 0 ? makeUniqueGapId(*logic) : gap_expression_id,
trailing_columns));
}
}

HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
LadderRung *rung = findEditableRung(
project_service_.editProject(), logic_id, rung_id);
ConditionExpression *editable = findConditionExpression(
*rung->condition, gap_expression_id);
*editable = std::move(replacement);
normalizeConditionExpression(&rung->condition);
std::string validation_error;
if (!rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
@@ -1730,6 +1953,69 @@ LogicEditorResult LogicEditorService::replaceWireColumnWithCondition(
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::replaceGapColumnWithWire(
const std::string &logic_id,
const std::string &rung_id,
const std::string &gap_expression_id,
int column_offset)
{
const ControlLogic *logic = findLogic(logic_id);
const ConditionExpression *gap = findExpression(
logic_id, rung_id, gap_expression_id);
if (logic == nullptr || gap == nullptr
|| gap->kind != ConditionExpressionKind::Gap)
{
return failure(LogicEditorError::ExpressionNotFound, "未找到要修复的断路网格");
}
if (column_offset < 0 || column_offset >= gap->gap->columnSpan)
{
return failure(LogicEditorError::InvalidOperation, "断路网格偏移超出有效范围");
}
const int leading_columns = column_offset;
const int trailing_columns = gap->gap->columnSpan - column_offset - 1;
const std::string wire_id = makeUniqueWireId(*logic);
ConditionExpression replacement;
if (leading_columns == 0 && trailing_columns == 0)
{
replacement = ConditionExpression::fromWire(wire_id, 1);
}
else
{
replacement.id = makeUniqueExpressionId(*logic);
replacement.kind = ConditionExpressionKind::Series;
if (leading_columns > 0)
{
replacement.children.push_back(ConditionExpression::fromGap(
gap_expression_id, leading_columns));
}
replacement.children.push_back(
ConditionExpression::fromWire(wire_id, 1));
if (trailing_columns > 0)
{
replacement.children.push_back(ConditionExpression::fromGap(
leading_columns > 0 ? makeUniqueGapId(*logic) : gap_expression_id,
trailing_columns));
}
}

HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
LadderRung *rung = findEditableRung(
project_service_.editProject(), logic_id, rung_id);
ConditionExpression *editable = findConditionExpression(
*rung->condition, gap_expression_id);
*editable = std::move(replacement);
normalizeConditionExpression(&rung->condition);
std::string validation_error;
if (!rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, wire_id};
}

LogicEditorResult LogicEditorService::addParallelBranch(
const std::string &logic_id,
const std::string &rung_id,
@@ -1757,8 +2043,13 @@ LogicEditorResult LogicEditorService::addParallelBranch(
LogicEditorError::InvalidOperation,
"并联选择中存在重复节点");
}
const NodeIdSet available_ids = conditionLeafIds(*existing_rung->condition);
if (!isSubset(selected_ids, available_ids))
const bool all_conditions = std::all_of(
selected_ids.cbegin(), selected_ids.cend(),
[&existing_rung](const std::string &node_id)
{
return findConditionNode(*existing_rung->condition, node_id) != nullptr;
});
if (!all_conditions)
{
return failure(
LogicEditorError::NodeNotFound,
@@ -1775,6 +2066,9 @@ LogicEditorResult LogicEditorService::addParallelBranch(
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
if (!addParallelForSelection(
&*rung->condition,
@@ -1789,7 +2083,8 @@ LogicEditorResult LogicEditorService::addParallelBranch(
"并联选择必须是一个连续的逻辑范围");
}
std::string validation_error;
if (!rung->validate(&validation_error))
if (!repairExplicitLayout(*editable_logic, *rung, &validation_error)
|| !rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
@@ -2197,6 +2492,7 @@ LogicEditorResult LogicEditorService::setOutput(
return {true, LogicEditorError::None, {}, node_id};
}
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
@@ -2210,7 +2506,16 @@ LogicEditorResult LogicEditorService::setOutput(
std::nullopt,
std::nullopt});
}
findEditableRung(project, logic_id, target_rung_id)->output = std::move(node);
LadderRung *editable_rung = findEditableRung(
project, logic_id, target_rung_id);
editable_rung->output = std::move(node);
if (!repairExplicitLayout(*editable_logic, *editable_rung))
{
rollbackEdit(std::move(before), modified_before);
return failure(
LogicEditorError::InvalidOperation,
"输出前的显式横线无法在 10 列条件区内完成布局");
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}
@@ -2353,6 +2658,10 @@ LogicEditorResult LogicEditorService::pasteConditionNodes(
current = replaceWireColumnWithCondition(
logic_id, rung_id, target.expressionId, target.column, source.config);
break;
case LogicConditionPasteTargetKind::ReplaceGapColumn:
current = replaceGapColumnWithCondition(
logic_id, rung_id, target.expressionId, target.column, source.config);
break;
case LogicConditionPasteTargetKind::Append:
default:
current = appendCondition(logic_id, rung_id, source.config);
@@ -2530,6 +2839,11 @@ LogicEditorResult LogicEditorService::pasteRung(
clone.id = unique_wire_id();
clone.wire = source_expression.wire;
}
else if (source_expression.kind == ConditionExpressionKind::Gap)
{
clone.id = unique_expression_id();
clone.gap = source_expression.gap;
}
else
{
clone.id = unique_expression_id();
@@ -2615,18 +2929,16 @@ LogicEditorResult LogicEditorService::removeNodes(
{
for (const std::string &node_id : node_ids)
{
if (rung.condition->kind == ConditionExpressionKind::Node
&& rung.condition->id == node_id)
ConditionExpression *expression = findConditionExpression(
*rung.condition, node_id);
if (expression != nullptr
&& expression->kind == ConditionExpressionKind::Node)
{
rung.condition.reset();
break;
*expression = ConditionExpression::fromGap(
makeUniqueGapId(*editable_logic), 1);
}
removeExpressionRecursive(&*rung.condition, node_id);
}
if (rung.condition.has_value())
{
normalizeConditionExpression(&rung.condition);
}
normalizeConditionExpression(&rung.condition);
}
}
for (const LadderRung &rung : editable_logic->rungs)
@@ -2652,7 +2964,11 @@ LogicEditorResult LogicEditorService::removeExpression(
return failure(LogicEditorError::ExpressionNotFound, "未找到条件支路");
}
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
if (rung->condition->id == expression_id)
{
@@ -2663,6 +2979,13 @@ LogicEditorResult LogicEditorService::removeExpression(
removeExpressionRecursive(&*rung->condition, expression_id);
normalizeConditionExpression(&rung->condition);
}
std::string validation_error;
if (!repairExplicitLayout(*editable_logic, *rung, &validation_error)
|| !rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, expression_id};
}
@@ -2699,8 +3022,13 @@ LogicEditorResult LogicEditorService::removeExpressions(
}

HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
LadderRung *rung = findEditableRung(
project_service_.editProject(), logic_id, rung_id);
project, logic_id, rung_id);
for (const std::string &expression_id : expression_ids)
{
if (!rung->condition.has_value())
@@ -2715,10 +3043,164 @@ LogicEditorResult LogicEditorService::removeExpressions(
removeExpressionRecursive(&*rung->condition, expression_id);
}
normalizeConditionExpression(&rung->condition);
std::string validation_error;
if (!repairExplicitLayout(*editable_logic, *rung, &validation_error)
|| !rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, expression_ids.front()};
}

LogicEditorResult LogicEditorService::disconnectWireCells(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::pair<std::string, int>> &wire_cells)
{
const ControlLogic *logic = findLogic(logic_id);
const LadderRung *existing_rung = findRung(logic_id, rung_id);
if (logic == nullptr || existing_rung == nullptr
|| !existing_rung->condition.has_value())
{
return failure(LogicEditorError::RungNotFound, "未找到梯形图条件网络");
}
if (wire_cells.empty())
{
return failure(LogicEditorError::InvalidOperation, "请先选择要断开的横线网格");
}
std::vector<std::pair<std::string, int>> cells = wire_cells;
std::sort(
cells.begin(), cells.end(),
[](const auto &left, const auto &right)
{
return left.first < right.first
|| (left.first == right.first && left.second > right.second);
});
if (std::adjacent_find(cells.cbegin(), cells.cend()) != cells.cend())
{
return failure(LogicEditorError::InvalidOperation, "横线断开列表中存在重复网格");
}
for (const auto &cell : cells)
{
const ConditionExpression *wire = findExpression(
logic_id, rung_id, cell.first);
if (wire == nullptr || wire->kind != ConditionExpressionKind::Wire
|| cell.second < 0 || cell.second >= wire->wire->columnSpan)
{
return failure(LogicEditorError::InvalidOperation, "横线网格偏移超出有效范围");
}
}

HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
for (const auto &cell : cells)
{
ConditionExpression *wire = findConditionExpression(
*rung->condition, cell.first);
if (wire == nullptr || wire->kind != ConditionExpressionKind::Wire
|| cell.second < 0 || cell.second >= wire->wire->columnSpan)
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, "横线网格断开位置已经失效");
}
const int leading_columns = cell.second;
const int trailing_columns = wire->wire->columnSpan - cell.second - 1;
if (leading_columns == 0 && trailing_columns == 0)
{
*wire = ConditionExpression::fromGap(
makeUniqueGapId(*editable_logic), 1);
continue;
}
const std::string original_wire_id = wire->id;
ConditionExpression replacement;
replacement.id = makeUniqueExpressionId(*editable_logic);
replacement.kind = ConditionExpressionKind::Series;
if (leading_columns > 0)
{
replacement.children.push_back(ConditionExpression::fromWire(
original_wire_id, leading_columns));
}
replacement.children.push_back(ConditionExpression::fromGap(
makeUniqueGapId(*editable_logic), 1));
if (trailing_columns > 0)
{
replacement.children.push_back(ConditionExpression::fromWire(
leading_columns > 0
? makeUniqueWireId(*editable_logic) : original_wire_id,
trailing_columns));
}
*wire = std::move(replacement);
normalizeConditionExpression(&rung->condition);
}
std::string validation_error;
if (!rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, cells.front().first};
}

LogicEditorResult LogicEditorService::disconnectWires(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &wire_ids)
{
const ControlLogic *logic = findLogic(logic_id);
const LadderRung *existing_rung = findRung(logic_id, rung_id);
if (logic == nullptr || existing_rung == nullptr
|| !existing_rung->condition.has_value())
{
return failure(LogicEditorError::RungNotFound, "未找到梯形图条件网络");
}
if (wire_ids.empty())
{
return failure(LogicEditorError::InvalidOperation, "请先选择要断开的横线");
}
NodeIdSet unique_ids;
for (const std::string &wire_id : wire_ids)
{
const ConditionExpression *wire = findExpression(logic_id, rung_id, wire_id);
if (!unique_ids.insert(wire_id).second
|| wire == nullptr || wire->kind != ConditionExpressionKind::Wire)
{
return failure(LogicEditorError::ExpressionNotFound, "断开列表中包含无效横线");
}
}

HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
for (const std::string &wire_id : wire_ids)
{
ConditionExpression *wire = findConditionExpression(
*rung->condition, wire_id);
*wire = ConditionExpression::fromGap(
makeUniqueGapId(*editable_logic), wire->wire->columnSpan);
}
normalizeConditionExpression(&rung->condition);
std::string validation_error;
if (!rung->validate(&validation_error))
{
rollbackEdit(std::move(before), modified_before);
return failure(LogicEditorError::InvalidOperation, validation_error);
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, wire_ids.front()};
}

bool LogicEditorService::canUndo() const
{
return history_.canUndo();
@@ -2843,6 +3325,25 @@ std::string LogicEditorService::makeUniqueWireId(const ControlLogic &logic)
}
}

std::string LogicEditorService::makeUniqueGapId(const ControlLogic &logic)
{
for (std::size_t index = 1;; ++index)
{
const std::string candidate = "gap-" + std::to_string(index);
const bool found = std::any_of(
logic.rungs.cbegin(), logic.rungs.cend(),
[&candidate](const LadderRung &rung)
{
return rung.condition.has_value()
&& findConditionExpression(*rung.condition, candidate) != nullptr;
});
if (!found)
{
return candidate;
}
}
}

std::string LogicEditorService::makeUniqueExpressionId(const ControlLogic &logic)
{
for (std::size_t index = 1;; ++index)
@@ -2875,6 +3376,108 @@ std::string LogicEditorService::makeUniqueRungId(const ControlLogic &logic)
}
}

bool LogicEditorService::repairExplicitLayout(
ControlLogic &logic, LadderRung &rung, std::string *error)
{
const auto append_wire = [&logic](ConditionExpression *expression, int columns)
{
if (expression == nullptr || columns <= 0)
{
return;
}
ConditionExpression wire = ConditionExpression::fromWire(
makeUniqueWireId(logic), columns);
if (expression->kind == ConditionExpressionKind::Series)
{
expression->children.push_back(std::move(wire));
return;
}
const std::string container_id = makeUniqueExpressionId(logic);
ConditionExpression original = std::move(*expression);
*expression = makeContainer(
container_id,
ConditionExpressionKind::Series,
std::move(original),
std::move(wire));
};

std::function<void(ConditionExpression *)> materialize_parallel_wires;
materialize_parallel_wires =
[&](ConditionExpression *expression)
{
if (expression == nullptr
|| expression->kind == ConditionExpressionKind::Node
|| expression->kind == ConditionExpressionKind::Wire
|| expression->kind == ConditionExpressionKind::Gap)
{
return;
}
for (ConditionExpression &child : expression->children)
{
materialize_parallel_wires(&child);
}
if (expression->kind != ConditionExpressionKind::Parallel)
{
return;
}
int maximum_columns = 0;
for (const ConditionExpression &child : expression->children)
{
maximum_columns = std::max(
maximum_columns, expressionColumns(child));
}
for (ConditionExpression &child : expression->children)
{
append_wire(
&child, maximum_columns - expressionColumns(child));
}
};

if (rung.condition.has_value())
{
materialize_parallel_wires(&*rung.condition);
}
if (!rung.output.has_value())
{
normalizeConditionExpression(&rung.condition);
return true;
}
if (!rung.condition.has_value())
{
rung.condition = ConditionExpression::fromWire(
makeUniqueWireId(logic), ProjectLimits::kMaximumConditionColumns);
return true;
}

int columns = expressionColumns(*rung.condition);
if (columns > ProjectLimits::kMaximumConditionColumns)
{
int overflow = columns - ProjectLimits::kMaximumConditionColumns;
if (!trimTrailingGridColumns(&*rung.condition, &overflow))
{
if (error != nullptr)
{
*error = "显式梯形图布局无法在 10 列条件区内完成";
}
return false;
}
normalizeConditionExpression(&rung.condition);
columns = expressionColumns(*rung.condition);
}
append_wire(
&*rung.condition,
ProjectLimits::kMaximumConditionColumns - columns);
normalizeConditionExpression(&rung.condition);
const bool valid_width = rung.condition.has_value()
&& expressionColumns(*rung.condition)
== ProjectLimits::kMaximumConditionColumns;
if (!valid_width && error != nullptr)
{
*error = "显式梯形图布局无法在 10 列条件区内完成";
}
return valid_width;
}

LogicEditorResult LogicEditorService::failure(
LogicEditorError error, const std::string &message)
{


+ 30
- 1
app/src/services/logic_editor_service.h Vedi File

@@ -40,7 +40,8 @@ enum class LogicConditionPasteTargetKind
BranchEmptyColumn,
AfterNode,
ReplaceWire,
ReplaceWireColumn
ReplaceWireColumn,
ReplaceGapColumn
};

struct LogicConditionPasteTarget
@@ -168,6 +169,19 @@ public:
const std::string &wire_expression_id,
int column_offset,
const LogicNodeConfig &config);
/** @brief 用条件节点替换断路表达式中的一个指定列单元格 */
LogicEditorResult replaceGapColumnWithCondition(
const std::string &logic_id,
const std::string &rung_id,
const std::string &gap_expression_id,
int column_offset,
const LogicNodeConfig &config);
/** @brief 用一格横线修复断路表达式中的指定网格 */
LogicEditorResult replaceGapColumnWithWire(
const std::string &logic_id,
const std::string &rung_id,
const std::string &gap_expression_id,
int column_offset);
/**
* @brief 将选中的条件节点建立为并联分支
* @param selected_node_ids 同一网络中按视觉连续范围选择的条件节点 ID
@@ -255,6 +269,16 @@ public:
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &expression_ids);
/** @brief 把选中的横线网格替换为断路,保留网络列位置 */
LogicEditorResult disconnectWireCells(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::pair<std::string, int>> &wire_cells);
/** @brief 把整段横线替换为等宽断路 */
LogicEditorResult disconnectWires(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &wire_ids);

// 当前编辑会话的撤销/重做
/** @brief 判断是否存在可撤销的梯形图编辑操作 */
@@ -306,10 +330,15 @@ private:
const ControlLogic &logic, const std::string &prefix);
// 生成唯一横线表达式 ID
static std::string makeUniqueWireId(const ControlLogic &logic);
// 生成控制逻辑内唯一的断路表达式 ID
static std::string makeUniqueGapId(const ControlLogic &logic);
// 生成唯一容器表达式 ID
static std::string makeUniqueExpressionId(const ControlLogic &logic);
// 生成唯一网络 ID
static std::string makeUniqueRungId(const ControlLogic &logic);
// 把并联补线和输出前连接线转换为显式 Wire,并保持输出网络十列布局
static bool repairExplicitLayout(
ControlLogic &logic, LadderRung &rung, std::string *error = nullptr);
// 创建统一的失败结果
static LogicEditorResult failure(
LogicEditorError error, const std::string &message);


+ 10
- 1
app/src/services/software_logic_executor.cpp Vedi File

@@ -208,7 +208,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
continue;
}

// 没有条件节点的网络等价于左侧电源线直接接通,作为恒真条件执行
// 输出网络的显式 Wire 会计算为真;空网络只作为编辑草稿跳过
bool rung_value = true;
LogicScanResult result = success();
if (rung.condition.has_value())
@@ -302,6 +302,15 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression(
}
return success();
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return failure(
LogicScanError::InvalidLogic,
"梯形图条件区存在未连接的空白网格",
logic_id,
{},
expression.id);
}
if (expression.kind == ConditionExpressionKind::Node)
{
LogicScanResult result = evaluateCondition(


+ 175
- 35
app/src/ui/logic_editor_widget.cpp Vedi File

@@ -150,6 +150,10 @@ ExpressionMetrics measureExpression(const ConditionExpression &expression)
{
return {expression.wire->columnSpan, 1};
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return {expression.gap->columnSpan, 1};
}

ExpressionMetrics metrics{0, 0};
if (expression.kind == ConditionExpressionKind::Series)
@@ -523,6 +527,54 @@ private:
int column_offset_ = 0;
};

class LogicEditorWidget::GapCellItem final : public QGraphicsItem
{
public:
GapCellItem(
const std::string &expression_id,
const std::string &rung_id,
int column_offset,
const QPointF &center)
: expression_id_(expression_id),
rung_id_(rung_id),
column_offset_(column_offset)
{
setPos(center);
setFlag(ItemIsSelectable, true);
setZValue(2.2);
setToolTip(LogicEditorWidget::tr("断路网格:第 %1 格,可补横线或插入触点")
.arg(column_offset + 1));
}

QRectF boundingRect() const override
{
return {-kCellWidth / 2.0, -kCellHeight / 2.0, kCellWidth, kCellHeight};
}

void paint(
QPainter *painter,
const QStyleOptionGraphicsItem *option,
QWidget *) override
{
if ((option->state & QStyle::State_Selected) == 0)
{
return;
}
painter->fillRect(boundingRect().adjusted(2, 2, -2, -2), kSelectionColor);
painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine));
painter->drawRect(boundingRect().adjusted(3, 3, -3, -3));
}

const std::string &expressionId() const { return expression_id_; }
const std::string &rungId() const { return rung_id_; }
int columnOffset() const { return column_offset_; }

private:
std::string expression_id_;
std::string rung_id_;
int column_offset_ = 0;
};

class LogicEditorWidget::EmptySlotItem final : public QGraphicsItem
{
public:
@@ -790,6 +842,28 @@ RenderResult renderExpression(
QPointF(top_left.x(), center.y()),
QPointF(top_left.x() + width, center.y())};
}
if (expression.kind == ConditionExpressionKind::Gap)
{
const qreal width = static_cast<qreal>(expression.gap->columnSpan)
* kCellWidth;
const QPointF center(
top_left.x() + width / 2.0,
top_left.y() + kCellHeight / 2.0);
for (int column = 0; column < expression.gap->columnSpan; ++column)
{
scene.addItem(new LogicEditorWidget::GapCellItem(
expression.id,
rung_id,
column,
QPointF(
top_left.x() + (static_cast<qreal>(column) + 0.5)
* kCellWidth,
center.y())));
}
return {
QPointF(top_left.x(), center.y()),
QPointF(top_left.x() + width, center.y())};
}
if (expression.kind == ConditionExpressionKind::Node)
{
const QPointF center(
@@ -917,30 +991,6 @@ RenderResult renderExpression(
scene.addLine(
QLineF(QPointF(left_join, branches[index].input.y()), branches[index].input),
ladderPen(branch_active));
if (branches[index].output.x() < right_join - 0.1)
{
// 分支宽度不足时补出到右侧汇合点的结构连接,不写入隐式 Wire
scene.addLine(
QLineF(
branches[index].output,
QPointF(right_join, branches[index].output.y())),
ladderPen(branch_active));
}
const int branch_columns = measureExpression(
expression.children[index]).columns;
for (int column = branch_columns; column < metrics.columns; ++column)
{
scene.addItem(new LogicEditorWidget::EmptySlotItem(
rung_id,
column,
QPointF(
top_left.x()
+ (static_cast<qreal>(column) + 0.5) * kCellWidth,
branches[index].output.y()),
false,
expression.children[index].id,
branch_active));
}
}
return {QPointF(left_join, top_y), QPointF(right_join, top_y)};
}
@@ -1307,6 +1357,19 @@ std::vector<std::pair<std::string, int>> LogicEditorWidget::selectedWireCells()
return cells;
}

std::vector<std::pair<std::string, int>> LogicEditorWidget::selectedGapCells() const
{
std::vector<std::pair<std::string, int>> cells;
for (QGraphicsItem *item : scene_->selectedItems())
{
if (const GapCellItem *cell = dynamic_cast<const GapCellItem *>(item))
{
cells.emplace_back(cell->expressionId(), cell->columnOffset());
}
}
return cells;
}

std::vector<std::string> LogicEditorWidget::selectedBranchIds() const
{
std::vector<std::string> ids;
@@ -1355,6 +1418,15 @@ std::string LogicEditorWidget::selectedRungId() const
}
rung_id = cell->rungId();
}
else if (const GapCellItem *cell =
dynamic_cast<const GapCellItem *>(item))
{
if (!rung_id.empty() && rung_id != cell->rungId())
{
return {};
}
rung_id = cell->rungId();
}
else if (const VerticalConnectorItem *connector =
dynamic_cast<const VerticalConnectorItem *>(item))
{
@@ -1418,16 +1490,23 @@ LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config)
selectedEmptySlots();
const std::vector<std::pair<std::string, int>> selected_wire_cells =
selectedWireCells();
const std::vector<std::pair<std::string, int>> selected_gap_cells =
selectedGapCells();
const std::vector<std::string> selected_expressions = selectedExpressionIds();
const std::vector<std::string> selected_wires = selectedWireIds();
const std::vector<std::string> selected_ids = selectedNodeIds();
LogicEditorResult result;
if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U
|| selected_gap_cells.size() > 1U
|| (!selected_empty_slots.empty()
&& (!selected_wire_cells.empty()
|| !selected_gap_cells.empty()
|| !selected_expressions.empty() || !selected_ids.empty()))
|| (!selected_wire_cells.empty()
&& (!selected_ids.empty() || selected_expressions.size() > 1U)))
&& (!selected_gap_cells.empty()
|| !selected_ids.empty() || selected_expressions.size() > 1U))
|| (!selected_gap_cells.empty()
&& (!selected_ids.empty() || !selected_expressions.empty())))
{
result = {false, LogicEditorError::InvalidOperation,
"插入条件时只能选择一个网格或条件对象", {}};
@@ -1450,6 +1529,15 @@ LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config)
selected_wire_cells.front().second,
config);
}
else if (selected_gap_cells.size() == 1U)
{
result = editor_service_.replaceGapColumnWithCondition(
logic_id_,
currentRungId(),
selected_gap_cells.front().first,
selected_gap_cells.front().second,
config);
}
else if (selected_expressions.size() > 1U || selected_wires.size() > 1U)
{
result = {false, LogicEditorError::InvalidOperation,
@@ -1493,6 +1581,8 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co
selectedEmptySlots();
const std::vector<std::pair<std::string, int>> selected_wire_cells =
selectedWireCells();
const std::vector<std::pair<std::string, int>> selected_gap_cells =
selectedGapCells();
const std::vector<std::string> selected_ids = selectedNodeIds();
std::vector<std::string> condition_ids;
for (const std::string &node_id : selected_ids)
@@ -1504,7 +1594,12 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co
}
}
LogicEditorResult result;
if (!selected_empty_slots.empty()
if (!selected_gap_cells.empty())
{
result = {false, LogicEditorError::InvalidOperation,
"断路网格不能建立并联支路,请先补横线或插入触点", {}};
}
else if (!selected_empty_slots.empty()
&& (!selected_ids.empty() || !selected_wire_cells.empty()))
{
result = {false, LogicEditorError::InvalidOperation,
@@ -1546,12 +1641,23 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co
LogicEditorResult LogicEditorWidget::addHorizontalWire()
{
const std::vector<std::string> selected_ids = selectedExpressionIds();
const std::vector<std::pair<std::string, int>> selected_gap_cells =
selectedGapCells();
LogicEditorResult result;
if (selected_ids.size() > 1U)
if (selected_ids.size() > 1U || selected_gap_cells.size() > 1U
|| (!selected_ids.empty() && !selected_gap_cells.empty()))
{
result = {false, LogicEditorError::InvalidOperation,
"插入横线时只能选择一个条件对象", {}};
}
else if (selected_gap_cells.size() == 1U)
{
result = editor_service_.replaceGapColumnWithWire(
logic_id_,
currentRungId(),
selected_gap_cells.front().first,
selected_gap_cells.front().second);
}
else if (selected_ids.size() == 1U)
{
result = editor_service_.insertWireAfter(
@@ -1581,10 +1687,17 @@ LogicEditorResult LogicEditorWidget::addVerticalWire()
selectedEmptySlots();
const std::vector<std::pair<std::string, int>> selected_wire_cells =
selectedWireCells();
const std::vector<std::pair<std::string, int>> selected_gap_cells =
selectedGapCells();
const std::vector<std::string> selected_nodes = selectedNodeIds();
const std::vector<std::string> selected_ids = selectedExpressionIds();
LogicEditorResult result;
if (!selected_empty_slots.empty()
if (!selected_gap_cells.empty())
{
result = {false, LogicEditorError::InvalidOperation,
"断路网格不能建立竖线连接,请先补横线", {}};
}
else if (!selected_empty_slots.empty()
&& (!selected_wire_cells.empty() || !selected_nodes.empty()))
{
result = {false, LogicEditorError::InvalidOperation,
@@ -1630,6 +1743,8 @@ LogicEditorResult LogicEditorWidget::addVerticalWire()

LogicEditorResult LogicEditorWidget::deleteHorizontalWire()
{
const std::vector<std::pair<std::string, int>> wire_cells =
selectedWireCells();
const std::vector<std::string> wire_ids = selectedWireIds();
const std::string rung_id = selectedRungId();
if (wire_ids.empty() || rung_id.empty())
@@ -1642,8 +1757,9 @@ LogicEditorResult LogicEditorWidget::deleteHorizontalWire()
reportFailure(result);
return result;
}
const LogicEditorResult result = editor_service_.removeExpressions(
logic_id_, rung_id, wire_ids);
const LogicEditorResult result = !wire_cells.empty()
? editor_service_.disconnectWireCells(logic_id_, rung_id, wire_cells)
: editor_service_.disconnectWires(logic_id_, rung_id, wire_ids);
if (result.succeeded)
{
reloadLogic();
@@ -1720,17 +1836,24 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes(
selectedEmptySlots();
const std::vector<std::pair<std::string, int>> selected_wire_cells =
selectedWireCells();
const std::vector<std::pair<std::string, int>> selected_gap_cells =
selectedGapCells();
const std::vector<std::string> selected_expressions = selectedExpressionIds();
const std::vector<std::string> selected_wires = selectedWireIds();
const std::vector<std::string> selected_ids = selectedNodeIds();
LogicEditorResult result;
LogicConditionPasteTarget target;
if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U
|| selected_gap_cells.size() > 1U
|| (!selected_empty_slots.empty()
&& (!selected_wire_cells.empty()
|| !selected_gap_cells.empty()
|| !selected_expressions.empty() || !selected_ids.empty()))
|| (!selected_wire_cells.empty()
&& (!selected_ids.empty() || selected_expressions.size() > 1U))
&& (!selected_gap_cells.empty()
|| !selected_ids.empty() || selected_expressions.size() > 1U))
|| (!selected_gap_cells.empty()
&& (!selected_ids.empty() || !selected_expressions.empty()))
|| selected_expressions.size() > 1U || selected_wires.size() > 1U
|| selected_ids.size() > 1U)
{
@@ -1754,6 +1877,12 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes(
target.expressionId = selected_wire_cells.front().first;
target.column = selected_wire_cells.front().second;
}
else if (selected_gap_cells.size() == 1U)
{
target.kind = LogicConditionPasteTargetKind::ReplaceGapColumn;
target.expressionId = selected_gap_cells.front().first;
target.column = selected_gap_cells.front().second;
}
else if (selected_wires.size() == 1U)
{
target.kind = LogicConditionPasteTargetKind::ReplaceWire;
@@ -1787,12 +1916,20 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes(

LogicEditorResult LogicEditorWidget::deleteSelected()
{
const std::vector<std::string> expression_ids = selectedExpressionIds();
const std::vector<std::string> wire_ids = selectedWireIds();
const std::vector<std::pair<std::string, int>> wire_cells =
selectedWireCells();
const std::vector<std::pair<std::string, int>> gap_cells =
selectedGapCells();
const std::vector<std::string> branch_ids = selectedBranchIds();
const std::vector<std::string> node_ids = selectedNodeIds();
LogicEditorResult result;
if (!branch_ids.empty())
if (!gap_cells.empty())
{
result = {false, LogicEditorError::InvalidOperation,
"所选网格已经是断路,不需要重复删除", {}};
}
else if (!branch_ids.empty())
{
result = editor_service_.removeExpressions(
logic_id_, selectedRungId(), branch_ids);
@@ -1814,8 +1951,11 @@ LogicEditorResult LogicEditorWidget::deleteSelected()
}
else
{
result = editor_service_.removeExpressions(
logic_id_, selectedRungId(), expression_ids);
result = !wire_cells.empty()
? editor_service_.disconnectWireCells(
logic_id_, selectedRungId(), wire_cells)
: editor_service_.disconnectWires(
logic_id_, selectedRungId(), wire_ids);
}
}
else if (!node_ids.empty())


+ 3
- 0
app/src/ui/logic_editor_widget.h Vedi File

@@ -31,6 +31,7 @@ public:
class NodeItem;
class WireItem;
class WireCellItem;
class GapCellItem;
class VerticalConnectorItem;
class RungItem;
class EmptySlotItem;
@@ -116,6 +117,8 @@ private:
std::vector<std::pair<std::string, int>> selectedEmptySlots() const;
/** 收集当前选中的横线网格位置 */
std::vector<std::pair<std::string, int>> selectedWireCells() const;
/** 收集当前选中的显式断路网格位置 */
std::vector<std::pair<std::string, int>> selectedGapCells() const;
/** 判断当前是否选中了网络内的图元 */
bool hasSelectedRungItem() const;



+ 75
- 12
app/tests/domain_tests.cpp Vedi File

@@ -9,6 +9,7 @@
#include "domain/virtual_register_repository.h"
#include "support/test_support.h"

#include <algorithm>
#include <cstdint>
#include <exception>
#include <functional>
@@ -21,6 +22,30 @@ namespace {

using TestSupport::require;

int expressionColumns(const ConditionExpression &expression)
{
if (expression.kind == ConditionExpressionKind::Node)
{
return 1;
}
if (expression.kind == ConditionExpressionKind::Wire)
{
return expression.wire->columnSpan;
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return expression.gap->columnSpan;
}
int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
for (const ConditionExpression &child : expression.children)
{
const int child_columns = expressionColumns(child);
columns = expression.kind == ConditionExpressionKind::Series
? columns + child_columns : std::max(columns, child_columns);
}
return columns;
}

void testRegisterAddressBoundaries()
{
// 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径
@@ -268,7 +293,13 @@ Project makeValidProject()
LadderRung rung;
rung.id = "rung-1";
rung.name = "Network 1";
rung.condition = ConditionExpression::fromNode(contact);
ConditionExpression condition;
condition.id = "start-series";
condition.kind = ConditionExpressionKind::Series;
condition.children = {
ConditionExpression::fromNode(contact),
ConditionExpression::fromWire("start-wire", 9)};
rung.condition = std::move(condition);
rung.output = coil;
logic.rungs.push_back(rung);

@@ -463,17 +494,30 @@ void testQuantityBoundaries()
true});
}
const int address = (*next_address)++;
ConditionExpression nested = makeNested(depth - 1, next_address);
ConditionExpression sibling = ConditionExpression::fromNode({
"depth-node-" + std::to_string(address),
ContactNodeConfig{RegisterAddress{RegisterArea::M, address}},
true});
ConditionExpression expression;
expression.id = "depth-expression-" + std::to_string(address);
expression.kind = depth % 2 == 0
? ConditionExpressionKind::Parallel
: ConditionExpressionKind::Series;
expression.children = {
makeNested(depth - 1, next_address),
ConditionExpression::fromNode({
"depth-node-" + std::to_string(address),
ContactNodeConfig{RegisterAddress{RegisterArea::M, address}},
true})};
if (expression.kind == ConditionExpressionKind::Parallel
&& expressionColumns(nested) > 1)
{
ConditionExpression padded_sibling;
padded_sibling.id = "depth-padding-" + std::to_string(address);
padded_sibling.kind = ConditionExpressionKind::Series;
padded_sibling.children = {
std::move(sibling),
ConditionExpression::fromWire(
"depth-wire-" + std::to_string(address),
expressionColumns(nested) - 1)};
sibling = std::move(padded_sibling);
}
expression.children = {std::move(nested), std::move(sibling)};
return expression;
};
int next_address = 1;
@@ -625,7 +669,8 @@ void testLadderLogicBoundaries()
root.kind = ConditionExpressionKind::Series;
root.children = {
ConditionExpression::fromNode(stop),
start_parallel};
start_parallel,
ConditionExpression::fromWire("hold-output-wire", 8)};
LadderRung rung;
rung.id = "rung-1";
rung.name = "Self hold";
@@ -644,6 +689,10 @@ void testLadderLogicBoundaries()
"wire-too-wide", WireSegment::kMaximumColumnSpan + 1);
require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected");

ConditionExpression gap = ConditionExpression::fromGap("gap-1", 1);
require(gap.validate() && !gap.validateForRunning(),
"a gap must be a valid editing draft but must block runtime validation");

ConditionExpression maximum_columns;
maximum_columns.id = "maximum-columns";
maximum_columns.kind = ConditionExpressionKind::Series;
@@ -696,9 +745,14 @@ void testLadderLogicBoundaries()
require(empty_rung.validate(), "an empty editing network must be valid");

empty_rung.output = coil;
require(empty_rung.validate(), "output-only network may remain in an editable draft");
require(empty_rung.validateForRunning(),
"an output-only network must be valid as an unconditional rung");
require(!empty_rung.validate(),
"an output without an explicit ten-column condition path must be rejected");
empty_rung.condition = ConditionExpression::fromWire("unconditional-wire", 10);
require(empty_rung.validate() && empty_rung.validateForRunning(),
"a ten-column wire path must represent a runnable unconditional rung");
empty_rung.condition = ConditionExpression::fromGap("unconditional-gap", 10);
require(empty_rung.validate() && !empty_rung.validateForRunning(),
"a full-width gap draft must remain saved but disconnected");

logic.rungs.front().output = coil;
logic.rungs.front().condition = root;
@@ -711,8 +765,17 @@ void testLadderLogicBoundaries()
nested_parallel.children = {
ConditionExpression::fromNode(start),
root};
require(!nested_parallel.validate(),
"parallel branches with unequal explicit widths must be rejected");
ConditionExpression padded_start;
padded_start.id = "padded-start";
padded_start.kind = ConditionExpressionKind::Series;
padded_start.children = {
ConditionExpression::fromNode(start),
ConditionExpression::fromWire("nested-parallel-wire", 9)};
nested_parallel.children.front() = std::move(padded_start);
require(nested_parallel.validate(),
"nested series and parallel expressions must be valid");
"parallel branches padded with explicit wires must be valid");
}

void testModelsValidateBindingsAndIdentifiers()


+ 238
- 28
app/tests/logic_editor_service_tests.cpp Vedi File

@@ -36,6 +36,10 @@ int conditionColumns(const ConditionExpression &expression)
{
return expression.wire->columnSpan;
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return expression.gap->columnSpan;
}
int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
for (const ConditionExpression &child : expression.children)
{
@@ -56,6 +60,10 @@ int wireColumns(const ConditionExpression &expression)
{
return expression.wire->columnSpan;
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return 0;
}
int columns = 0;
for (const ConditionExpression &child : expression.children)
{
@@ -64,6 +72,25 @@ int wireColumns(const ConditionExpression &expression)
return columns;
}

int gapColumns(const ConditionExpression &expression)
{
if (expression.kind == ConditionExpressionKind::Gap)
{
return expression.gap->columnSpan;
}
if (expression.kind == ConditionExpressionKind::Node
|| expression.kind == ConditionExpressionKind::Wire)
{
return 0;
}
int columns = 0;
for (const ConditionExpression &child : expression.children)
{
columns += gapColumns(child);
}
return columns;
}

int conditionNodes(const ConditionExpression &expression)
{
if (expression.kind == ConditionExpressionKind::Node)
@@ -74,6 +101,10 @@ int conditionNodes(const ConditionExpression &expression)
{
return 0;
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return 0;
}
int count = 0;
for (const ConditionExpression &child : expression.children)
{
@@ -82,6 +113,23 @@ int conditionNodes(const ConditionExpression &expression)
return count;
}

const ConditionExpression *firstExpressionOfKind(
const ConditionExpression &expression, ConditionExpressionKind kind)
{
if (expression.kind == kind)
{
return &expression;
}
for (const ConditionExpression &child : expression.children)
{
if (const ConditionExpression *found = firstExpressionOfKind(child, kind))
{
return found;
}
}
return nullptr;
}

void testEmptyLogicCreatesNetworksOnFirstEdit()
{
TestProjectStorage storage;
@@ -121,9 +169,13 @@ void testEmptyLogicCreatesNetworksOnFirstEdit()
CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal});
require(first_output.succeeded
&& service.findLogic(logic_id)->rungs.size() == 1U
&& !service.findLogic(logic_id)->rungs.front().condition.has_value()
&& service.findLogic(logic_id)->rungs.front().condition.has_value()
&& service.findLogic(logic_id)->rungs.front().condition->kind
== ConditionExpressionKind::Wire
&& service.findLogic(logic_id)->rungs.front().condition->wire->columnSpan
== ProjectLimits::kMaximumConditionColumns
&& service.findLogic(logic_id)->rungs.front().output.has_value(),
"the first output must create an unconditional network");
"the first output must create a full-width explicit wire network");
}

void testAppendingWireMovesToNextNetworkAfterTenColumns()
@@ -199,15 +251,20 @@ void testStructuredEditingAndNormalization()
rung = service.findRung(logic_id, rung_id);
require(rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel
&& rung->condition->children.at(1).children.at(1).kind
== ConditionExpressionKind::Node,
"single-child series container must collapse after deletion");
== ConditionExpressionKind::Series
&& gapColumns(*rung->condition) == 1
&& conditionColumns(*rung->condition) == 3,
"deleting a nested node must preserve its column as an explicit gap");

require(service.removeNode(logic_id, parallel.id).succeeded,
"parallel leaf deletion must succeed");
rung = service.findRung(logic_id, rung_id);
require(rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.size() == 2U,
"single-child parallel container must collapse after deletion");
"deleting a parallel leaf must preserve the surrounding topology");
require(gapColumns(*rung->condition) == 2
&& conditionColumns(*rung->condition) == 3,
"each deleted condition must remain as a one-column gap");

require(service.setOutput(
logic_id,
@@ -268,10 +325,21 @@ void testParallelBranchGridInsertion()
"parallel grid insertion setup must create a short lower branch");

service.clearHistory();
const LogicEditorResult inserted = service.insertConditionInBranchAtColumn(
logic_id, rung_id, branch.id, 2, contact(12));
const LadderRung *padded_rung = service.findRung(logic_id, rung_id);
const ConditionExpression &padded_lower = padded_rung->condition->children.at(1);
require(padded_lower.kind == ConditionExpressionKind::Series
&& padded_lower.children.size() == 2U
&& padded_lower.children.back().kind == ConditionExpressionKind::Wire
&& padded_lower.children.back().wire->columnSpan == 2,
"a short parallel branch must persist its two padding cells as a wire");
const LogicEditorResult inserted = service.replaceWireColumnWithCondition(
logic_id,
rung_id,
padded_lower.children.back().id,
1,
contact(12));
require(inserted.succeeded,
"a visible parallel branch padding cell must accept a condition");
"a persisted parallel branch wire cell must accept a condition");
const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Parallel,
@@ -288,9 +356,12 @@ void testParallelBranchGridInsertion()
require(service.undo().succeeded,
"parallel branch grid insertion must be one undoable edit");
rung = service.findRung(logic_id, rung_id);
require(rung->condition->children.at(1).kind == ConditionExpressionKind::Node
&& rung->condition->children.at(1).node->id == branch.id,
"undo must restore the original short parallel branch");
require(rung->condition->children.at(1).kind == ConditionExpressionKind::Series
&& rung->condition->children.at(1).children.front().node->id == branch.id
&& rung->condition->children.at(1).children.back().kind
== ConditionExpressionKind::Wire
&& rung->condition->children.at(1).children.back().wire->columnSpan == 2,
"undo must restore the original branch and its explicit padding wire");

service.clearHistory();
const bool modified_before = project_service.isModified();
@@ -334,8 +405,8 @@ void testStructuredWireEditing()
"a selected wire must be replaceable by a configured node type");
const LogicEditorResult extension = service.insertWireAfter(
logic_id, rung_id, replacement.id);
require(extension.succeeded && extension.id == "wire-1",
"a wire id must become reusable after its expression is replaced");
require(extension.succeeded && extension.id == "wire-2",
"replacing one cell of a multi-cell wire must retain the original id on the remaining cell");

rung = service.findRung(logic_id, rung_id);
const ConditionExpression *extended_branch = service.findExpression(
@@ -354,9 +425,12 @@ void testStructuredWireEditing()
logic_id, rung_id, {extended_branch_id}).succeeded,
"deleting a selected vertical connection branch must succeed atomically");
rung = service.findRung(logic_id, rung_id);
require(rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.size() == 3U,
"removing a bypass branch must normalize back to the original series");
require(rung->condition.has_value()
&& conditionNodes(*rung->condition) == 3
&& conditionColumns(*rung->condition) == 4
&& wireColumns(*rung->condition) == 1
&& service.findNode(logic_id, replacement.id) == nullptr,
"removing a bypass branch must keep the materialized padding wire without reconnecting positions");
require(service.undo().succeeded
&& service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr,
"wire branch deletion must participate in ladder undo history");
@@ -487,8 +561,11 @@ void testBatchDeleteAllNodesInParallelBranch()
&& rung->condition->children.at(0).kind
== ConditionExpressionKind::Node
&& rung->condition->children.at(1).kind
== ConditionExpressionKind::Node,
"an empty parallel branch must collapse into the remaining branch");
== ConditionExpressionKind::Parallel
&& conditionNodes(*rung->condition) == 2
&& gapColumns(*rung->condition) == 3
&& conditionColumns(*rung->condition) == 4,
"deleted parallel conditions must remain as gaps without collapsing the branch");
require(service.findNode(logic_id, second.id) == nullptr
&& service.findNode(logic_id, third.id) == nullptr
&& service.findNode(logic_id, fourth.id) == nullptr,
@@ -567,9 +644,131 @@ void testUnconditionalOutputEditing()
.succeeded,
"the editor must allow a coil before any condition is added");
const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && !rung->condition.has_value()
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Wire
&& rung->condition->wire->columnSpan
== ProjectLimits::kMaximumConditionColumns
&& rung->output.has_value() && rung->validateForRunning(),
"an editor-created output-only network must be runnable as unconditional");
"an editor-created unconditional network must use a full-width wire");

const std::string contact_rung_id = makeEmptyRung(service, logic_id);
const LogicEditorResult contact_result = service.appendCondition(
logic_id, contact_rung_id, contact(0));
require(contact_result.succeeded
&& service.updateNodeConfig(
logic_id, contact_result.id, contact(0)).succeeded
&& service.setOutput(
logic_id,
contact_rung_id,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 11}, CoilMode::Normal},
true).succeeded,
"a contact followed by an output must be editable");
const LadderRung *contact_rung = service.findRung(logic_id, contact_rung_id);
require(contact_rung != nullptr && contact_rung->condition.has_value()
&& conditionNodes(*contact_rung->condition) == 1
&& wireColumns(*contact_rung->condition) == 9
&& conditionColumns(*contact_rung->condition) == 10
&& contact_rung->validateForRunning(),
"a contact followed by an output must persist the remaining nine wire cells");
}

void testExplicitGapEditing()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = makeEmptyRung(service, logic_id);
require(service.setOutput(
logic_id,
rung_id,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 30}, CoilMode::Normal},
true).succeeded,
"gap editing setup must create an explicit full-width wire");

const LadderRung *rung = service.findRung(logic_id, rung_id);
const std::string full_wire_id = rung->condition->id;
require(service.disconnectWireCells(
logic_id, rung_id, {{full_wire_id, 4}}).succeeded,
"deleting one wire cell must create a gap");
rung = service.findRung(logic_id, rung_id);
require(rung->validate() && !rung->validateForRunning()
&& conditionColumns(*rung->condition) == 10
&& wireColumns(*rung->condition) == 9
&& gapColumns(*rung->condition) == 1,
"a disconnected wire cell must preserve width and block runtime");

const ConditionExpression *gap = firstExpressionOfKind(
*rung->condition, ConditionExpressionKind::Gap);
require(gap != nullptr, "the disconnected cell must expose a gap expression");
const std::string first_gap_id = gap->id;
require(service.replaceGapColumnWithWire(
logic_id, rung_id, first_gap_id, 0).succeeded,
"a gap cell must be repairable with a real wire");
rung = service.findRung(logic_id, rung_id);
require(gapColumns(*rung->condition) == 0
&& wireColumns(*rung->condition) == 10
&& rung->validateForRunning(),
"repairing the gap with a wire must restore a runnable path");

const ConditionExpression *wire = firstExpressionOfKind(
*rung->condition, ConditionExpressionKind::Wire);
require(wire != nullptr, "the repaired path must expose a wire expression");
const std::string repaired_wire_id = wire->id;
require(service.disconnectWireCells(
logic_id, rung_id, {{repaired_wire_id, 0}}).succeeded,
"a repaired wire cell must be disconnectable again");
rung = service.findRung(logic_id, rung_id);
gap = firstExpressionOfKind(*rung->condition, ConditionExpressionKind::Gap);
require(gap != nullptr, "the second disconnection must expose a gap expression");
const std::string second_gap_id = gap->id;
const LogicEditorResult inserted_result = service.replaceGapColumnWithCondition(
logic_id, rung_id, second_gap_id, 0, contact(31));
require(inserted_result.succeeded
&& service.updateNodeConfig(
logic_id, inserted_result.id, contact(31)).succeeded,
"a gap cell must be replaceable with a contact");
rung = service.findRung(logic_id, rung_id);
const ConditionExpression *inserted = firstExpressionOfKind(
*rung->condition, ConditionExpressionKind::Node);
require(inserted != nullptr && gapColumns(*rung->condition) == 0
&& conditionNodes(*rung->condition) == 1
&& conditionColumns(*rung->condition) == 10
&& rung->validateForRunning(),
"a contact inserted into a gap must preserve the ten-column path");
const std::string inserted_node_id = inserted->node->id;
require(service.removeNode(logic_id, inserted_node_id).succeeded,
"the inserted contact must be removable");
rung = service.findRung(logic_id, rung_id);
require(conditionNodes(*rung->condition) == 0
&& gapColumns(*rung->condition) == 1
&& conditionColumns(*rung->condition) == 10
&& !rung->validateForRunning(),
"deleting a contact must restore a gap instead of reconnecting the path");

const std::string multi_rung_id = makeEmptyRung(service, logic_id);
require(service.setOutput(
logic_id,
multi_rung_id,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 32}, CoilMode::Normal},
true).succeeded,
"multi-gap setup must create a full-width wire");
const std::string multi_wire_id = service.findRung(
logic_id, multi_rung_id)->condition->id;
require(service.disconnectWireCells(
logic_id,
multi_rung_id,
{{multi_wire_id, 2}, {multi_wire_id, 7}}).succeeded,
"multiple cells in one wire must disconnect atomically");
const LadderRung *multi_rung = service.findRung(logic_id, multi_rung_id);
require(conditionColumns(*multi_rung->condition) == 10
&& wireColumns(*multi_rung->condition) == 8
&& gapColumns(*multi_rung->condition) == 2
&& !multi_rung->validateForRunning(),
"multiple disconnected cells must retain both explicit gaps");
}

void testColumnTargetedConditionInsertion()
@@ -627,12 +826,17 @@ void testColumnTargetedConditionInsertion()
true)
.succeeded,
"an output-only network must be configurable before adding a condition");
require(service.insertConditionAtColumn(
logic_id, second_rung.id, 0, contact(0)).succeeded,
"a selected first grid slot must work on an output-only network");
const std::string output_wire_id = service.findRung(
logic_id, second_rung.id)->condition->id;
require(service.replaceWireColumnWithCondition(
logic_id, second_rung.id, output_wire_id, 0, contact(0)).succeeded,
"the first wire cell must accept a contact on an unconditional network");
const LadderRung *output_rung = service.findRung(logic_id, second_rung.id);
require(output_rung != nullptr && output_rung->condition.has_value()
&& output_rung->condition->kind == ConditionExpressionKind::Node
&& output_rung->condition->kind == ConditionExpressionKind::Series
&& conditionNodes(*output_rung->condition) == 1
&& wireColumns(*output_rung->condition) == 9
&& conditionColumns(*output_rung->condition) == 10
&& output_rung->output.has_value()
&& output_rung->validate(),
"condition insertion must keep the independent output slot intact");
@@ -1149,13 +1353,18 @@ void testConditionPasteTargetsAndValidation()
{branch_source.id, branch_source_two.id, branch_source_three.id},
contact(4));
require(branch.succeeded, "branch paste setup must create a parallel branch");
const ConditionExpression &paste_lower = service.findRung(
logic_id, branch_rung)->condition->children.at(1);
require(paste_lower.kind == ConditionExpressionKind::Series
&& paste_lower.children.back().kind == ConditionExpressionKind::Wire,
"branch paste setup must persist the short branch padding wire");
LogicConditionPasteTarget branch_target;
branch_target.kind = LogicConditionPasteTargetKind::BranchEmptyColumn;
branch_target.expressionId = branch.id;
branch_target.column = 2;
branch_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn;
branch_target.expressionId = paste_lower.children.back().id;
branch_target.column = 1;
require(service.pasteConditionNodes(
logic_id, branch_rung, {source}, branch_target).succeeded,
"condition paste must support a visible empty cell in a branch");
"condition paste must support a persisted wire cell in a branch");

const std::string after_node_rung = makeEmptyRung(service, logic_id);
const LogicEditorResult after_source = service.appendCondition(
@@ -1248,6 +1457,7 @@ int main()
testBatchDeleteAllNodesInParallelBranch();
testConditionColumnLimit();
testUnconditionalOutputEditing();
testExplicitGapEditing();
testColumnTargetedConditionInsertion();
testWireColumnReplacement();
testSequentialConditionInsertionConsumesFollowingWire();


+ 42
- 8
app/tests/offline_simulation_service_tests.cpp Vedi File

@@ -1,4 +1,5 @@
#include "domain/hmi_model.h"
#include "domain/project_limits.h"
#include "domain/register_repository.h"
#include "domain/virtual_register_repository.h"
#include "services/hmi_runtime_service.h"
@@ -132,6 +133,26 @@ LadderRung rung(const std::string &id,
series.children = std::move(series_children);
result.condition = std::move(series);
}
const int occupied_columns = static_cast<int>(stages.size());
if (occupied_columns < ProjectLimits::kMaximumConditionColumns)
{
ConditionExpression wire = ConditionExpression::fromWire(
id + "-output-wire",
ProjectLimits::kMaximumConditionColumns - occupied_columns);
if (result.condition->kind == ConditionExpressionKind::Series)
{
result.condition->children.push_back(std::move(wire));
}
else
{
ConditionExpression series;
series.id = id + "-explicit-series";
series.kind = ConditionExpressionKind::Series;
series.children.push_back(std::move(*result.condition));
series.children.push_back(std::move(wire));
result.condition = std::move(series);
}
}
result.output = output;
return result;
}
@@ -179,12 +200,22 @@ void testNestedSeriesParallelExpression()
nested_series.children = {
ConditionExpression::fromNode(contact("b", 1)),
ConditionExpression::fromNode(contact("c", 2))};
ConditionExpression padded_a;
padded_a.id = "padded-a";
padded_a.kind = ConditionExpressionKind::Series;
padded_a.children = {
ConditionExpression::fromNode(contact("a", 0)),
ConditionExpression::fromWire("a-branch-wire", 1)};
ConditionExpression parallel;
parallel.id = "root-parallel";
parallel.kind = ConditionExpressionKind::Parallel;
parallel.children = {std::move(padded_a), nested_series};
ConditionExpression root;
root.id = "root-parallel";
root.kind = ConditionExpressionKind::Parallel;
root.id = "nested-output-series";
root.kind = ConditionExpressionKind::Series;
root.children = {
ConditionExpression::fromNode(contact("a", 0)),
nested_series};
std::move(parallel),
ConditionExpression::fromWire("nested-output-wire", 8)};
LadderRung nested_rung;
nested_rung.id = "nested-rung";
nested_rung.name = "nested-rung";
@@ -220,16 +251,18 @@ void testUnconditionalCoil()
LadderRung unconditional;
unconditional.id = "unconditional-rung";
unconditional.name = "unconditional-rung";
unconditional.condition = ConditionExpression::fromWire(
"unconditional-wire", ProjectLimits::kMaximumConditionColumns);
unconditional.output = coil("unconditional-coil", 10);
const ControlLogic program = logic({unconditional});

LogicTraceSnapshot trace;
require(executor.validate({program}).succeeded,
"an output-only network must pass runtime validation");
"a full-width wire network must pass runtime validation");
require(executor.executeScan({program}, repository, &trace).succeeded,
"an output-only network scan must succeed");
"a full-width wire network scan must succeed");
require(readBit(repository, 10),
"an output-only network must energize its coil as a constant-true rung");
"a full-width wire network must energize its coil as a constant-true rung");
require(trace.rungValues.at("unconditional-rung")
&& trace.nodePowerValues.at("unconditional-coil"),
"an unconditional rung must report energized power flow");
@@ -245,7 +278,8 @@ void testWirePassThroughAndPowerTrace()
root.children = {
ConditionExpression::fromNode(contact("wire-input", 0)),
ConditionExpression::fromWire("wire-segment", 2),
ConditionExpression::fromNode(contact("wire-output", 1))};
ConditionExpression::fromNode(contact("wire-output", 1)),
ConditionExpression::fromWire("wire-output-padding", 6)};
LadderRung wired_rung;
wired_rung.id = "wired-rung";
wired_rung.name = "wired-rung";


+ 27
- 2
app/tests/performance_tests.cpp Vedi File

@@ -3,9 +3,11 @@
#include "domain/virtual_register_repository.h"
#include "services/software_logic_executor.h"

#include <QDebug>
#include <QTest>

#include <string>
#include <utility>
#include <vector>

namespace
@@ -34,8 +36,15 @@ ControlLogic makeLogic(int index)
LadderRung rung;
rung.id = "rung-" + std::to_string(index);
rung.name = rung.id;
rung.condition = ConditionExpression::fromNode(
contact("contact-" + std::to_string(index), index));
ConditionExpression condition;
condition.id = "series-" + std::to_string(index);
condition.kind = ConditionExpressionKind::Series;
condition.children = {
ConditionExpression::fromNode(
contact("contact-" + std::to_string(index), index)),
ConditionExpression::fromWire(
"wire-" + std::to_string(index), 9)};
rung.condition = std::move(condition);
rung.output = coil("coil-" + std::to_string(index), index + 100);
return {"logic-" + std::to_string(index),
"logic-" + std::to_string(index),
@@ -48,6 +57,16 @@ class PerformanceTests final : public QObject
Q_OBJECT

private slots:
void initTestCase()
{
qInfo().noquote()
<< QStringLiteral("性能测试开始:下面两项 RESULT 的耗时均为平均每次执行时间");
qInfo().noquote()
<< QStringLiteral("测试项目 1:100 个控制逻辑的软件扫描");
qInfo().noquote()
<< QStringLiteral("测试项目 2:M0 到 M4000 共 4001 个虚拟 M 地址的完整写入和读取");
}

void softwareExecutor_scansRepresentativeProject()
{
std::vector<ControlLogic> logics;
@@ -89,6 +108,12 @@ private slots:
}
}
}

void cleanupTestCase()
{
qInfo().noquote()
<< QStringLiteral("性能测试结束:请结合上方两项 RESULT 和最后的 Totals 判断结果");
}
};

} // namespace


+ 87
- 8
app/tests/project_management_tests.cpp Vedi File

@@ -150,7 +150,8 @@ Project makeExampleProject()
series.children = {
std::move(parallel),
ConditionExpression::fromWire("start-wire", 2),
ConditionExpression::fromNode(compare)};
ConditionExpression::fromNode(compare),
ConditionExpression::fromWire("start-output-wire", 6)};
rung.condition = std::move(series);
rung.output = coil;
logic.rungs.push_back(rung);
@@ -167,7 +168,13 @@ Project makeExampleProject()
edge_rung.id = "edge-rung";
edge_rung.name = "Edge network";
edge_rung.comment = "上升沿输出";
edge_rung.condition = ConditionExpression::fromNode(rising_edge);
ConditionExpression edge_series;
edge_series.id = "edge-series";
edge_series.kind = ConditionExpressionKind::Series;
edge_series.children = {
ConditionExpression::fromNode(rising_edge),
ConditionExpression::fromWire("edge-output-wire", 9)};
edge_rung.condition = std::move(edge_series);
edge_rung.output = edge_output;
logic.rungs.push_back(edge_rung);

@@ -185,7 +192,13 @@ Project makeExampleProject()
LadderRung data_rung;
data_rung.id = rung_id;
data_rung.name = rung_id;
data_rung.condition = ConditionExpression::fromNode(input);
ConditionExpression data_series;
data_series.id = rung_id + "-series";
data_series.kind = ConditionExpressionKind::Series;
data_series.children = {
ConditionExpression::fromNode(input),
ConditionExpression::fromWire(rung_id + "-output-wire", 9)};
data_rung.condition = std::move(data_series);
data_rung.output = std::move(output);
logic.rungs.push_back(std::move(data_rung));
};
@@ -328,11 +341,13 @@ void testUnconditionalOutputRoundTrip()
coil.id = "unconditional-coil";
coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal};
rung.condition = ConditionExpression::fromWire(
"unconditional-wire", ProjectLimits::kMaximumConditionColumns);
rung.output = coil;
service.editProject().controlLogics.push_back(
ControlLogic{"logic-1", "Logic 1", {rung}, true});
require(service.project().validateForRunning(),
"an output-only project must be runnable before saving");
"an explicit full-width wire project must be runnable before saving");

const QString path = directory.filePath("unconditional-output.json");
require(service.saveAs(path.toStdString()).succeeded,
@@ -340,9 +355,71 @@ void testUnconditionalOutputRoundTrip()
require(service.load(path.toStdString()).succeeded,
"an output-only project must load successfully");
const LadderRung &loaded = service.project().controlLogics.front().rungs.front();
require(!loaded.condition.has_value() && loaded.output.has_value()
require(loaded.condition.has_value()
&& loaded.condition->kind == ConditionExpressionKind::Wire
&& loaded.condition->wire->columnSpan
== ProjectLimits::kMaximumConditionColumns
&& loaded.output.has_value()
&& loaded.validateForRunning(),
"JSON round trip must preserve unconditional output semantics");
"JSON round trip must preserve explicit unconditional wire semantics");

QJsonObject implicit_root = QJsonDocument::fromJson(readBytes(path)).object();
QJsonArray logics = implicit_root.value(QStringLiteral("controlLogics")).toArray();
QJsonObject logic = logics.at(0).toObject();
QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray();
QJsonObject implicit_rung = rungs.at(0).toObject();
implicit_rung.insert(QStringLiteral("condition"), QJsonValue::Null);
rungs.replace(0, implicit_rung);
logic.insert(QStringLiteral("rungs"), rungs);
logics.replace(0, logic);
implicit_root.insert(QStringLiteral("controlLogics"), logics);
const QString implicit_path = directory.filePath("implicit-output.json");
writeText(
implicit_path,
QJsonDocument(implicit_root).toJson(QJsonDocument::Indented));
require(!service.load(implicit_path.toStdString()).succeeded,
"version 1.0 implicit output wiring must not be migrated on load");
}

void testGapRoundTrip()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

JsonProjectStorage storage;
ProjectService service(storage);
require(service.createNewProject("Gap draft").succeeded,
"gap draft project creation must succeed");
LadderRung gap_rung;
gap_rung.id = "gap-rung";
gap_rung.name = "Gap rung";
gap_rung.condition = ConditionExpression::fromGap(
"gap-full-width", ProjectLimits::kMaximumConditionColumns);
gap_rung.output = LogicNode{
"gap-output",
CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
true};
service.editProject().controlLogics.push_back(
ControlLogic{"gap-logic", "Gap logic", {gap_rung}, true});
require(service.project().validate() && !service.project().validateForRunning(),
"a gap network must be saveable but not runnable");

const QString gap_path = directory.filePath("gap-project.json");
require(service.saveAs(gap_path.toStdString()).succeeded,
"a gap draft must save successfully");
require(readBytes(gap_path).contains("\"kind\": \"gap\""),
"version 1.0 JSON must persist gap expressions explicitly");
require(service.load(gap_path.toStdString()).succeeded,
"a version 1.0 gap draft must load successfully");
const LadderRung &loaded_gap =
service.project().controlLogics.front().rungs.front();
require(service.project().metadata.formatVersion == "1.0"
&& loaded_gap.condition.has_value()
&& loaded_gap.condition->kind == ConditionExpressionKind::Gap
&& loaded_gap.condition->gap->columnSpan
== ProjectLimits::kMaximumConditionColumns
&& !loaded_gap.validateForRunning(),
"gap JSON round trip must preserve the disconnected runtime state");
}

void testExampleProjectRoundTrip()
@@ -490,8 +567,9 @@ void testExampleProjectRoundTrip()
== RegisterAddress{RegisterArea::M, 6},
"edge network output must survive round trip");
require(std::holds_alternative<EdgeContactNodeConfig>(
edge_rung.condition->node->config)
&& std::get<EdgeContactNodeConfig>(edge_rung.condition->node->config).mode
edge_rung.condition->children.front().node->config)
&& std::get<EdgeContactNodeConfig>(
edge_rung.condition->children.front().node->config).mode
== EdgeMode::Rising,
"rising edge configuration must survive round trip");
require(std::get<MoveNodeConfig>(
@@ -935,6 +1013,7 @@ int main()
// 工程服务和 JSON 存储在同一测试进程中验证完整闭环
testEmptyProjectRoundTrip();
testUnconditionalOutputRoundTrip();
testGapRoundTrip();
testExampleProjectRoundTrip();
testRegisterCommentService();
testInvalidFiles();


+ 21
- 4
app/tests/runtime_mode_service_tests.cpp Vedi File

@@ -89,6 +89,19 @@ private:

using TestSupport::require;

ConditionExpression conditionWithOutputWire(
LogicNode node,
const std::string &wire_id)
{
ConditionExpression series;
series.id = wire_id + "-series";
series.kind = ConditionExpressionKind::Series;
series.children = {
ConditionExpression::fromNode(std::move(node)),
ConditionExpression::fromWire(wire_id, 9)};
return series;
}

void testModeTransitions()
{
// 验证服务将 PLC 首读状态与领域模式切换规则正确组合
@@ -126,7 +139,8 @@ void testModeTransitions()
LadderRung edge_rung;
edge_rung.id = "poll-edge-rung";
edge_rung.name = "Poll edge";
edge_rung.condition = ConditionExpression::fromNode(edge);
edge_rung.condition = conditionWithOutputWire(
edge, "poll-edge-output-wire");
edge_rung.output = edge_coil;
logic.rungs.push_back(edge_rung);
LogicNode comparison;
@@ -140,7 +154,8 @@ void testModeTransitions()
LadderRung comparison_rung;
comparison_rung.id = "poll-comparison-rung";
comparison_rung.name = "Poll comparison";
comparison_rung.condition = ConditionExpression::fromNode(comparison);
comparison_rung.condition = conditionWithOutputWire(
comparison, "poll-comparison-output-wire");
comparison_rung.output = comparison_coil;
logic.rungs.push_back(comparison_rung);
LogicNode move_input;
@@ -158,7 +173,8 @@ void testModeTransitions()
LadderRung move_rung;
move_rung.id = "poll-move-rung";
move_rung.name = "Poll MOVE";
move_rung.condition = ConditionExpression::fromNode(move_input);
move_rung.condition = conditionWithOutputWire(
move_input, "poll-move-output-wire");
move_rung.output = move_output;
logic.rungs.push_back(move_rung);
LogicNode add_input;
@@ -181,7 +197,8 @@ void testModeTransitions()
LadderRung add_rung;
add_rung.id = "poll-add-rung";
add_rung.name = "Poll ADD";
add_rung.condition = ConditionExpression::fromNode(add_input);
add_rung.condition = conditionWithOutputWire(
add_input, "poll-add-output-wire");
add_rung.output = add_output;
logic.rungs.push_back(add_rung);
project.controlLogics.push_back(logic);


Caricamento…
Annulla
Salva