| @@ -164,7 +164,8 @@ void normalizeExpression(ConditionExpression *expression) | |||||
| { | { | ||||
| if (expression == nullptr | if (expression == nullptr | ||||
| || expression->kind == ConditionExpressionKind::Node | || expression->kind == ConditionExpressionKind::Node | ||||
| || expression->kind == ConditionExpressionKind::Wire) | |||||
| || expression->kind == ConditionExpressionKind::Wire | |||||
| || expression->kind == ConditionExpressionKind::Gap) | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -243,6 +244,7 @@ bool validateConditionExpression( | |||||
| if (expression.kind == ConditionExpressionKind::Node) | if (expression.kind == ConditionExpressionKind::Node) | ||||
| { | { | ||||
| if (!expression.node.has_value() || expression.wire.has_value() | if (!expression.node.has_value() || expression.wire.has_value() | ||||
| || expression.gap.has_value() | |||||
| || !expression.children.empty() || !expression.node->isCondition()) | || !expression.children.empty() || !expression.node->isCondition()) | ||||
| { | { | ||||
| setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); | setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); | ||||
| @@ -255,6 +257,7 @@ bool validateConditionExpression( | |||||
| if (expression.kind == ConditionExpressionKind::Wire) | if (expression.kind == ConditionExpressionKind::Wire) | ||||
| { | { | ||||
| if (expression.node.has_value() || !expression.wire.has_value() | if (expression.node.has_value() || !expression.wire.has_value() | ||||
| || expression.gap.has_value() | |||||
| || !expression.children.empty()) | || !expression.children.empty()) | ||||
| { | { | ||||
| setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); | setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); | ||||
| @@ -268,6 +271,22 @@ bool validateConditionExpression( | |||||
| *rows = 1; | *rows = 1; | ||||
| return true; | 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 | if (expression.kind != ConditionExpressionKind::Series | ||||
| && expression.kind != ConditionExpressionKind::Parallel) | && expression.kind != ConditionExpressionKind::Parallel) | ||||
| { | { | ||||
| @@ -275,6 +294,7 @@ bool validateConditionExpression( | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (expression.node.has_value() || expression.wire.has_value() | if (expression.node.has_value() || expression.wire.has_value() | ||||
| || expression.gap.has_value() | |||||
| || expression.children.size() < 2U) | || expression.children.size() < 2U) | ||||
| { | { | ||||
| setError(error, "串联和并联表达式至少需要两个子表达式"); | setError(error, "串联和并联表达式至少需要两个子表达式"); | ||||
| @@ -288,6 +308,7 @@ bool validateConditionExpression( | |||||
| int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | ||||
| int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; | int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; | ||||
| int parallel_columns = -1; | |||||
| for (const ConditionExpression &child : expression.children) | for (const ConditionExpression &child : expression.children) | ||||
| { | { | ||||
| if (child.kind == expression.kind) | if (child.kind == expression.kind) | ||||
| @@ -316,6 +337,12 @@ bool validateConditionExpression( | |||||
| } | } | ||||
| else | 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); | total_columns = std::max(total_columns, child_columns); | ||||
| if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) | if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) | ||||
| { | { | ||||
| @@ -336,6 +363,30 @@ bool validateConditionExpression( | |||||
| return true; | 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 | } // namespace | ||||
| bool WordOperand::validate(std::string *error) const | bool WordOperand::validate(std::string *error) const | ||||
| @@ -481,6 +532,16 @@ bool WireSegment::validate(std::string *error) const | |||||
| return true; | 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( | ConditionExpression ConditionExpression::fromWire( | ||||
| std::string expression_id, int column_span) | std::string expression_id, int column_span) | ||||
| { | { | ||||
| @@ -491,6 +552,16 @@ ConditionExpression ConditionExpression::fromWire( | |||||
| return expression; | 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 | bool ConditionExpression::validate(std::string *error) const | ||||
| { | { | ||||
| std::size_t node_count = 0U; | std::size_t node_count = 0U; | ||||
| @@ -519,6 +590,11 @@ bool ConditionExpression::validateForRunning(std::string *error) const | |||||
| { | { | ||||
| return true; | return true; | ||||
| } | } | ||||
| if (kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| setError(error, "梯形图条件区存在未连接的空白网格"); | |||||
| return false; | |||||
| } | |||||
| for (const ConditionExpression &child : children) | for (const ConditionExpression &child : children) | ||||
| { | { | ||||
| if (!child.validateForRunning(error)) | if (!child.validateForRunning(error)) | ||||
| @@ -539,6 +615,7 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression | |||||
| normalizeExpression(&expression->value()); | normalizeExpression(&expression->value()); | ||||
| if (expression->value().kind != ConditionExpressionKind::Node | if (expression->value().kind != ConditionExpressionKind::Node | ||||
| && expression->value().kind != ConditionExpressionKind::Wire | && expression->value().kind != ConditionExpressionKind::Wire | ||||
| && expression->value().kind != ConditionExpressionKind::Gap | |||||
| && expression->value().children.empty()) | && expression->value().children.empty()) | ||||
| { | { | ||||
| expression->reset(); | expression->reset(); | ||||
| @@ -552,6 +629,10 @@ const LogicNode *findConditionNode( | |||||
| { | { | ||||
| return nullptr; | return nullptr; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return nullptr; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Node) | if (expression.kind == ConditionExpressionKind::Node) | ||||
| { | { | ||||
| return expression.node->id == node_id ? &*expression.node : nullptr; | return expression.node->id == node_id ? &*expression.node : nullptr; | ||||
| @@ -614,6 +695,10 @@ void collectConditionNodes( | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return; | |||||
| } | |||||
| for (const ConditionExpression &child : expression.children) | for (const ConditionExpression &child : expression.children) | ||||
| { | { | ||||
| collectConditionNodes(child, nodes); | collectConditionNodes(child, nodes); | ||||
| @@ -707,6 +792,14 @@ bool LadderRung::validateStructure(std::string *error) const | |||||
| node_ids.push_back(node->id); | 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.has_value()) | ||||
| { | { | ||||
| if (!output->validate(error) || !output->isOutput()) | if (!output->validate(error) || !output->isOutput()) | ||||
| @@ -152,6 +152,7 @@ enum class ConditionExpressionKind | |||||
| { | { | ||||
| Node, // 一个实际的条件节点 | Node, // 一个实际的条件节点 | ||||
| Wire, // 一段横线 | Wire, // 一段横线 | ||||
| Gap, // 一段明确断开的空白网格 | |||||
| Series, // 多个条件串联,必须全部满足 | Series, // 多个条件串联,必须全部满足 | ||||
| Parallel // 多个条件并联,满足任意一条即可 | Parallel // 多个条件并联,满足任意一条即可 | ||||
| }; | }; | ||||
| @@ -168,6 +169,18 @@ struct WireSegment | |||||
| bool validate(std::string *error = nullptr) const; | 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 | struct ConditionExpression | ||||
| { | { | ||||
| @@ -175,12 +188,15 @@ struct ConditionExpression | |||||
| ConditionExpressionKind kind = ConditionExpressionKind::Node; // 表达式类型 | ConditionExpressionKind kind = ConditionExpressionKind::Node; // 表达式类型 | ||||
| std::optional<LogicNode> node; // kind 为 Node 时保存的节点 | std::optional<LogicNode> node; // kind 为 Node 时保存的节点 | ||||
| std::optional<WireSegment> wire; // kind 为 Wire 时保存的横线 | std::optional<WireSegment> wire; // kind 为 Wire 时保存的横线 | ||||
| std::optional<GapSegment> gap; // kind 为 Gap 时保存的断路 | |||||
| std::vector<ConditionExpression> children; // 串联或并联的子表达式 | std::vector<ConditionExpression> children; // 串联或并联的子表达式 | ||||
| // 把一个逻辑节点包装成条件表达式 | // 把一个逻辑节点包装成条件表达式 | ||||
| static ConditionExpression fromNode(LogicNode node); | static ConditionExpression fromNode(LogicNode node); | ||||
| // 创建一段指定列数的横线表达式 | // 创建一段指定列数的横线表达式 | ||||
| static ConditionExpression fromWire(std::string id, int column_span = 1); | 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; | bool validate(std::string *error = nullptr) const; | ||||
| // 运行态校验额外要求每个节点都已经配置完成 | // 运行态校验额外要求每个节点都已经配置完成 | ||||
| @@ -214,13 +230,13 @@ void collectConditionNodes( | |||||
| void collectConditionExpressionIds( | void collectConditionExpressionIds( | ||||
| const ConditionExpression &expression, std::vector<std::string> *ids); | const ConditionExpression &expression, std::vector<std::string> *ids); | ||||
| // 条件表达式为空时表示恒真网络,输出指令固定在最右侧 | |||||
| // 输出指令固定在第 11 列;有输出时前 10 列必须由显式条件、横线或断路占满 | |||||
| struct LadderRung | struct LadderRung | ||||
| { | { | ||||
| std::string id; // 网络唯一 ID | std::string id; // 网络唯一 ID | ||||
| std::string name; // 网络名称 | std::string name; // 网络名称 | ||||
| std::string comment; // 网络注释 | std::string comment; // 网络注释 | ||||
| std::optional<ConditionExpression> condition; // 条件表达式,没有时表示恒真 | |||||
| std::optional<ConditionExpression> condition; // 条件区表达式;空值只用于空网络草稿 | |||||
| std::optional<LogicNode> output; // 网络右侧的输出指令 | std::optional<LogicNode> output; // 网络右侧的输出指令 | ||||
| // 编辑态允许没有条件或输出,便于逐步搭建网络 | // 编辑态允许没有条件或输出,便于逐步搭建网络 | ||||
| @@ -1308,6 +1308,8 @@ QString expressionKindText(ConditionExpressionKind kind) | |||||
| return QStringLiteral("node"); | return QStringLiteral("node"); | ||||
| case ConditionExpressionKind::Wire: | case ConditionExpressionKind::Wire: | ||||
| return QStringLiteral("wire"); | return QStringLiteral("wire"); | ||||
| case ConditionExpressionKind::Gap: | |||||
| return QStringLiteral("gap"); | |||||
| case ConditionExpressionKind::Series: | case ConditionExpressionKind::Series: | ||||
| return QStringLiteral("series"); | return QStringLiteral("series"); | ||||
| case ConditionExpressionKind::Parallel: | case ConditionExpressionKind::Parallel: | ||||
| @@ -1329,6 +1331,10 @@ QJsonObject serializeConditionExpression(const ConditionExpression &expression) | |||||
| { | { | ||||
| object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); | object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); | ||||
| } | } | ||||
| else if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| object.insert(QStringLiteral("columnSpan"), expression.gap->columnSpan); | |||||
| } | |||||
| else | else | ||||
| { | { | ||||
| QJsonArray children; | QJsonArray children; | ||||
| @@ -1404,11 +1410,29 @@ bool parseConditionExpression( | |||||
| expression->wire = WireSegment{column_span}; | expression->wire = WireSegment{column_span}; | ||||
| return true; | 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") | if (kind != "series" && kind != "parallel") | ||||
| { | { | ||||
| return state->fail( | return state->fail( | ||||
| ProjectStorageError::InvalidField, | ProjectStorageError::InvalidField, | ||||
| context + ".kind 必须是 node、wire、series 或 parallel"); | |||||
| context + ".kind 必须是 node、wire、gap、series 或 parallel"); | |||||
| } | } | ||||
| QJsonArray children; | QJsonArray children; | ||||
| if (!readArray( | if (!readArray( | ||||
| @@ -1875,7 +1899,6 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||||
| { | { | ||||
| return {false, {}, state.error, state.message}; | return {false, {}, state.error, state.message}; | ||||
| } | } | ||||
| // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 | // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 | ||||
| std::string validation_error; | std::string validation_error; | ||||
| if (!project.validate(&validation_error)) | if (!project.validate(&validation_error)) | ||||
| @@ -2,7 +2,7 @@ | |||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| // 使用严格 1.0 JSON 文件保存和加载工程;加载成功后才替换调用方工程 | |||||
| // 严格读写当前 1.0 JSON 工程格式 | |||||
| class JsonProjectStorage final : public ProjectStorage | class JsonProjectStorage final : public ProjectStorage | ||||
| { | { | ||||
| public: | public: | ||||
| @@ -29,6 +29,8 @@ | |||||
| // 程序从这里开始,argc 和 argv 保存用户启动程序时附带的命令行参数 | // 程序从这里开始,argc 和 argv 保存用户启动程序时附带的命令行参数 | ||||
| int main(int argc, char *argv[]) | int main(int argc, char *argv[]) | ||||
| { | { | ||||
| // Windows 默认会给对话框添加上下文帮助按钮,应用内统一隐藏 | |||||
| QApplication::setAttribute(Qt::AA_DisableWindowContextHelpButton); | |||||
| // 创建整个 Qt 应用,后面的窗口、按钮和消息循环都要依靠它 | // 创建整个 Qt 应用,后面的窗口、按钮和消息循环都要依靠它 | ||||
| QApplication application(argc, argv); | QApplication application(argc, argv); | ||||
| // 设置程序名称,Qt 的窗口信息和配置中会用到这个名字 | // 设置程序名称,Qt 的窗口信息和配置中会用到这个名字 | ||||
| @@ -147,6 +147,10 @@ int expressionColumns(const ConditionExpression &expression) | |||||
| { | { | ||||
| return expression.wire->columnSpan; | return expression.wire->columnSpan; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return expression.gap->columnSpan; | |||||
| } | |||||
| int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | ||||
| for (const ConditionExpression &child : expression.children) | for (const ConditionExpression &child : expression.children) | ||||
| { | { | ||||
| @@ -369,7 +373,8 @@ bool addParallelForWireCellRange( | |||||
| return true; | return true; | ||||
| } | } | ||||
| if (expression->kind == ConditionExpressionKind::Node | if (expression->kind == ConditionExpressionKind::Node | ||||
| || expression->kind == ConditionExpressionKind::Wire) | |||||
| || expression->kind == ConditionExpressionKind::Wire | |||||
| || expression->kind == ConditionExpressionKind::Gap) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -405,7 +410,8 @@ bool findWireCellLocation( | |||||
| WireCellLocation *location) | WireCellLocation *location) | ||||
| { | { | ||||
| if (expression.kind == ConditionExpressionKind::Node | if (expression.kind == ConditionExpressionKind::Node | ||||
| || expression.kind == ConditionExpressionKind::Wire) | |||||
| || expression.kind == ConditionExpressionKind::Wire | |||||
| || expression.kind == ConditionExpressionKind::Gap) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -517,7 +523,8 @@ bool removeExpressionRecursive( | |||||
| { | { | ||||
| if (expression == nullptr | if (expression == nullptr | ||||
| || expression->kind == ConditionExpressionKind::Node | || expression->kind == ConditionExpressionKind::Node | ||||
| || expression->kind == ConditionExpressionKind::Wire) | |||||
| || expression->kind == ConditionExpressionKind::Wire | |||||
| || expression->kind == ConditionExpressionKind::Gap) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -553,7 +560,8 @@ std::optional<int> selectedExpressionColumns( | |||||
| return expressionColumns(expression); | return expressionColumns(expression); | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Node | if (expression.kind == ConditionExpressionKind::Node | ||||
| || expression.kind == ConditionExpressionKind::Wire) | |||||
| || expression.kind == ConditionExpressionKind::Wire | |||||
| || expression.kind == ConditionExpressionKind::Gap) | |||||
| { | { | ||||
| return std::nullopt; | return std::nullopt; | ||||
| } | } | ||||
| @@ -620,6 +628,52 @@ LadderRung *findEditableRung( | |||||
| return rung == logic->rungs.end() ? nullptr : &*rung; | 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 | } // namespace | ||||
| LogicEditorService::LogicEditorService(ProjectService &project_service) | LogicEditorService::LogicEditorService(ProjectService &project_service) | ||||
| @@ -705,6 +759,7 @@ bool LogicEditorService::expressionsEqual( | |||||
| if (left.id != right.id || left.kind != right.kind | if (left.id != right.id || left.kind != right.kind | ||||
| || left.node.has_value() != right.node.has_value() | || left.node.has_value() != right.node.has_value() | ||||
| || left.wire.has_value() != right.wire.has_value() | || left.wire.has_value() != right.wire.has_value() | ||||
| || left.gap.has_value() != right.gap.has_value() | |||||
| || left.children.size() != right.children.size()) | || left.children.size() != right.children.size()) | ||||
| { | { | ||||
| return false; | return false; | ||||
| @@ -718,6 +773,11 @@ bool LogicEditorService::expressionsEqual( | |||||
| { | { | ||||
| return false; | 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) | for (std::size_t index = 0; index < left.children.size(); ++index) | ||||
| { | { | ||||
| if (!expressionsEqual(left.children[index], right.children[index])) | if (!expressionsEqual(left.children[index], right.children[index])) | ||||
| @@ -1194,7 +1254,69 @@ LogicEditorResult LogicEditorService::appendCondition( | |||||
| std::nullopt}); | std::nullopt}); | ||||
| } | } | ||||
| LadderRung *rung = findEditableRung(project, logic_id, target_rung_id); | 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); | rung->condition = std::move(leaf); | ||||
| } | } | ||||
| @@ -1205,14 +1327,15 @@ LogicEditorResult LogicEditorService::appendCondition( | |||||
| else | else | ||||
| { | { | ||||
| rung->condition = makeContainer( | rung->condition = makeContainer( | ||||
| makeUniqueExpressionId(*logic), | |||||
| makeUniqueExpressionId(*editable_logic), | |||||
| ConditionExpressionKind::Series, | ConditionExpressionKind::Series, | ||||
| std::move(*rung->condition), | std::move(*rung->condition), | ||||
| std::move(leaf)); | std::move(leaf)); | ||||
| } | } | ||||
| std::string validation_error; | 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); | rollbackEdit(std::move(before), modified_before); | ||||
| return failure(LogicEditorError::InvalidOperation, validation_error); | return failure(LogicEditorError::InvalidOperation, validation_error); | ||||
| @@ -1259,6 +1382,9 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn( | |||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| const bool modified_before = project_service_.isModified(); | const bool modified_before = project_service_.isModified(); | ||||
| Project &project = project_service_.editProject(); | 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); | LadderRung *rung = findEditableRung(project, logic_id, rung_id); | ||||
| if (leading_wire_columns > 0) | if (leading_wire_columns > 0) | ||||
| @@ -1308,7 +1434,8 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn( | |||||
| std::string validation_error; | 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); | rollbackEdit(std::move(before), modified_before); | ||||
| return failure(LogicEditorError::InvalidOperation, validation_error); | return failure(LogicEditorError::InvalidOperation, validation_error); | ||||
| @@ -1369,8 +1496,12 @@ LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn( | |||||
| makeNode(node_id, config)); | makeNode(node_id, config)); | ||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| const bool modified_before = project_service_.isModified(); | 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( | LadderRung *rung = findEditableRung( | ||||
| project_service_.editProject(), logic_id, rung_id); | |||||
| project, logic_id, rung_id); | |||||
| ConditionExpression *editable_branch = findConditionExpression( | ConditionExpression *editable_branch = findConditionExpression( | ||||
| *rung->condition, branch_expression_id); | *rung->condition, branch_expression_id); | ||||
| @@ -1399,7 +1530,8 @@ LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn( | |||||
| } | } | ||||
| std::string validation_error; | 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); | rollbackEdit(std::move(before), modified_before); | ||||
| return failure(LogicEditorError::InvalidOperation, validation_error); | return failure(LogicEditorError::InvalidOperation, validation_error); | ||||
| @@ -1514,6 +1646,9 @@ LogicEditorResult LogicEditorService::insertConditionAfter( | |||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| const bool modified_before = project_service_.isModified(); | const bool modified_before = project_service_.isModified(); | ||||
| Project &project = project_service_.editProject(); | 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); | LadderRung *rung = findEditableRung(project, logic_id, rung_id); | ||||
| ConditionExpression *target = findConditionExpression(*rung->condition, target_node_id); | ConditionExpression *target = findConditionExpression(*rung->condition, target_node_id); | ||||
| ConditionExpression *parent = findParentExpression(*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; | const auto insertion_position = target_iterator + 1; | ||||
| if (insertion_position != parent->children.end() | 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); | *insertion_position = std::move(leaf); | ||||
| } | } | ||||
| else | else | ||||
| { | { | ||||
| --insertion_position->wire->columnSpan; | |||||
| --span; | |||||
| parent->children.insert(insertion_position, std::move(leaf)); | parent->children.insert(insertion_position, std::move(leaf)); | ||||
| } | } | ||||
| } | } | ||||
| @@ -1549,13 +1688,14 @@ LogicEditorResult LogicEditorService::insertConditionAfter( | |||||
| { | { | ||||
| ConditionExpression original = std::move(*target); | ConditionExpression original = std::move(*target); | ||||
| *target = makeContainer( | *target = makeContainer( | ||||
| makeUniqueExpressionId(*logic), | |||||
| makeUniqueExpressionId(*editable_logic), | |||||
| ConditionExpressionKind::Series, | ConditionExpressionKind::Series, | ||||
| std::move(original), | std::move(original), | ||||
| std::move(leaf)); | std::move(leaf)); | ||||
| } | } | ||||
| std::string validation_error; | 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); | rollbackEdit(std::move(before), modified_before); | ||||
| return failure(LogicEditorError::InvalidOperation, validation_error); | return failure(LogicEditorError::InvalidOperation, validation_error); | ||||
| @@ -1592,8 +1732,12 @@ LogicEditorResult LogicEditorService::insertWireAfter( | |||||
| ConditionExpression leaf = ConditionExpression::fromWire(wire_id, column_span); | ConditionExpression leaf = ConditionExpression::fromWire(wire_id, column_span); | ||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| const bool modified_before = project_service_.isModified(); | 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( | LadderRung *rung = findEditableRung( | ||||
| project_service_.editProject(), logic_id, rung_id); | |||||
| project, logic_id, rung_id); | |||||
| ConditionExpression *editable_target = findConditionExpression( | ConditionExpression *editable_target = findConditionExpression( | ||||
| *rung->condition, target_expression_id); | *rung->condition, target_expression_id); | ||||
| ConditionExpression *parent = findParentExpression( | ConditionExpression *parent = findParentExpression( | ||||
| @@ -1612,13 +1756,14 @@ LogicEditorResult LogicEditorService::insertWireAfter( | |||||
| { | { | ||||
| ConditionExpression original = std::move(*editable_target); | ConditionExpression original = std::move(*editable_target); | ||||
| *editable_target = makeContainer( | *editable_target = makeContainer( | ||||
| makeUniqueExpressionId(*logic), | |||||
| makeUniqueExpressionId(*editable_logic), | |||||
| ConditionExpressionKind::Series, | ConditionExpressionKind::Series, | ||||
| std::move(original), | std::move(original), | ||||
| std::move(leaf)); | std::move(leaf)); | ||||
| } | } | ||||
| std::string validation_error; | 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); | rollbackEdit(std::move(before), modified_before); | ||||
| return failure(LogicEditorError::InvalidOperation, validation_error); | return failure(LogicEditorError::InvalidOperation, validation_error); | ||||
| @@ -1645,6 +1790,11 @@ LogicEditorResult LogicEditorService::replaceWireWithCondition( | |||||
| { | { | ||||
| return failure(LogicEditorError::InvalidNode, "横线只能替换为条件节点"); | 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)); | const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | ||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| const bool modified_before = project_service_.isModified(); | const bool modified_before = project_service_.isModified(); | ||||
| @@ -1715,12 +1865,85 @@ LogicEditorResult LogicEditorService::replaceWireColumnWithCondition( | |||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| const bool modified_before = project_service_.isModified(); | const bool modified_before = project_service_.isModified(); | ||||
| Project &project = project_service_.editProject(); | 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); | LadderRung *rung = findEditableRung(project, logic_id, rung_id); | ||||
| ConditionExpression *editable = findConditionExpression( | ConditionExpression *editable = findConditionExpression( | ||||
| *rung->condition, wire_expression_id); | *rung->condition, wire_expression_id); | ||||
| *editable = std::move(replacement); | *editable = std::move(replacement); | ||||
| normalizeConditionExpression(&rung->condition); | normalizeConditionExpression(&rung->condition); | ||||
| std::string validation_error; | 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)) | if (!rung->validate(&validation_error)) | ||||
| { | { | ||||
| rollbackEdit(std::move(before), modified_before); | rollbackEdit(std::move(before), modified_before); | ||||
| @@ -1730,6 +1953,69 @@ LogicEditorResult LogicEditorService::replaceWireColumnWithCondition( | |||||
| return {true, LogicEditorError::None, {}, node_id}; | 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( | LogicEditorResult LogicEditorService::addParallelBranch( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| @@ -1757,8 +2043,13 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||||
| LogicEditorError::InvalidOperation, | 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( | return failure( | ||||
| LogicEditorError::NodeNotFound, | LogicEditorError::NodeNotFound, | ||||
| @@ -1775,6 +2066,9 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| const bool modified_before = project_service_.isModified(); | const bool modified_before = project_service_.isModified(); | ||||
| Project &project = project_service_.editProject(); | 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); | LadderRung *rung = findEditableRung(project, logic_id, rung_id); | ||||
| if (!addParallelForSelection( | if (!addParallelForSelection( | ||||
| &*rung->condition, | &*rung->condition, | ||||
| @@ -1789,7 +2083,8 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||||
| "并联选择必须是一个连续的逻辑范围"); | "并联选择必须是一个连续的逻辑范围"); | ||||
| } | } | ||||
| std::string validation_error; | 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); | rollbackEdit(std::move(before), modified_before); | ||||
| return failure(LogicEditorError::InvalidOperation, validation_error); | return failure(LogicEditorError::InvalidOperation, validation_error); | ||||
| @@ -2197,6 +2492,7 @@ LogicEditorResult LogicEditorService::setOutput( | |||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| const bool modified_before = project_service_.isModified(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto editable_logic = std::find_if( | auto editable_logic = std::find_if( | ||||
| project.controlLogics.begin(), project.controlLogics.end(), | project.controlLogics.begin(), project.controlLogics.end(), | ||||
| @@ -2210,7 +2506,16 @@ LogicEditorResult LogicEditorService::setOutput( | |||||
| std::nullopt, | std::nullopt, | ||||
| 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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| @@ -2353,6 +2658,10 @@ LogicEditorResult LogicEditorService::pasteConditionNodes( | |||||
| current = replaceWireColumnWithCondition( | current = replaceWireColumnWithCondition( | ||||
| logic_id, rung_id, target.expressionId, target.column, source.config); | logic_id, rung_id, target.expressionId, target.column, source.config); | ||||
| break; | break; | ||||
| case LogicConditionPasteTargetKind::ReplaceGapColumn: | |||||
| current = replaceGapColumnWithCondition( | |||||
| logic_id, rung_id, target.expressionId, target.column, source.config); | |||||
| break; | |||||
| case LogicConditionPasteTargetKind::Append: | case LogicConditionPasteTargetKind::Append: | ||||
| default: | default: | ||||
| current = appendCondition(logic_id, rung_id, source.config); | current = appendCondition(logic_id, rung_id, source.config); | ||||
| @@ -2530,6 +2839,11 @@ LogicEditorResult LogicEditorService::pasteRung( | |||||
| clone.id = unique_wire_id(); | clone.id = unique_wire_id(); | ||||
| clone.wire = source_expression.wire; | clone.wire = source_expression.wire; | ||||
| } | } | ||||
| else if (source_expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| clone.id = unique_expression_id(); | |||||
| clone.gap = source_expression.gap; | |||||
| } | |||||
| else | else | ||||
| { | { | ||||
| clone.id = unique_expression_id(); | clone.id = unique_expression_id(); | ||||
| @@ -2615,18 +2929,16 @@ LogicEditorResult LogicEditorService::removeNodes( | |||||
| { | { | ||||
| for (const std::string &node_id : node_ids) | 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) | for (const LadderRung &rung : editable_logic->rungs) | ||||
| @@ -2652,7 +2964,11 @@ LogicEditorResult LogicEditorService::removeExpression( | |||||
| return failure(LogicEditorError::ExpressionNotFound, "未找到条件支路"); | return failure(LogicEditorError::ExpressionNotFound, "未找到条件支路"); | ||||
| } | } | ||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| const bool modified_before = project_service_.isModified(); | |||||
| Project &project = project_service_.editProject(); | 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); | LadderRung *rung = findEditableRung(project, logic_id, rung_id); | ||||
| if (rung->condition->id == expression_id) | if (rung->condition->id == expression_id) | ||||
| { | { | ||||
| @@ -2663,6 +2979,13 @@ LogicEditorResult LogicEditorService::removeExpression( | |||||
| removeExpressionRecursive(&*rung->condition, expression_id); | removeExpressionRecursive(&*rung->condition, expression_id); | ||||
| normalizeConditionExpression(&rung->condition); | 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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, expression_id}; | return {true, LogicEditorError::None, {}, expression_id}; | ||||
| } | } | ||||
| @@ -2699,8 +3022,13 @@ LogicEditorResult LogicEditorService::removeExpressions( | |||||
| } | } | ||||
| HistoryState before = captureState(); | 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( | LadderRung *rung = findEditableRung( | ||||
| project_service_.editProject(), logic_id, rung_id); | |||||
| project, logic_id, rung_id); | |||||
| for (const std::string &expression_id : expression_ids) | for (const std::string &expression_id : expression_ids) | ||||
| { | { | ||||
| if (!rung->condition.has_value()) | if (!rung->condition.has_value()) | ||||
| @@ -2715,10 +3043,164 @@ LogicEditorResult LogicEditorService::removeExpressions( | |||||
| removeExpressionRecursive(&*rung->condition, expression_id); | removeExpressionRecursive(&*rung->condition, expression_id); | ||||
| } | } | ||||
| normalizeConditionExpression(&rung->condition); | 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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, expression_ids.front()}; | 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 | bool LogicEditorService::canUndo() const | ||||
| { | { | ||||
| return history_.canUndo(); | 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) | std::string LogicEditorService::makeUniqueExpressionId(const ControlLogic &logic) | ||||
| { | { | ||||
| for (std::size_t index = 1;; ++index) | 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( | LogicEditorResult LogicEditorService::failure( | ||||
| LogicEditorError error, const std::string &message) | LogicEditorError error, const std::string &message) | ||||
| { | { | ||||
| @@ -40,7 +40,8 @@ enum class LogicConditionPasteTargetKind | |||||
| BranchEmptyColumn, | BranchEmptyColumn, | ||||
| AfterNode, | AfterNode, | ||||
| ReplaceWire, | ReplaceWire, | ||||
| ReplaceWireColumn | |||||
| ReplaceWireColumn, | |||||
| ReplaceGapColumn | |||||
| }; | }; | ||||
| struct LogicConditionPasteTarget | struct LogicConditionPasteTarget | ||||
| @@ -168,6 +169,19 @@ public: | |||||
| const std::string &wire_expression_id, | const std::string &wire_expression_id, | ||||
| int column_offset, | int column_offset, | ||||
| const LogicNodeConfig &config); | 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 将选中的条件节点建立为并联分支 | * @brief 将选中的条件节点建立为并联分支 | ||||
| * @param selected_node_ids 同一网络中按视觉连续范围选择的条件节点 ID | * @param selected_node_ids 同一网络中按视觉连续范围选择的条件节点 ID | ||||
| @@ -255,6 +269,16 @@ public: | |||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::vector<std::string> &expression_ids); | 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 判断是否存在可撤销的梯形图编辑操作 */ | /** @brief 判断是否存在可撤销的梯形图编辑操作 */ | ||||
| @@ -306,10 +330,15 @@ private: | |||||
| const ControlLogic &logic, const std::string &prefix); | const ControlLogic &logic, const std::string &prefix); | ||||
| // 生成唯一横线表达式 ID | // 生成唯一横线表达式 ID | ||||
| static std::string makeUniqueWireId(const ControlLogic &logic); | static std::string makeUniqueWireId(const ControlLogic &logic); | ||||
| // 生成控制逻辑内唯一的断路表达式 ID | |||||
| static std::string makeUniqueGapId(const ControlLogic &logic); | |||||
| // 生成唯一容器表达式 ID | // 生成唯一容器表达式 ID | ||||
| static std::string makeUniqueExpressionId(const ControlLogic &logic); | static std::string makeUniqueExpressionId(const ControlLogic &logic); | ||||
| // 生成唯一网络 ID | // 生成唯一网络 ID | ||||
| static std::string makeUniqueRungId(const ControlLogic &logic); | static std::string makeUniqueRungId(const ControlLogic &logic); | ||||
| // 把并联补线和输出前连接线转换为显式 Wire,并保持输出网络十列布局 | |||||
| static bool repairExplicitLayout( | |||||
| ControlLogic &logic, LadderRung &rung, std::string *error = nullptr); | |||||
| // 创建统一的失败结果 | // 创建统一的失败结果 | ||||
| static LogicEditorResult failure( | static LogicEditorResult failure( | ||||
| LogicEditorError error, const std::string &message); | LogicEditorError error, const std::string &message); | ||||
| @@ -208,7 +208,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||||
| continue; | continue; | ||||
| } | } | ||||
| // 没有条件节点的网络等价于左侧电源线直接接通,作为恒真条件执行 | |||||
| // 输出网络的显式 Wire 会计算为真;空网络只作为编辑草稿跳过 | |||||
| bool rung_value = true; | bool rung_value = true; | ||||
| LogicScanResult result = success(); | LogicScanResult result = success(); | ||||
| if (rung.condition.has_value()) | if (rung.condition.has_value()) | ||||
| @@ -302,6 +302,15 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||||
| } | } | ||||
| return success(); | return success(); | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "梯形图条件区存在未连接的空白网格", | |||||
| logic_id, | |||||
| {}, | |||||
| expression.id); | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Node) | if (expression.kind == ConditionExpressionKind::Node) | ||||
| { | { | ||||
| LogicScanResult result = evaluateCondition( | LogicScanResult result = evaluateCondition( | ||||
| @@ -150,6 +150,10 @@ ExpressionMetrics measureExpression(const ConditionExpression &expression) | |||||
| { | { | ||||
| return {expression.wire->columnSpan, 1}; | return {expression.wire->columnSpan, 1}; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return {expression.gap->columnSpan, 1}; | |||||
| } | |||||
| ExpressionMetrics metrics{0, 0}; | ExpressionMetrics metrics{0, 0}; | ||||
| if (expression.kind == ConditionExpressionKind::Series) | if (expression.kind == ConditionExpressionKind::Series) | ||||
| @@ -523,6 +527,54 @@ private: | |||||
| int column_offset_ = 0; | 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 ¢er) | |||||
| : 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 | class LogicEditorWidget::EmptySlotItem final : public QGraphicsItem | ||||
| { | { | ||||
| public: | public: | ||||
| @@ -790,6 +842,28 @@ RenderResult renderExpression( | |||||
| QPointF(top_left.x(), center.y()), | QPointF(top_left.x(), center.y()), | ||||
| QPointF(top_left.x() + width, 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) | if (expression.kind == ConditionExpressionKind::Node) | ||||
| { | { | ||||
| const QPointF center( | const QPointF center( | ||||
| @@ -917,30 +991,6 @@ RenderResult renderExpression( | |||||
| scene.addLine( | scene.addLine( | ||||
| QLineF(QPointF(left_join, branches[index].input.y()), branches[index].input), | QLineF(QPointF(left_join, branches[index].input.y()), branches[index].input), | ||||
| ladderPen(branch_active)); | 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)}; | 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; | 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> LogicEditorWidget::selectedBranchIds() const | ||||
| { | { | ||||
| std::vector<std::string> ids; | std::vector<std::string> ids; | ||||
| @@ -1355,6 +1418,15 @@ std::string LogicEditorWidget::selectedRungId() const | |||||
| } | } | ||||
| rung_id = cell->rungId(); | 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 = | else if (const VerticalConnectorItem *connector = | ||||
| dynamic_cast<const VerticalConnectorItem *>(item)) | dynamic_cast<const VerticalConnectorItem *>(item)) | ||||
| { | { | ||||
| @@ -1418,16 +1490,23 @@ LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) | |||||
| selectedEmptySlots(); | selectedEmptySlots(); | ||||
| const std::vector<std::pair<std::string, int>> selected_wire_cells = | const std::vector<std::pair<std::string, int>> selected_wire_cells = | ||||
| selectedWireCells(); | 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_expressions = selectedExpressionIds(); | ||||
| const std::vector<std::string> selected_wires = selectedWireIds(); | const std::vector<std::string> selected_wires = selectedWireIds(); | ||||
| const std::vector<std::string> selected_ids = selectedNodeIds(); | const std::vector<std::string> selected_ids = selectedNodeIds(); | ||||
| LogicEditorResult result; | LogicEditorResult result; | ||||
| if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U | if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U | ||||
| || selected_gap_cells.size() > 1U | |||||
| || (!selected_empty_slots.empty() | || (!selected_empty_slots.empty() | ||||
| && (!selected_wire_cells.empty() | && (!selected_wire_cells.empty() | ||||
| || !selected_gap_cells.empty() | |||||
| || !selected_expressions.empty() || !selected_ids.empty())) | || !selected_expressions.empty() || !selected_ids.empty())) | ||||
| || (!selected_wire_cells.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, | result = {false, LogicEditorError::InvalidOperation, | ||||
| "插入条件时只能选择一个网格或条件对象", {}}; | "插入条件时只能选择一个网格或条件对象", {}}; | ||||
| @@ -1450,6 +1529,15 @@ LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) | |||||
| selected_wire_cells.front().second, | selected_wire_cells.front().second, | ||||
| config); | 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) | else if (selected_expressions.size() > 1U || selected_wires.size() > 1U) | ||||
| { | { | ||||
| result = {false, LogicEditorError::InvalidOperation, | result = {false, LogicEditorError::InvalidOperation, | ||||
| @@ -1493,6 +1581,8 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co | |||||
| selectedEmptySlots(); | selectedEmptySlots(); | ||||
| const std::vector<std::pair<std::string, int>> selected_wire_cells = | const std::vector<std::pair<std::string, int>> selected_wire_cells = | ||||
| selectedWireCells(); | selectedWireCells(); | ||||
| const std::vector<std::pair<std::string, int>> selected_gap_cells = | |||||
| selectedGapCells(); | |||||
| const std::vector<std::string> selected_ids = selectedNodeIds(); | const std::vector<std::string> selected_ids = selectedNodeIds(); | ||||
| std::vector<std::string> condition_ids; | std::vector<std::string> condition_ids; | ||||
| for (const std::string &node_id : selected_ids) | for (const std::string &node_id : selected_ids) | ||||
| @@ -1504,7 +1594,12 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co | |||||
| } | } | ||||
| } | } | ||||
| LogicEditorResult result; | 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())) | && (!selected_ids.empty() || !selected_wire_cells.empty())) | ||||
| { | { | ||||
| result = {false, LogicEditorError::InvalidOperation, | result = {false, LogicEditorError::InvalidOperation, | ||||
| @@ -1546,12 +1641,23 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co | |||||
| LogicEditorResult LogicEditorWidget::addHorizontalWire() | LogicEditorResult LogicEditorWidget::addHorizontalWire() | ||||
| { | { | ||||
| const std::vector<std::string> selected_ids = selectedExpressionIds(); | const std::vector<std::string> selected_ids = selectedExpressionIds(); | ||||
| const std::vector<std::pair<std::string, int>> selected_gap_cells = | |||||
| selectedGapCells(); | |||||
| LogicEditorResult result; | 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, | 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) | else if (selected_ids.size() == 1U) | ||||
| { | { | ||||
| result = editor_service_.insertWireAfter( | result = editor_service_.insertWireAfter( | ||||
| @@ -1581,10 +1687,17 @@ LogicEditorResult LogicEditorWidget::addVerticalWire() | |||||
| selectedEmptySlots(); | selectedEmptySlots(); | ||||
| const std::vector<std::pair<std::string, int>> selected_wire_cells = | const std::vector<std::pair<std::string, int>> selected_wire_cells = | ||||
| selectedWireCells(); | 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_nodes = selectedNodeIds(); | ||||
| const std::vector<std::string> selected_ids = selectedExpressionIds(); | const std::vector<std::string> selected_ids = selectedExpressionIds(); | ||||
| LogicEditorResult result; | 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())) | && (!selected_wire_cells.empty() || !selected_nodes.empty())) | ||||
| { | { | ||||
| result = {false, LogicEditorError::InvalidOperation, | result = {false, LogicEditorError::InvalidOperation, | ||||
| @@ -1630,6 +1743,8 @@ LogicEditorResult LogicEditorWidget::addVerticalWire() | |||||
| LogicEditorResult LogicEditorWidget::deleteHorizontalWire() | LogicEditorResult LogicEditorWidget::deleteHorizontalWire() | ||||
| { | { | ||||
| const std::vector<std::pair<std::string, int>> wire_cells = | |||||
| selectedWireCells(); | |||||
| const std::vector<std::string> wire_ids = selectedWireIds(); | const std::vector<std::string> wire_ids = selectedWireIds(); | ||||
| const std::string rung_id = selectedRungId(); | const std::string rung_id = selectedRungId(); | ||||
| if (wire_ids.empty() || rung_id.empty()) | if (wire_ids.empty() || rung_id.empty()) | ||||
| @@ -1642,8 +1757,9 @@ LogicEditorResult LogicEditorWidget::deleteHorizontalWire() | |||||
| reportFailure(result); | reportFailure(result); | ||||
| return 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) | if (result.succeeded) | ||||
| { | { | ||||
| reloadLogic(); | reloadLogic(); | ||||
| @@ -1720,17 +1836,24 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes( | |||||
| selectedEmptySlots(); | selectedEmptySlots(); | ||||
| const std::vector<std::pair<std::string, int>> selected_wire_cells = | const std::vector<std::pair<std::string, int>> selected_wire_cells = | ||||
| selectedWireCells(); | 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_expressions = selectedExpressionIds(); | ||||
| const std::vector<std::string> selected_wires = selectedWireIds(); | const std::vector<std::string> selected_wires = selectedWireIds(); | ||||
| const std::vector<std::string> selected_ids = selectedNodeIds(); | const std::vector<std::string> selected_ids = selectedNodeIds(); | ||||
| LogicEditorResult result; | LogicEditorResult result; | ||||
| LogicConditionPasteTarget target; | LogicConditionPasteTarget target; | ||||
| if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U | if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U | ||||
| || selected_gap_cells.size() > 1U | |||||
| || (!selected_empty_slots.empty() | || (!selected_empty_slots.empty() | ||||
| && (!selected_wire_cells.empty() | && (!selected_wire_cells.empty() | ||||
| || !selected_gap_cells.empty() | |||||
| || !selected_expressions.empty() || !selected_ids.empty())) | || !selected_expressions.empty() || !selected_ids.empty())) | ||||
| || (!selected_wire_cells.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_expressions.size() > 1U || selected_wires.size() > 1U | ||||
| || selected_ids.size() > 1U) | || selected_ids.size() > 1U) | ||||
| { | { | ||||
| @@ -1754,6 +1877,12 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes( | |||||
| target.expressionId = selected_wire_cells.front().first; | target.expressionId = selected_wire_cells.front().first; | ||||
| target.column = selected_wire_cells.front().second; | 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) | else if (selected_wires.size() == 1U) | ||||
| { | { | ||||
| target.kind = LogicConditionPasteTargetKind::ReplaceWire; | target.kind = LogicConditionPasteTargetKind::ReplaceWire; | ||||
| @@ -1787,12 +1916,20 @@ LogicEditorResult LogicEditorWidget::pasteConditionNodes( | |||||
| LogicEditorResult LogicEditorWidget::deleteSelected() | LogicEditorResult LogicEditorWidget::deleteSelected() | ||||
| { | { | ||||
| const std::vector<std::string> expression_ids = selectedExpressionIds(); | |||||
| const std::vector<std::string> wire_ids = selectedWireIds(); | 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> branch_ids = selectedBranchIds(); | ||||
| const std::vector<std::string> node_ids = selectedNodeIds(); | const std::vector<std::string> node_ids = selectedNodeIds(); | ||||
| LogicEditorResult result; | LogicEditorResult result; | ||||
| if (!branch_ids.empty()) | |||||
| if (!gap_cells.empty()) | |||||
| { | |||||
| result = {false, LogicEditorError::InvalidOperation, | |||||
| "所选网格已经是断路,不需要重复删除", {}}; | |||||
| } | |||||
| else if (!branch_ids.empty()) | |||||
| { | { | ||||
| result = editor_service_.removeExpressions( | result = editor_service_.removeExpressions( | ||||
| logic_id_, selectedRungId(), branch_ids); | logic_id_, selectedRungId(), branch_ids); | ||||
| @@ -1814,8 +1951,11 @@ LogicEditorResult LogicEditorWidget::deleteSelected() | |||||
| } | } | ||||
| else | 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()) | else if (!node_ids.empty()) | ||||
| @@ -31,6 +31,7 @@ public: | |||||
| class NodeItem; | class NodeItem; | ||||
| class WireItem; | class WireItem; | ||||
| class WireCellItem; | class WireCellItem; | ||||
| class GapCellItem; | |||||
| class VerticalConnectorItem; | class VerticalConnectorItem; | ||||
| class RungItem; | class RungItem; | ||||
| class EmptySlotItem; | class EmptySlotItem; | ||||
| @@ -116,6 +117,8 @@ private: | |||||
| std::vector<std::pair<std::string, int>> selectedEmptySlots() const; | 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>> selectedWireCells() const; | ||||
| /** 收集当前选中的显式断路网格位置 */ | |||||
| std::vector<std::pair<std::string, int>> selectedGapCells() const; | |||||
| /** 判断当前是否选中了网络内的图元 */ | /** 判断当前是否选中了网络内的图元 */ | ||||
| bool hasSelectedRungItem() const; | bool hasSelectedRungItem() const; | ||||
| @@ -9,6 +9,7 @@ | |||||
| #include "domain/virtual_register_repository.h" | #include "domain/virtual_register_repository.h" | ||||
| #include "support/test_support.h" | #include "support/test_support.h" | ||||
| #include <algorithm> | |||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <exception> | #include <exception> | ||||
| #include <functional> | #include <functional> | ||||
| @@ -21,6 +22,30 @@ namespace { | |||||
| using TestSupport::require; | 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() | void testRegisterAddressBoundaries() | ||||
| { | { | ||||
| // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 | // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 | ||||
| @@ -268,7 +293,13 @@ Project makeValidProject() | |||||
| LadderRung rung; | LadderRung rung; | ||||
| rung.id = "rung-1"; | rung.id = "rung-1"; | ||||
| rung.name = "Network 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; | rung.output = coil; | ||||
| logic.rungs.push_back(rung); | logic.rungs.push_back(rung); | ||||
| @@ -463,17 +494,30 @@ void testQuantityBoundaries() | |||||
| true}); | true}); | ||||
| } | } | ||||
| const int address = (*next_address)++; | 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; | ConditionExpression expression; | ||||
| expression.id = "depth-expression-" + std::to_string(address); | expression.id = "depth-expression-" + std::to_string(address); | ||||
| expression.kind = depth % 2 == 0 | expression.kind = depth % 2 == 0 | ||||
| ? ConditionExpressionKind::Parallel | ? ConditionExpressionKind::Parallel | ||||
| : ConditionExpressionKind::Series; | : 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; | return expression; | ||||
| }; | }; | ||||
| int next_address = 1; | int next_address = 1; | ||||
| @@ -625,7 +669,8 @@ void testLadderLogicBoundaries() | |||||
| root.kind = ConditionExpressionKind::Series; | root.kind = ConditionExpressionKind::Series; | ||||
| root.children = { | root.children = { | ||||
| ConditionExpression::fromNode(stop), | ConditionExpression::fromNode(stop), | ||||
| start_parallel}; | |||||
| start_parallel, | |||||
| ConditionExpression::fromWire("hold-output-wire", 8)}; | |||||
| LadderRung rung; | LadderRung rung; | ||||
| rung.id = "rung-1"; | rung.id = "rung-1"; | ||||
| rung.name = "Self hold"; | rung.name = "Self hold"; | ||||
| @@ -644,6 +689,10 @@ void testLadderLogicBoundaries() | |||||
| "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); | "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); | ||||
| require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected"); | 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; | ConditionExpression maximum_columns; | ||||
| maximum_columns.id = "maximum-columns"; | maximum_columns.id = "maximum-columns"; | ||||
| maximum_columns.kind = ConditionExpressionKind::Series; | maximum_columns.kind = ConditionExpressionKind::Series; | ||||
| @@ -696,9 +745,14 @@ void testLadderLogicBoundaries() | |||||
| require(empty_rung.validate(), "an empty editing network must be valid"); | require(empty_rung.validate(), "an empty editing network must be valid"); | ||||
| empty_rung.output = coil; | 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().output = coil; | ||||
| logic.rungs.front().condition = root; | logic.rungs.front().condition = root; | ||||
| @@ -711,8 +765,17 @@ void testLadderLogicBoundaries() | |||||
| nested_parallel.children = { | nested_parallel.children = { | ||||
| ConditionExpression::fromNode(start), | ConditionExpression::fromNode(start), | ||||
| root}; | 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(), | require(nested_parallel.validate(), | ||||
| "nested series and parallel expressions must be valid"); | |||||
| "parallel branches padded with explicit wires must be valid"); | |||||
| } | } | ||||
| void testModelsValidateBindingsAndIdentifiers() | void testModelsValidateBindingsAndIdentifiers() | ||||
| @@ -36,6 +36,10 @@ int conditionColumns(const ConditionExpression &expression) | |||||
| { | { | ||||
| return expression.wire->columnSpan; | return expression.wire->columnSpan; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return expression.gap->columnSpan; | |||||
| } | |||||
| int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | ||||
| for (const ConditionExpression &child : expression.children) | for (const ConditionExpression &child : expression.children) | ||||
| { | { | ||||
| @@ -56,6 +60,10 @@ int wireColumns(const ConditionExpression &expression) | |||||
| { | { | ||||
| return expression.wire->columnSpan; | return expression.wire->columnSpan; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return 0; | |||||
| } | |||||
| int columns = 0; | int columns = 0; | ||||
| for (const ConditionExpression &child : expression.children) | for (const ConditionExpression &child : expression.children) | ||||
| { | { | ||||
| @@ -64,6 +72,25 @@ int wireColumns(const ConditionExpression &expression) | |||||
| return columns; | 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) | int conditionNodes(const ConditionExpression &expression) | ||||
| { | { | ||||
| if (expression.kind == ConditionExpressionKind::Node) | if (expression.kind == ConditionExpressionKind::Node) | ||||
| @@ -74,6 +101,10 @@ int conditionNodes(const ConditionExpression &expression) | |||||
| { | { | ||||
| return 0; | return 0; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return 0; | |||||
| } | |||||
| int count = 0; | int count = 0; | ||||
| for (const ConditionExpression &child : expression.children) | for (const ConditionExpression &child : expression.children) | ||||
| { | { | ||||
| @@ -82,6 +113,23 @@ int conditionNodes(const ConditionExpression &expression) | |||||
| return count; | 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() | void testEmptyLogicCreatesNetworksOnFirstEdit() | ||||
| { | { | ||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| @@ -121,9 +169,13 @@ void testEmptyLogicCreatesNetworksOnFirstEdit() | |||||
| CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}); | CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}); | ||||
| require(first_output.succeeded | require(first_output.succeeded | ||||
| && service.findLogic(logic_id)->rungs.size() == 1U | && 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(), | && 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() | void testAppendingWireMovesToNextNetworkAfterTenColumns() | ||||
| @@ -199,15 +251,20 @@ void testStructuredEditingAndNormalization() | |||||
| rung = service.findRung(logic_id, rung_id); | rung = service.findRung(logic_id, rung_id); | ||||
| require(rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel | require(rung->condition->children.at(1).kind == ConditionExpressionKind::Parallel | ||||
| && rung->condition->children.at(1).children.at(1).kind | && 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, | require(service.removeNode(logic_id, parallel.id).succeeded, | ||||
| "parallel leaf deletion must succeed"); | "parallel leaf deletion must succeed"); | ||||
| rung = service.findRung(logic_id, rung_id); | rung = service.findRung(logic_id, rung_id); | ||||
| require(rung->condition->kind == ConditionExpressionKind::Series | require(rung->condition->kind == ConditionExpressionKind::Series | ||||
| && rung->condition->children.size() == 2U, | && 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( | require(service.setOutput( | ||||
| logic_id, | logic_id, | ||||
| @@ -268,10 +325,21 @@ void testParallelBranchGridInsertion() | |||||
| "parallel grid insertion setup must create a short lower branch"); | "parallel grid insertion setup must create a short lower branch"); | ||||
| service.clearHistory(); | 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, | 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); | 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::Parallel, | && rung->condition->kind == ConditionExpressionKind::Parallel, | ||||
| @@ -288,9 +356,12 @@ void testParallelBranchGridInsertion() | |||||
| require(service.undo().succeeded, | require(service.undo().succeeded, | ||||
| "parallel branch grid insertion must be one undoable edit"); | "parallel branch grid insertion must be one undoable edit"); | ||||
| rung = service.findRung(logic_id, rung_id); | 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(); | service.clearHistory(); | ||||
| const bool modified_before = project_service.isModified(); | const bool modified_before = project_service.isModified(); | ||||
| @@ -334,8 +405,8 @@ void testStructuredWireEditing() | |||||
| "a selected wire must be replaceable by a configured node type"); | "a selected wire must be replaceable by a configured node type"); | ||||
| const LogicEditorResult extension = service.insertWireAfter( | const LogicEditorResult extension = service.insertWireAfter( | ||||
| logic_id, rung_id, replacement.id); | 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); | rung = service.findRung(logic_id, rung_id); | ||||
| const ConditionExpression *extended_branch = service.findExpression( | const ConditionExpression *extended_branch = service.findExpression( | ||||
| @@ -354,9 +425,12 @@ void testStructuredWireEditing() | |||||
| logic_id, rung_id, {extended_branch_id}).succeeded, | logic_id, rung_id, {extended_branch_id}).succeeded, | ||||
| "deleting a selected vertical connection branch must succeed atomically"); | "deleting a selected vertical connection branch must succeed atomically"); | ||||
| rung = service.findRung(logic_id, rung_id); | 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 | require(service.undo().succeeded | ||||
| && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr, | && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr, | ||||
| "wire branch deletion must participate in ladder undo history"); | "wire branch deletion must participate in ladder undo history"); | ||||
| @@ -487,8 +561,11 @@ void testBatchDeleteAllNodesInParallelBranch() | |||||
| && rung->condition->children.at(0).kind | && rung->condition->children.at(0).kind | ||||
| == ConditionExpressionKind::Node | == ConditionExpressionKind::Node | ||||
| && rung->condition->children.at(1).kind | && 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 | require(service.findNode(logic_id, second.id) == nullptr | ||||
| && service.findNode(logic_id, third.id) == nullptr | && service.findNode(logic_id, third.id) == nullptr | ||||
| && service.findNode(logic_id, fourth.id) == nullptr, | && service.findNode(logic_id, fourth.id) == nullptr, | ||||
| @@ -567,9 +644,131 @@ void testUnconditionalOutputEditing() | |||||
| .succeeded, | .succeeded, | ||||
| "the editor must allow a coil before any condition is added"); | "the editor must allow a coil before any condition is added"); | ||||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | 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(), | && 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() | void testColumnTargetedConditionInsertion() | ||||
| @@ -627,12 +826,17 @@ void testColumnTargetedConditionInsertion() | |||||
| true) | true) | ||||
| .succeeded, | .succeeded, | ||||
| "an output-only network must be configurable before adding a condition"); | "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); | const LadderRung *output_rung = service.findRung(logic_id, second_rung.id); | ||||
| require(output_rung != nullptr && output_rung->condition.has_value() | 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->output.has_value() | ||||
| && output_rung->validate(), | && output_rung->validate(), | ||||
| "condition insertion must keep the independent output slot intact"); | "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}, | {branch_source.id, branch_source_two.id, branch_source_three.id}, | ||||
| contact(4)); | contact(4)); | ||||
| require(branch.succeeded, "branch paste setup must create a parallel branch"); | 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; | 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( | require(service.pasteConditionNodes( | ||||
| logic_id, branch_rung, {source}, branch_target).succeeded, | 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 std::string after_node_rung = makeEmptyRung(service, logic_id); | ||||
| const LogicEditorResult after_source = service.appendCondition( | const LogicEditorResult after_source = service.appendCondition( | ||||
| @@ -1248,6 +1457,7 @@ int main() | |||||
| testBatchDeleteAllNodesInParallelBranch(); | testBatchDeleteAllNodesInParallelBranch(); | ||||
| testConditionColumnLimit(); | testConditionColumnLimit(); | ||||
| testUnconditionalOutputEditing(); | testUnconditionalOutputEditing(); | ||||
| testExplicitGapEditing(); | |||||
| testColumnTargetedConditionInsertion(); | testColumnTargetedConditionInsertion(); | ||||
| testWireColumnReplacement(); | testWireColumnReplacement(); | ||||
| testSequentialConditionInsertionConsumesFollowingWire(); | testSequentialConditionInsertionConsumesFollowingWire(); | ||||
| @@ -1,4 +1,5 @@ | |||||
| #include "domain/hmi_model.h" | #include "domain/hmi_model.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include "domain/register_repository.h" | #include "domain/register_repository.h" | ||||
| #include "domain/virtual_register_repository.h" | #include "domain/virtual_register_repository.h" | ||||
| #include "services/hmi_runtime_service.h" | #include "services/hmi_runtime_service.h" | ||||
| @@ -132,6 +133,26 @@ LadderRung rung(const std::string &id, | |||||
| series.children = std::move(series_children); | series.children = std::move(series_children); | ||||
| result.condition = std::move(series); | 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; | result.output = output; | ||||
| return result; | return result; | ||||
| } | } | ||||
| @@ -179,12 +200,22 @@ void testNestedSeriesParallelExpression() | |||||
| nested_series.children = { | nested_series.children = { | ||||
| ConditionExpression::fromNode(contact("b", 1)), | ConditionExpression::fromNode(contact("b", 1)), | ||||
| ConditionExpression::fromNode(contact("c", 2))}; | 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; | ConditionExpression root; | ||||
| root.id = "root-parallel"; | |||||
| root.kind = ConditionExpressionKind::Parallel; | |||||
| root.id = "nested-output-series"; | |||||
| root.kind = ConditionExpressionKind::Series; | |||||
| root.children = { | root.children = { | ||||
| ConditionExpression::fromNode(contact("a", 0)), | |||||
| nested_series}; | |||||
| std::move(parallel), | |||||
| ConditionExpression::fromWire("nested-output-wire", 8)}; | |||||
| LadderRung nested_rung; | LadderRung nested_rung; | ||||
| nested_rung.id = "nested-rung"; | nested_rung.id = "nested-rung"; | ||||
| nested_rung.name = "nested-rung"; | nested_rung.name = "nested-rung"; | ||||
| @@ -220,16 +251,18 @@ void testUnconditionalCoil() | |||||
| LadderRung unconditional; | LadderRung unconditional; | ||||
| unconditional.id = "unconditional-rung"; | unconditional.id = "unconditional-rung"; | ||||
| unconditional.name = "unconditional-rung"; | unconditional.name = "unconditional-rung"; | ||||
| unconditional.condition = ConditionExpression::fromWire( | |||||
| "unconditional-wire", ProjectLimits::kMaximumConditionColumns); | |||||
| unconditional.output = coil("unconditional-coil", 10); | unconditional.output = coil("unconditional-coil", 10); | ||||
| const ControlLogic program = logic({unconditional}); | const ControlLogic program = logic({unconditional}); | ||||
| LogicTraceSnapshot trace; | LogicTraceSnapshot trace; | ||||
| require(executor.validate({program}).succeeded, | 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, | 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), | 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") | require(trace.rungValues.at("unconditional-rung") | ||||
| && trace.nodePowerValues.at("unconditional-coil"), | && trace.nodePowerValues.at("unconditional-coil"), | ||||
| "an unconditional rung must report energized power flow"); | "an unconditional rung must report energized power flow"); | ||||
| @@ -245,7 +278,8 @@ void testWirePassThroughAndPowerTrace() | |||||
| root.children = { | root.children = { | ||||
| ConditionExpression::fromNode(contact("wire-input", 0)), | ConditionExpression::fromNode(contact("wire-input", 0)), | ||||
| ConditionExpression::fromWire("wire-segment", 2), | 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; | LadderRung wired_rung; | ||||
| wired_rung.id = "wired-rung"; | wired_rung.id = "wired-rung"; | ||||
| wired_rung.name = "wired-rung"; | wired_rung.name = "wired-rung"; | ||||
| @@ -3,9 +3,11 @@ | |||||
| #include "domain/virtual_register_repository.h" | #include "domain/virtual_register_repository.h" | ||||
| #include "services/software_logic_executor.h" | #include "services/software_logic_executor.h" | ||||
| #include <QDebug> | |||||
| #include <QTest> | #include <QTest> | ||||
| #include <string> | #include <string> | ||||
| #include <utility> | |||||
| #include <vector> | #include <vector> | ||||
| namespace | namespace | ||||
| @@ -34,8 +36,15 @@ ControlLogic makeLogic(int index) | |||||
| LadderRung rung; | LadderRung rung; | ||||
| rung.id = "rung-" + std::to_string(index); | rung.id = "rung-" + std::to_string(index); | ||||
| rung.name = rung.id; | 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); | rung.output = coil("coil-" + std::to_string(index), index + 100); | ||||
| return {"logic-" + std::to_string(index), | return {"logic-" + std::to_string(index), | ||||
| "logic-" + std::to_string(index), | "logic-" + std::to_string(index), | ||||
| @@ -48,6 +57,16 @@ class PerformanceTests final : public QObject | |||||
| Q_OBJECT | Q_OBJECT | ||||
| private slots: | private slots: | ||||
| void initTestCase() | |||||
| { | |||||
| qInfo().noquote() | |||||
| << QStringLiteral("性能测试开始:下面两项 RESULT 的耗时均为平均每次执行时间"); | |||||
| qInfo().noquote() | |||||
| << QStringLiteral("测试项目 1:100 个控制逻辑的软件扫描"); | |||||
| qInfo().noquote() | |||||
| << QStringLiteral("测试项目 2:M0 到 M4000 共 4001 个虚拟 M 地址的完整写入和读取"); | |||||
| } | |||||
| void softwareExecutor_scansRepresentativeProject() | void softwareExecutor_scansRepresentativeProject() | ||||
| { | { | ||||
| std::vector<ControlLogic> logics; | std::vector<ControlLogic> logics; | ||||
| @@ -89,6 +108,12 @@ private slots: | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| void cleanupTestCase() | |||||
| { | |||||
| qInfo().noquote() | |||||
| << QStringLiteral("性能测试结束:请结合上方两项 RESULT 和最后的 Totals 判断结果"); | |||||
| } | |||||
| }; | }; | ||||
| } // namespace | } // namespace | ||||
| @@ -150,7 +150,8 @@ Project makeExampleProject() | |||||
| series.children = { | series.children = { | ||||
| std::move(parallel), | std::move(parallel), | ||||
| ConditionExpression::fromWire("start-wire", 2), | ConditionExpression::fromWire("start-wire", 2), | ||||
| ConditionExpression::fromNode(compare)}; | |||||
| ConditionExpression::fromNode(compare), | |||||
| ConditionExpression::fromWire("start-output-wire", 6)}; | |||||
| rung.condition = std::move(series); | rung.condition = std::move(series); | ||||
| rung.output = coil; | rung.output = coil; | ||||
| logic.rungs.push_back(rung); | logic.rungs.push_back(rung); | ||||
| @@ -167,7 +168,13 @@ Project makeExampleProject() | |||||
| edge_rung.id = "edge-rung"; | edge_rung.id = "edge-rung"; | ||||
| edge_rung.name = "Edge network"; | edge_rung.name = "Edge network"; | ||||
| edge_rung.comment = "上升沿输出"; | 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; | edge_rung.output = edge_output; | ||||
| logic.rungs.push_back(edge_rung); | logic.rungs.push_back(edge_rung); | ||||
| @@ -185,7 +192,13 @@ Project makeExampleProject() | |||||
| LadderRung data_rung; | LadderRung data_rung; | ||||
| data_rung.id = rung_id; | data_rung.id = rung_id; | ||||
| data_rung.name = 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); | data_rung.output = std::move(output); | ||||
| logic.rungs.push_back(std::move(data_rung)); | logic.rungs.push_back(std::move(data_rung)); | ||||
| }; | }; | ||||
| @@ -328,11 +341,13 @@ void testUnconditionalOutputRoundTrip() | |||||
| coil.id = "unconditional-coil"; | coil.id = "unconditional-coil"; | ||||
| coil.config = CoilNodeConfig{ | coil.config = CoilNodeConfig{ | ||||
| RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}; | RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}; | ||||
| rung.condition = ConditionExpression::fromWire( | |||||
| "unconditional-wire", ProjectLimits::kMaximumConditionColumns); | |||||
| rung.output = coil; | rung.output = coil; | ||||
| service.editProject().controlLogics.push_back( | service.editProject().controlLogics.push_back( | ||||
| ControlLogic{"logic-1", "Logic 1", {rung}, true}); | ControlLogic{"logic-1", "Logic 1", {rung}, true}); | ||||
| require(service.project().validateForRunning(), | 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"); | const QString path = directory.filePath("unconditional-output.json"); | ||||
| require(service.saveAs(path.toStdString()).succeeded, | require(service.saveAs(path.toStdString()).succeeded, | ||||
| @@ -340,9 +355,71 @@ void testUnconditionalOutputRoundTrip() | |||||
| require(service.load(path.toStdString()).succeeded, | require(service.load(path.toStdString()).succeeded, | ||||
| "an output-only project must load successfully"); | "an output-only project must load successfully"); | ||||
| const LadderRung &loaded = service.project().controlLogics.front().rungs.front(); | 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(), | && 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() | void testExampleProjectRoundTrip() | ||||
| @@ -490,8 +567,9 @@ void testExampleProjectRoundTrip() | |||||
| == RegisterAddress{RegisterArea::M, 6}, | == RegisterAddress{RegisterArea::M, 6}, | ||||
| "edge network output must survive round trip"); | "edge network output must survive round trip"); | ||||
| require(std::holds_alternative<EdgeContactNodeConfig>( | 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, | == EdgeMode::Rising, | ||||
| "rising edge configuration must survive round trip"); | "rising edge configuration must survive round trip"); | ||||
| require(std::get<MoveNodeConfig>( | require(std::get<MoveNodeConfig>( | ||||
| @@ -935,6 +1013,7 @@ int main() | |||||
| // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | ||||
| testEmptyProjectRoundTrip(); | testEmptyProjectRoundTrip(); | ||||
| testUnconditionalOutputRoundTrip(); | testUnconditionalOutputRoundTrip(); | ||||
| testGapRoundTrip(); | |||||
| testExampleProjectRoundTrip(); | testExampleProjectRoundTrip(); | ||||
| testRegisterCommentService(); | testRegisterCommentService(); | ||||
| testInvalidFiles(); | testInvalidFiles(); | ||||
| @@ -89,6 +89,19 @@ private: | |||||
| using TestSupport::require; | 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() | void testModeTransitions() | ||||
| { | { | ||||
| // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 | // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 | ||||
| @@ -126,7 +139,8 @@ void testModeTransitions() | |||||
| LadderRung edge_rung; | LadderRung edge_rung; | ||||
| edge_rung.id = "poll-edge-rung"; | edge_rung.id = "poll-edge-rung"; | ||||
| edge_rung.name = "Poll edge"; | 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; | edge_rung.output = edge_coil; | ||||
| logic.rungs.push_back(edge_rung); | logic.rungs.push_back(edge_rung); | ||||
| LogicNode comparison; | LogicNode comparison; | ||||
| @@ -140,7 +154,8 @@ void testModeTransitions() | |||||
| LadderRung comparison_rung; | LadderRung comparison_rung; | ||||
| comparison_rung.id = "poll-comparison-rung"; | comparison_rung.id = "poll-comparison-rung"; | ||||
| comparison_rung.name = "Poll comparison"; | 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; | comparison_rung.output = comparison_coil; | ||||
| logic.rungs.push_back(comparison_rung); | logic.rungs.push_back(comparison_rung); | ||||
| LogicNode move_input; | LogicNode move_input; | ||||
| @@ -158,7 +173,8 @@ void testModeTransitions() | |||||
| LadderRung move_rung; | LadderRung move_rung; | ||||
| move_rung.id = "poll-move-rung"; | move_rung.id = "poll-move-rung"; | ||||
| move_rung.name = "Poll MOVE"; | 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; | move_rung.output = move_output; | ||||
| logic.rungs.push_back(move_rung); | logic.rungs.push_back(move_rung); | ||||
| LogicNode add_input; | LogicNode add_input; | ||||
| @@ -181,7 +197,8 @@ void testModeTransitions() | |||||
| LadderRung add_rung; | LadderRung add_rung; | ||||
| add_rung.id = "poll-add-rung"; | add_rung.id = "poll-add-rung"; | ||||
| add_rung.name = "Poll ADD"; | 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; | add_rung.output = add_output; | ||||
| logic.rungs.push_back(add_rung); | logic.rungs.push_back(add_rung); | ||||
| project.controlLogics.push_back(logic); | project.controlLogics.push_back(logic); | ||||