Procházet zdrojové kódy

feat: 完善梯形图网格编辑与运行界面

main
suyu před 4 týdny
rodič
revize
88cacc0f57
20 změnil soubory, kde provedl 1035 přidání a 143 odebrání
  1. +5
    -2
      app/src/domain/hmi_model.cpp
  2. +207
    -23
      app/src/services/logic_editor_service.cpp
  3. +7
    -1
      app/src/services/logic_editor_service.h
  4. +19
    -2
      app/src/ui/alarm_configuration_dialog.h
  5. +23
    -2
      app/src/ui/free_monitor_widget.h
  6. +13
    -8
      app/src/ui/hmi_editor_widget.h
  7. +87
    -39
      app/src/ui/logic_editor_widget.cpp
  8. +52
    -4
      app/src/ui/logic_editor_widget.h
  9. +18
    -2
      app/src/ui/logic_instruction_dialog.h
  10. +76
    -22
      app/src/ui/main_window.h
  11. +11
    -2
      app/src/ui/plc_connection_dialog.h
  12. +56
    -2
      app/src/ui/project_workspace_controller.h
  13. +43
    -2
      app/src/ui/property_panel_controller.h
  14. +17
    -1
      app/src/ui/register_comment_dialog.h
  15. +32
    -3
      app/src/ui/runtime_monitor_widget.h
  16. +18
    -4
      app/src/ui/runtime_monitor_window.h
  17. +43
    -3
      app/src/ui/runtime_panel_controller.h
  18. +14
    -2
      app/src/ui/toolbar_icon_factory.h
  19. +143
    -11
      app/tests/logic_editor_service_tests.cpp
  20. +151
    -8
      app/tests/main_window_tests.cpp

+ 5
- 2
app/src/domain/hmi_model.cpp Zobrazit soubor

@@ -271,11 +271,14 @@ bool HmiPage::validate(std::string *error) const
setError(error, "HMI 页面名称不能超过 4096 个 UTF-8 字节");
return false;
}
if (width <= 0 || height <= 0
if (width < ProjectLimits::kMinimumHmiPageWidth
|| height < ProjectLimits::kMinimumHmiPageHeight
|| width > ProjectLimits::kMaximumHmiPageWidth
|| height > ProjectLimits::kMaximumHmiPageHeight)
{
setError(error, "HMI 页面宽度和高度必须在 1~8192 范围内");
setError(
error,
"HMI 页面宽度必须在 320~1600、高度必须在 200~800 范围内");
return false;
}
if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage)


+ 207
- 23
app/src/services/logic_editor_service.cpp Zobrazit soubor

@@ -80,6 +80,24 @@ ConditionExpression *findParentExpression(
return nullptr;
}

const ConditionExpression *findParentExpression(
const ConditionExpression &expression, const std::string &child_id)
{
for (const ConditionExpression &child : expression.children)
{
if (child.id == child_id)
{
return &expression;
}
if (const ConditionExpression *parent = findParentExpression(
child, child_id))
{
return parent;
}
}
return nullptr;
}

using NodeIdSet = std::unordered_set<std::string>;

NodeIdSet conditionLeafIds(const ConditionExpression &expression)
@@ -652,7 +670,6 @@ LogicEditorResult LogicEditorService::ensureDefaultLogic()
ControlLogic logic;
logic.id = "logic-1";
logic.name = "控制逻辑 1";
logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt, std::nullopt});
Project &project = project_service_.editProject();
project.controlLogics.push_back(std::move(logic));
return {true, LogicEditorError::None, {}, project.controlLogics.back().id};
@@ -684,7 +701,6 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name)
ControlLogic logic;
logic.id = makeUniqueLogicId(current);
logic.name = name;
logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt, std::nullopt});
HistoryState before = captureState();
Project &project = project_service_.editProject();
project.controlLogics.push_back(std::move(logic));
@@ -850,10 +866,6 @@ LogicEditorResult LogicEditorService::removeRung(
{
return failure(LogicEditorError::RungNotFound, "未找到梯形图网络");
}
if (logic->rungs.size() == 1U)
{
return failure(LogicEditorError::InvalidOperation, "控制逻辑至少需要保留一个网络");
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
auto target = std::find_if(
@@ -908,11 +920,19 @@ LogicEditorResult LogicEditorService::appendCondition(
const LogicNodeConfig &config)
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr || findRung(logic_id, rung_id) == nullptr)
if (logic == nullptr)
{
return failure(
logic == nullptr ? LogicEditorError::LogicNotFound : LogicEditorError::RungNotFound,
logic == nullptr ? "未找到控制逻辑" : "未找到梯形图网络");
return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑");
}
const bool create_rung = rung_id.empty();
if (!create_rung && findRung(logic_id, rung_id) == nullptr)
{
return failure(LogicEditorError::RungNotFound, "未找到梯形图网络");
}
if (create_rung
&& logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic)
{
return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络");
}
if (!isConditionConfig(config))
{
@@ -920,11 +940,25 @@ LogicEditorResult LogicEditorService::appendCondition(
}
// 先生成稳定节点 ID,再把新节点接到已有表达式的串联末尾
const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config));
const std::string target_rung_id = create_rung
? makeUniqueRungId(*logic) : rung_id;
ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config));
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
if (create_rung)
{
editable_logic->rungs.push_back({
target_rung_id,
"网络 " + std::to_string(editable_logic->rungs.size() + 1U),
{},
std::nullopt,
std::nullopt});
}
LadderRung *rung = findEditableRung(project, logic_id, target_rung_id);
if (!rung->condition.has_value())
{
rung->condition = std::move(leaf);
@@ -1048,31 +1082,152 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn(
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::appendWire(
LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn(
const std::string &logic_id,
const std::string &rung_id,
int column_span)
const std::string &branch_expression_id,
int column,
const LogicNodeConfig &config)
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr || findRung(logic_id, rung_id) == nullptr)
const LadderRung *existing_rung = findRung(logic_id, rung_id);
if (logic == nullptr || existing_rung == nullptr
|| !existing_rung->condition.has_value())
{
return failure(
logic == nullptr ? LogicEditorError::LogicNotFound
: LogicEditorError::RungNotFound,
logic == nullptr ? "未找到控制逻辑" : "未找到梯形图网络");
}
if (!isConditionConfig(config))
{
return failure(LogicEditorError::InvalidNode, "并联空网格只能插入条件节点");
}

const ConditionExpression *branch = findExpression(
logic_id, rung_id, branch_expression_id);
const ConditionExpression *branch_parent = findParentExpression(
*existing_rung->condition, branch_expression_id);
if (branch == nullptr || branch_parent == nullptr
|| branch_parent->kind != ConditionExpressionKind::Parallel)
{
return failure(
LogicEditorError::ExpressionNotFound,
"未找到并联分支空网格");
}

const int branch_columns = expressionColumns(*branch);
const int parallel_columns = expressionColumns(*branch_parent);
if (column < branch_columns || column >= parallel_columns)
{
return failure(
LogicEditorError::InvalidOperation,
"所选位置不是并联分支中的可用空网格");
}

const int leading_wire_columns = column - branch_columns;
const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config));
const std::string wire_id = leading_wire_columns > 0
? makeUniqueWireId(*logic) : std::string{};
const std::string series_id = makeUniqueExpressionId(*logic);
ConditionExpression leaf = ConditionExpression::fromNode(
makeNode(node_id, config));
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
LadderRung *rung = findEditableRung(
project_service_.editProject(), logic_id, rung_id);
ConditionExpression *editable_branch = findConditionExpression(
*rung->condition, branch_expression_id);

if (editable_branch->kind == ConditionExpressionKind::Series)
{
if (leading_wire_columns > 0)
{
editable_branch->children.push_back(ConditionExpression::fromWire(
wire_id, leading_wire_columns));
}
editable_branch->children.push_back(std::move(leaf));
}
else
{
ConditionExpression series;
series.id = series_id;
series.kind = ConditionExpressionKind::Series;
series.children.push_back(std::move(*editable_branch));
if (leading_wire_columns > 0)
{
series.children.push_back(ConditionExpression::fromWire(
wire_id, leading_wire_columns));
}
series.children.push_back(std::move(leaf));
*editable_branch = std::move(series);
}

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, {}, node_id};
}

LogicEditorResult LogicEditorService::appendWire(
const std::string &logic_id,
const std::string &rung_id,
int column_span)
{
const ControlLogic *logic = findLogic(logic_id);
if (logic == nullptr)
{
return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑");
}
WireSegment wire{column_span};
std::string error;
if (!wire.validate(&error))
{
return failure(LogicEditorError::InvalidOperation, error);
}
const LadderRung *existing_rung = rung_id.empty()
? nullptr : findRung(logic_id, rung_id);
if (!rung_id.empty() && existing_rung == nullptr)
{
return failure(LogicEditorError::RungNotFound, "未找到梯形图网络");
}
bool create_rung = rung_id.empty();
if (!create_rung && column_span == 1 && existing_rung->condition.has_value()
&& expressionColumns(*existing_rung->condition)
>= ProjectLimits::kMaximumConditionColumns)
{
// 无选中目标的连续追加达到十列后,原子切换到下一个网络
create_rung = true;
}
if (create_rung
&& logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic)
{
return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络");
}
const std::string target_rung_id = create_rung
? makeUniqueRungId(*logic) : rung_id;
const std::string wire_id = makeUniqueWireId(*logic);
ConditionExpression leaf = ConditionExpression::fromWire(wire_id, column_span);
HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
LadderRung *rung = findEditableRung(
project_service_.editProject(), logic_id, rung_id);
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; });
if (create_rung)
{
editable_logic->rungs.push_back({
target_rung_id,
"网络 " + std::to_string(editable_logic->rungs.size() + 1U),
{},
std::nullopt,
std::nullopt});
}
LadderRung *rung = findEditableRung(project, logic_id, target_rung_id);
if (!rung->condition.has_value())
{
rung->condition = std::move(leaf);
@@ -1488,14 +1643,31 @@ LogicEditorResult LogicEditorService::setOutput(
bool configured)
{
const ControlLogic *logic = findLogic(logic_id);
const LadderRung *existing_rung = findRung(logic_id, rung_id);
if (logic == nullptr || existing_rung == nullptr || !isOutputConfig(config))
const LadderRung *existing_rung = rung_id.empty()
? nullptr : findRung(logic_id, rung_id);
if (logic == nullptr || (!rung_id.empty() && existing_rung == nullptr)
|| !isOutputConfig(config))
{
return failure(
LogicEditorError::InvalidNode,
"梯形图输出必须使用有效的输出指令");
logic == nullptr ? LogicEditorError::LogicNotFound
: existing_rung == nullptr && !rung_id.empty()
? LogicEditorError::RungNotFound
: LogicEditorError::InvalidNode,
logic == nullptr ? "未找到控制逻辑"
: existing_rung == nullptr && !rung_id.empty()
? "未找到梯形图网络"
: "梯形图输出必须使用有效的输出指令");
}
const std::string node_id = existing_rung->output.has_value()
if (rung_id.empty()
&& logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic)
{
return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络");
}
const bool create_rung = rung_id.empty();
const std::string target_rung_id = create_rung
? makeUniqueRungId(*logic) : rung_id;
const std::string node_id = existing_rung != nullptr
&& existing_rung->output.has_value()
? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config));
LogicNode node = makeNode(node_id, config);
node.configured = configured;
@@ -1504,14 +1676,26 @@ LogicEditorResult LogicEditorService::setOutput(
{
return failure(LogicEditorError::InvalidNode, validation_error);
}
if (existing_rung->output.has_value()
if (existing_rung != nullptr && existing_rung->output.has_value()
&& nodesEqual(*existing_rung->output, node))
{
return {true, LogicEditorError::None, {}, node_id};
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
findEditableRung(project, logic_id, rung_id)->output = std::move(node);
auto editable_logic = std::find_if(
project.controlLogics.begin(), project.controlLogics.end(),
[&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; });
if (create_rung)
{
editable_logic->rungs.push_back({
target_rung_id,
"网络 " + std::to_string(editable_logic->rungs.size() + 1U),
{},
std::nullopt,
std::nullopt});
}
findEditableRung(project, logic_id, target_rung_id)->output = std::move(node);
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}


+ 7
- 1
app/src/services/logic_editor_service.h Zobrazit soubor

@@ -54,7 +54,7 @@ public:
const std::string &logic_id, const std::string &node_id) const;
std::string registerCommentFor(const RegisterAddress &address) const;

// 确保新工程至少有一组可编辑逻辑
// 确保新工程至少有一组可编辑逻辑;逻辑组初始允许没有网络
LogicEditorResult ensureDefaultLogic();
// 逻辑组管理
LogicEditorResult addLogic(const std::string &name);
@@ -81,6 +81,12 @@ public:
const std::string &rung_id,
int column,
const LogicNodeConfig &config);
LogicEditorResult insertConditionInBranchAtColumn(
const std::string &logic_id,
const std::string &rung_id,
const std::string &branch_expression_id,
int column,
const LogicNodeConfig &config);
LogicEditorResult appendWire(
const std::string &logic_id,
const std::string &rung_id,


+ 19
- 2
app/src/ui/alarm_configuration_dialog.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file alarm_configuration_dialog.h
* @brief 定义报警定义配置对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include <QDialog>
@@ -12,7 +20,7 @@ class AlarmConfigurationDialog;
class AlarmEditorService;
struct AlarmDefinition;

// 报警定义编辑对话框;保存和删除都委托 AlarmEditorService
/** 报警定义编辑对话框;保存和删除都委托 AlarmEditorService */
class AlarmConfigurationDialog final : public QDialog
{
public:
@@ -22,16 +30,25 @@ public:
~AlarmConfigurationDialog() override;

private:
// 刷新左侧列表并尽量恢复原选中项
/** 刷新左侧列表并尽量恢复原选中项 */
void reloadDefinitions(const std::string &selected_id = {});
/** 将当前选中报警加载到编辑区 */
void loadSelectedDefinition();
/** 根据报警类型更新条件输入项 */
void updateConditionOptions();
/** 新增一条报警定义 */
void addDefinition();
/** 更新当前选中的报警定义 */
void updateDefinition();
/** 删除当前选中的报警定义 */
void removeDefinition();
/** 从输入框读取报警定义 */
AlarmDefinition definitionFromInputs() const;
/** 返回当前选中的报警标识 */
std::string selectedId() const;

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::AlarmConfigurationDialog> ui_;
/** 报警定义服务,不由对话框拥有 */
AlarmEditorService &service_;
};

+ 23
- 2
app/src/ui/free_monitor_widget.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file free_monitor_widget.h
* @brief 定义自由寄存器监控控件
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/runtime_state.h"
@@ -13,7 +21,7 @@ class FreeMonitorWidget;

class RegisterMonitorService;

// 自由监控 UI:地址列表属于当前会话,读写统一委托 RegisterMonitorService
/** 自由监控 UI:地址列表属于当前会话,读写统一委托 RegisterMonitorService */
class FreeMonitorWidget final : public QWidget
{
Q_OBJECT
@@ -24,24 +32,37 @@ public:
QWidget *parent = nullptr);
~FreeMonitorWidget() override;

// 真机未连接、通信故障或编辑态时应关闭写入入口
/** 真机未连接、通信故障或编辑态时应关闭写入入口 */
void setWriteEnabled(bool enabled);
/** 根据运行模式和 PLC 状态刷新所有监控值 */
void refreshValues(ApplicationMode mode, PlcConnectionState plc_state);
/** 从服务重新加载当前会话中的地址列表 */
void reloadAddresses();

signals:
/** 监控地址列表发生变化时发出 */
void monitorAddressesChanged();
/** 操作完成后发出可直接显示的消息 */
void operationMessage(const QString &message);

private:
/** 从输入框添加一个或多个监控地址 */
void addAddresses();
/** 删除当前选中的监控地址 */
void removeSelectedAddresses();
/** 清空当前会话中的监控地址 */
void clearAddresses();
/** 写入指定行中的寄存器值 */
void writeRow(int row);
/** 根据当前权限刷新写入控件状态 */
void updateWriteControls();
/** 统一处理监控服务返回结果 */
void handleResult(const QString &action, bool succeeded, const std::string &message);

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::FreeMonitorWidget> ui_;
/** 寄存器监控服务,不由控件拥有 */
RegisterMonitorService &service_;
/** 当前是否允许执行寄存器写入 */
bool write_enabled_ = false;
};

+ 13
- 8
app/src/ui/hmi_editor_widget.h Zobrazit soubor

@@ -1,6 +1,7 @@
/**
* @file hmi_editor_widget.h
* @brief 定义基于 QGraphicsScene 的 HMI 页面编辑和运行画布
* @version 1.0.0
* @author suyu
* @date 2026-08-10
*/
@@ -56,8 +57,7 @@ public:
* @param active 为 true 时允许按钮和数值输入调用运行时服务
*/
void setRuntimeActive(bool active);
// 故障态保持运行值可见,但禁止按钮和数值输入继续写入
// 故障态保持运行值可见,但禁止按钮和数值输入继续写入
/** 故障态保持运行值可见,但禁止按钮和数值输入继续写入 */
void setRuntimeWriteEnabled(bool enabled);
/**
* @brief 使用当前页面模型完全重建场景
@@ -70,7 +70,9 @@ public:
* @param control_id 待选中控件标识,不存在时不改变当前选择
*/
void selectControl(const std::string &control_id);
/** 返回当前选中的单个控件标识 */
std::string selectedControlId() const;
/** 返回当前选中的多个控件标识 */
std::vector<std::string> selectedControlIds() const;
/**
* @brief 从统一寄存器仓库读取当前值并刷新画布显示
@@ -95,6 +97,7 @@ signals:
* @param message 可直接显示的错误信息
*/
void editorError(const QString &message);
/** 运行态请求跳转到指定 HMI 页面 */
void pageNavigationRequested(const QString &target_page_id);

protected:
@@ -115,18 +118,20 @@ private:
// 在运行态处理数值输入激活
void handleNumericInputActivated(const std::string &control_id);

// 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据
/** 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 */
HmiEditorService &editor_service_;
// 提供运行态寄存器读写能力,画布不直接访问寄存器仓库
/** 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 */
HmiRuntimeService &runtime_service_;
/** 报警查询服务,不由控件拥有 */
AlarmService &alarm_service_;
// 持有所有页面图元和页面边框
/** 持有所有页面图元和页面边框 */
QGraphicsScene *scene_ = nullptr;
// 当前画布投影的页面标识
/** 当前画布投影的页面标识 */
std::string page_id_;
// 控制是否允许拖动和编辑控件
/** 控制是否允许拖动和编辑控件 */
bool editing_enabled_ = true;
// 控制是否刷新运行值并响应运行态控件操作
/** 控制是否刷新运行值并响应运行态控件操作 */
bool runtime_active_ = false;
/** 控制运行态按钮和数值输入是否允许写入 */
bool runtime_write_enabled_ = false;
};

+ 87
- 39
app/src/ui/logic_editor_widget.cpp Zobrazit soubor

@@ -649,14 +649,24 @@ public:
const std::string &rung_id,
int column,
const QPointF &center,
bool show_label)
: rung_id_(rung_id), column_(column), show_label_(show_label)
bool show_label,
std::string branch_expression_id = {})
: rung_id_(rung_id),
branch_expression_id_(std::move(branch_expression_id)),
column_(column),
show_label_(show_label)
{
setPos(center);
setFlag(ItemIsSelectable, true);
setZValue(2.0);
setToolTip(LogicEditorWidget::tr("空条件网格:第 %1 列,点击后可插入触点")
.arg(column + 1));
setToolTip(
branch_expression_id_.empty()
? LogicEditorWidget::tr(
"空条件网格:第 %1 列,点击后可插入触点")
.arg(column + 1)
: LogicEditorWidget::tr(
"并联空网格:第 %1 格,点击后可插入触点")
.arg(column + 1));
}

QRectF boundingRect() const override
@@ -676,9 +686,6 @@ public:
painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine));
painter->drawRect(boundingRect().adjusted(3, 3, -3, -3));
}
painter->setPen(ladderPen(false));
painter->drawLine(
QPointF(-kCellWidth / 2.0, 0), QPointF(kCellWidth / 2.0, 0));
if (show_label_)
{
painter->setPen(kPlaceholderColor);
@@ -690,10 +697,15 @@ public:
}

const std::string &rungId() const { return rung_id_; }
const std::string &branchExpressionId() const
{
return branch_expression_id_;
}
int column() const { return column_; }

private:
std::string rung_id_;
std::string branch_expression_id_;
int column_ = 0;
bool show_label_ = false;
};
@@ -782,6 +794,13 @@ public:

QRectF boundingRect() const override { return {kSceneMargin, 0, width_, height_}; }

QPainterPath shape() const override
{
QPainterPath path;
path.addRect(QRectF(kSceneMargin, 0, width_, kRungHeaderHeight));
return path;
}

void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *) override
{
const bool selected = (option->state & QStyle::State_Selected) != 0;
@@ -1006,9 +1025,29 @@ RenderResult renderExpression(
scene.addLine(
QLineF(QPointF(left_join, branches[index].input.y()), branches[index].input),
ladderPen(branch_active));
scene.addLine(
QLineF(branches[index].output, QPointF(right_join, branches[index].output.y())),
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));
}
}
return {QPointF(left_join, top_y), QPointF(right_join, top_y)};
}
@@ -1142,14 +1181,14 @@ void LogicEditorWidget::reloadLogic()
column == 0 && !rung.output.has_value()));
}

const bool rung_active = runtime_trace_enabled_ && traceValue(
trace_, &LogicTraceSnapshot::rungValues, rung.id);
const qreal output_left = right_rail_x - kCellWidth;
scene_->addLine(
QLineF(expression_output, QPointF(output_left, main_y)),
ladderPen(rung_active));
if (rung.output.has_value())
{
const bool rung_active = runtime_trace_enabled_ && traceValue(
trace_, &LogicTraceSnapshot::rungValues, rung.id);
scene_->addLine(
QLineF(expression_output, QPointF(output_left, main_y)),
ladderPen(rung_active));
scene_->addLine(
QLineF(QPointF(output_left, main_y), QPointF(right_rail_x, main_y)),
ladderPen(rung_active));
@@ -1186,11 +1225,6 @@ void LogicEditorWidget::reloadLogic()
*scene_,
QRectF(output_left, grid_top, kCellWidth, kCellHeight),
tr("输出线圈"));
scene_->addLine(
QLineF(
QPointF(output_left + kCellWidth, main_y),
QPointF(right_rail_x, main_y)),
ladderPen(rung_active));
}
top += rung_height + kRungGap;
++number;
@@ -1353,17 +1387,17 @@ std::vector<std::string> LogicEditorWidget::selectedWireIds() const
return ids;
}

std::vector<int> LogicEditorWidget::selectedEmptySlotColumns() const
std::vector<std::pair<std::string, int>> LogicEditorWidget::selectedEmptySlots() const
{
std::vector<int> columns;
std::vector<std::pair<std::string, int>> targets;
for (QGraphicsItem *item : scene_->selectedItems())
{
if (const EmptySlotItem *slot = dynamic_cast<const EmptySlotItem *>(item))
{
columns.push_back(slot->column());
targets.emplace_back(slot->branchExpressionId(), slot->column());
}
}
return columns;
return targets;
}

std::vector<std::pair<std::string, int>> LogicEditorWidget::selectedWireCells() const
@@ -1456,6 +1490,18 @@ std::string LogicEditorWidget::selectedRungId() const
return rung_id;
}

bool LogicEditorWidget::hasSelectedRungItem() const
{
for (QGraphicsItem *item : scene_->selectedItems())
{
if (dynamic_cast<const RungItem *>(item) != nullptr)
{
return true;
}
}
return false;
}

LogicEditorResult LogicEditorWidget::addRung()
{
const LogicEditorResult result = editor_service_.addRung(logic_id_);
@@ -1474,15 +1520,16 @@ LogicEditorResult LogicEditorWidget::addRung()

LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config)
{
const std::vector<int> selected_empty_columns = selectedEmptySlotColumns();
const std::vector<std::pair<std::string, int>> selected_empty_slots =
selectedEmptySlots();
const std::vector<std::pair<std::string, int>> selected_wire_cells =
selectedWireCells();
const std::vector<std::string> selected_expressions = selectedExpressionIds();
const std::vector<std::string> selected_wires = selectedWireIds();
const std::vector<std::string> selected_ids = selectedNodeIds();
LogicEditorResult result;
if (selected_empty_columns.size() > 1U || selected_wire_cells.size() > 1U
|| (!selected_empty_columns.empty()
if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U
|| (!selected_empty_slots.empty()
&& (!selected_wire_cells.empty()
|| !selected_expressions.empty() || !selected_ids.empty()))
|| (!selected_wire_cells.empty()
@@ -1491,13 +1538,14 @@ LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config)
result = {false, LogicEditorError::InvalidOperation,
"插入条件时只能选择一个网格或条件对象", {}};
}
else if (selected_empty_columns.size() == 1U)
else if (selected_empty_slots.size() == 1U)
{
result = editor_service_.insertConditionAtColumn(
logic_id_,
currentRungId(),
selected_empty_columns.front(),
config);
const auto &slot = selected_empty_slots.front();
result = slot.first.empty()
? editor_service_.insertConditionAtColumn(
logic_id_, currentRungId(), slot.second, config)
: editor_service_.insertConditionInBranchAtColumn(
logic_id_, currentRungId(), slot.first, slot.second, config);
}
else if (selected_wire_cells.size() == 1U)
{
@@ -1762,20 +1810,20 @@ LogicEditorResult LogicEditorWidget::deleteSelected()
{
result = editor_service_.removeNodes(logic_id_, node_ids);
}
else
else if (hasSelectedRungItem())
{
const std::string rung_id = selectedRungId();
if (rung_id.empty())
{
return {false, LogicEditorError::InvalidOperation,
"请先选择要删除的逻辑节点或网络", {}};
}
result = editor_service_.removeRung(logic_id_, rung_id);
if (result.succeeded && current_rung_id_ == rung_id)
{
current_rung_id_ = editor_service_.firstRungId(logic_id_);
}
}
else
{
result = {false, LogicEditorError::InvalidOperation,
"请先选择要删除的逻辑节点或网络", {}};
}
if (result.succeeded)
{
reloadLogic();


+ 52
- 4
app/src/ui/logic_editor_widget.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file logic_editor_widget.h
* @brief 定义结构化梯形图编辑和运行轨迹画布
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/control_logic_model.h"
@@ -13,7 +21,7 @@
class QGraphicsScene;
class QResizeEvent;

// 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交
/** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */
class LogicEditorWidget final : public QGraphicsView
{
Q_OBJECT
@@ -30,57 +38,97 @@ public:
LogicEditorService &editor_service,
QWidget *parent = nullptr);

/** 切换当前显示的控制逻辑 */
void setLogicId(const std::string &logic_id);
/** 设置是否允许编辑梯形图 */
void setEditingEnabled(bool enabled);
// 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹
/** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */
void setRuntimeTrace(
const LogicTraceSnapshot &trace,
const std::string &fault_node_id = {});
/** 清除当前运行轨迹和故障节点标记 */
void clearRuntimeTrace();
/** 根据当前逻辑模型重建梯形图场景 */
void reloadLogic();
/** 选中指定节点并滚动到可见区域 */
void selectNode(const std::string &node_id);
/** 返回当前选中的单个节点标识 */
std::string selectedNodeId() const;
/** 返回当前选中的多个节点标识 */
std::vector<std::string> selectedNodeIds() const;
/** 返回当前选中的网络标识 */
std::string selectedRungId() const;

/** 新增一个空的梯形图网络 */
LogicEditorResult addRung();
// 下列编辑命令只负责把当前选择翻译成服务调用
/** 将当前选择转换为服务调用并新增串联条件 */
LogicEditorResult addCondition(const LogicNodeConfig &config);
/** 将当前选择转换为服务调用并新增并联支路 */
LogicEditorResult addParallelBranch(const LogicNodeConfig &config);
/** 在当前选择位置新增横线 */
LogicEditorResult addHorizontalWire();
/** 在当前选择位置新增竖线 */
LogicEditorResult addVerticalWire();
/** 删除当前选择位置的横线 */
LogicEditorResult deleteHorizontalWire();
/** 删除当前选择位置的竖线 */
LogicEditorResult deleteVerticalWire();
/** 设置当前网络的输出线圈 */
LogicEditorResult setOutput(
const LogicNodeConfig &config, bool configured = false);
/** 删除当前选中的节点、线段或网络 */
LogicEditorResult deleteSelected();

signals:
/** 当前选中的节点发生变化时发出 */
void nodeSelected(const QString &node_id);
/** 梯形图模型成功发生变化时发出 */
void graphChanged();
/** 编辑服务操作失败时发出可显示的错误信息 */
void editorError(const QString &message);

protected:
/** 在窗口大小变化后重新布局梯形图场景 */
void resizeEvent(QResizeEvent *event) override;

private:
/** 处理图形场景选择变化 */
void handleSelectionChanged();
/** 把编辑服务结果转换为错误信号 */
void reportFailure(const LogicEditorResult &result);
/** 返回当前选中的网络标识 */
std::string currentRungId() const;
/** 选中指定表达式图元 */
void selectExpression(const std::string &expression_id);
/** 选中指定横线图元 */
void selectWire(const std::string &wire_id);
/** 收集当前选中的表达式标识 */
std::vector<std::string> selectedExpressionIds() const;
/** 收集当前选中的横线标识 */
std::vector<std::string> selectedWireIds() const;
/** 收集当前选中的并联支路标识 */
std::vector<std::string> selectedBranchIds() const;
std::vector<int> selectedEmptySlotColumns() const;
/** 收集当前选中的空网格位置 */
std::vector<std::pair<std::string, int>> selectedEmptySlots() const;
/** 收集当前选中的横线网格位置 */
std::vector<std::pair<std::string, int>> selectedWireCells() const;
/** 判断当前是否选中了网络内的图元 */
bool hasSelectedRungItem() const;

/** 梯形图编辑服务,不由控件拥有 */
LogicEditorService &editor_service_;
/** 承载梯形图图元的场景 */
QGraphicsScene *scene_ = nullptr;
/** 当前显示的控制逻辑标识 */
std::string logic_id_;
/** 当前选中的网络标识 */
std::string current_rung_id_;
/** 最近一次收到的运行轨迹 */
LogicTraceSnapshot trace_;
/** 最近一次运行故障节点标识 */
std::string fault_node_id_;
/** 是否显示运行轨迹 */
bool runtime_trace_enabled_ = false;
/** 是否允许编辑梯形图 */
bool editing_enabled_ = true;
};

+ 18
- 2
app/src/ui/logic_instruction_dialog.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file logic_instruction_dialog.h
* @brief 定义梯形图指令参数编辑对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/control_logic_model.h"
@@ -13,7 +21,7 @@ namespace Ui {
class LogicInstructionDialog;
}

// 梯形图节点配置对话框;只负责把表单转换为强类型 LogicNodeConfig
/** 梯形图节点配置对话框;只负责把表单转换为强类型 LogicNodeConfig */
class LogicInstructionDialog final : public QDialog
{
Q_OBJECT
@@ -24,22 +32,30 @@ public:
QWidget *parent = nullptr);
~LogicInstructionDialog() override;

// 返回当前表单配置,调用方仍需交给 LogicEditorService 校验
/** 返回当前表单配置,调用方仍需交给 LogicEditorService 校验 */
LogicNodeConfig config() const;

private:
/** 根据计数器配置初始化计数器输入项 */
void configureCounter(const CounterNodeConfig &config);
/** 根据传送配置初始化传送输入项 */
void configureMove(const MoveNodeConfig &config);
/** 根据算术配置初始化运算输入项 */
void configureArithmetic(const ArithmeticNodeConfig &config);
/** 根据操作数类型更新数值输入框的合法范围 */
void updateOperandRanges();
/** 将领域操作数写入对应的下拉框和数值框 */
static void setOperand(
QComboBox *kind_combo,
QSpinBox *value_spin,
const WordOperand &operand);
/** 从下拉框和数值框读取领域操作数 */
static WordOperand operandFrom(
const QComboBox *kind_combo,
const QSpinBox *value_spin);

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::LogicInstructionDialog> ui_;
/** 打开对话框时保存的原始节点配置 */
LogicNodeConfig original_config_;
};

+ 76
- 22
app/src/ui/main_window.h Zobrazit soubor

@@ -91,78 +91,105 @@ public:

~MainWindow() override;

/** 返回当前选中的 HMI 页面标识 */
const std::string &currentHmiPageId() const;
/** 返回当前选中的控制逻辑标识 */
const std::string &currentLogicId() const;

protected:
/** 处理主窗口及子控件的事件过滤请求 */
bool eventFilter(QObject *watched, QEvent *event) override;
/** 关闭窗口前处理保存确认和运行态退出 */
void closeEvent(QCloseEvent *event) override;

private:
// 配置菜单和工具栏动作
/** 配置菜单和工具栏动作 */
void configureActions();
// 配置主窗口外观和状态栏
/** 配置主窗口外观和状态栏 */
void configureAppearance();
// 创建并连接 HMI 编辑画布
/** 创建并连接 HMI 编辑画布 */
void configureHmiEditor();
// 创建并连接控制逻辑编辑画布
/** 创建并连接控制逻辑编辑画布 */
void configureLogicEditor();
/** 创建并连接运行监控面板 */
void configureRuntimeMonitor();
/** 创建并连接工程树 */
void configureProjectTree();
// 创建并连接控件属性编辑表单
/** 创建并连接控件属性编辑表单 */
void configurePropertyEditor();
/** 创建并连接 PLC 串口配置入口 */
void configurePlcConnection();
/** 创建并连接报警配置入口 */
void configureAlarms();
/** 创建并连接寄存器注释入口 */
void configureRegisterComments();
/** 撤销当前处于焦点的编辑器操作 */
void undoActiveEditor();
/** 重做当前处于焦点的编辑器操作 */
void redoActiveEditor();
/** 删除当前处于焦点的编辑器选中项 */
void deleteActiveSelection();
/** 清除当前处于焦点的编辑器选择 */
void clearActiveSelection();
/** 根据编辑器选择状态更新撤销、重做和删除动作 */
void updateEditActions();
/** 清空 HMI 和梯形图编辑器的撤销历史 */
void clearEditorHistories();
/** 根据工程路径和修改状态刷新窗口标题 */
void updateWindowTitle();
/** 对可能破坏当前工程的操作请求保存确认 */
bool confirmSaveBeforeDestructiveAction();
// 刷新工程树和当前 HMI 页面信息
/** 刷新工程树和当前 HMI 页面信息 */
void refreshProjectUi();
/** 更新工程树相关动作的可用状态 */
void updateProjectTreeActions();
// 显示指定 HMI 控件的可编辑属性
/** 显示指定 HMI 控件的可编辑属性 */
void showControlProperties(const std::string &control_id);
// 显示指定控制逻辑节点的可编辑属性
/** 显示指定控制逻辑节点的可编辑属性 */
void showLogicNodeProperties(const std::string &node_id);
// 向当前页面添加指定类型的 HMI 控件
/** 向当前页面添加指定类型的 HMI 控件 */
void addHmiControl(HmiControlType type);
// 删除当前选中的 HMI 控件
/** 删除当前选中的 HMI 控件 */
void deleteSelectedControl();
// 在当前光标节点后添加串联条件,没有选择时追加到网络末尾
/** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */
void addLogicCondition(const LogicNodeConfig &config);
// 为当前选中的连续逻辑范围建立并联支路
/** 为当前选中的连续逻辑范围建立并联支路 */
void addLogicParallelBranch(const LogicNodeConfig &config);
/** 在当前梯形图位置添加横线 */
void addLogicHorizontalWire();
/** 在当前梯形图位置添加竖线 */
void addLogicVerticalWire();
/** 删除当前梯形图位置的横线 */
void deleteLogicHorizontalWire();
/** 删除当前梯形图位置的竖线 */
void deleteLogicVerticalWire();
// 设置当前网络右侧的输出线圈
/** 设置当前网络右侧的输出线圈 */
void setLogicOutput(const LogicNodeConfig &config);
/** 打开输出指令配置并提交输出线圈 */
void configureAndSetLogicOutput(const LogicNodeConfig &config);
// 新增一个梯形图网络
/** 新增一个梯形图网络 */
void addLogicRung();
/** 编辑当前网络的注释 */
void editSelectedRungComment();
// 删除当前选中的逻辑节点或网络
/** 删除当前选中的逻辑节点或网络 */
void deleteSelectedLogicObject();
/** 打开 PLC 连接 */
void connectPlc();
/** 关闭 PLC 连接 */
void disconnectPlc();
// 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志
/** 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 */
void schedulePlcStatusUpdate();
// 创建新的工程并刷新编辑界面
/** 创建新的工程并刷新编辑界面 */
void createNewProject();
// 保存当前工程到已关联的路径
/** 保存当前工程到已关联的路径 */
void saveProject();
// 将当前工程保存到用户指定的路径
/** 将当前工程保存到用户指定的路径 */
void saveProjectAs();
// 从用户指定的路径加载工程
/** 从用户指定的路径加载工程 */
void loadProject();
// 在状态栏和输出面板显示工程操作结果
/** 在状态栏和输出面板显示工程操作结果 */
void showProjectResult(const QString &action, const QString &message, bool succeeded);
/** 向输出面板追加一条消息 */
void appendOutputMessage(const QString &message);

/**
@@ -178,15 +205,17 @@ private:
* @param message 要显示在状态栏和输出区的模式结果消息
*/
void updateModeUi(const QString &message);
// 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限
/** 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 */
void updateSimulationUi(bool report_fault);

/**
* @brief 将互斥模式动作同步到服务层当前状态
*/
void restoreCurrentModeAction();
/** 完成主窗口初始化和首次界面刷新 */
void initializeUi();

/** Qt Designer 生成的主窗口界面对象 */
std::unique_ptr<Ui::MainWindow> ui_;
/**
* @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期
@@ -196,29 +225,54 @@ private:
* @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期
*/
ProjectService &project_service_;
/** 非拥有的 HMI 编辑服务依赖 */
HmiEditorService &hmi_editor_service_;
/** 非拥有的梯形图编辑服务依赖 */
LogicEditorService &logic_editor_service_;
/** 非拥有的 HMI 运行服务依赖 */
HmiRuntimeService &hmi_runtime_service_;
/** 非拥有的报警编辑服务依赖 */
AlarmEditorService &alarm_editor_service_;
/** 非拥有的报警查询服务依赖 */
AlarmService &alarm_service_;
/** 非拥有的寄存器注释服务依赖 */
RegisterCommentService &register_comment_service_;
/** 非拥有的寄存器监控服务依赖 */
RegisterMonitorService &register_monitor_service_;
/** 当调用方未提供导航服务时由主窗口负责拥有的实例 */
std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_;
/** 当前使用的 HMI 页面导航服务 */
HmiNavigationService *hmi_navigation_service_ = nullptr;
/** 工程树控制器 */
std::unique_ptr<ProjectWorkspaceController> project_workspace_controller_;
/** 属性面板控制器 */
std::unique_ptr<PropertyPanelController> property_panel_controller_;
/** 运行监控面板控制器 */
std::unique_ptr<RuntimePanelController> runtime_panel_controller_;
/** 运行模式互斥动作组 */
QActionGroup *mode_action_group_ = nullptr;
/** 主界面的 HMI 编辑器 */
HmiEditorWidget *hmi_editor_widget_ = nullptr;
/** 主界面的梯形图编辑器 */
LogicEditorWidget *logic_editor_widget_ = nullptr;
/** 唯一的运行监控控件 */
RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr;
/** 显示当前运行模式的状态标签 */
QLabel *mode_status_label_ = nullptr;
/** 显示 PLC 连接状态的标签 */
QLabel *register_status_label_ = nullptr;
/** 显示离线执行器状态的标签 */
QLabel *executor_status_label_ = nullptr;
/** 当前保存的 PLC 串口配置 */
PlcSerialConfiguration plc_configuration_;
/** 当前选中的 HMI 控件标识 */
std::string selected_control_id_;
/** 当前选中的梯形图节点标识 */
std::string selected_logic_node_id_;
/** 当前选中的 HMI 页面标识 */
std::string current_hmi_page_id_;
/** 当前选中的控制逻辑标识 */
std::string current_logic_id_;
/** 是否已经安排了待处理的 PLC 状态刷新 */
bool plc_status_update_pending_ = false;
};

+ 11
- 2
app/src/ui/plc_connection_dialog.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file plc_connection_dialog.h
* @brief 定义 PLC 串口连接参数对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "services/plc_communication_gateway.h"
@@ -10,7 +18,7 @@ namespace Ui {
class PlcConnectionDialog;
}

// PLC 串口参数对话框;只编辑配置,不负责打开串口
/** PLC 串口参数对话框;只编辑配置,不负责打开串口 */
class PlcConnectionDialog final : public QDialog
{
Q_OBJECT
@@ -21,9 +29,10 @@ public:
QWidget *parent = nullptr);
~PlcConnectionDialog() override;

// 返回表单中的串口配置,连接前由网关再次校验
/** 返回表单中的串口配置,连接前由网关再次校验 */
PlcSerialConfiguration configuration() const;

private:
/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::PlcConnectionDialog> ui_;
};

+ 56
- 2
app/src/ui/project_workspace_controller.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file project_workspace_controller.h
* @brief 定义工程树和当前编辑对象控制器
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/hmi_model.h"
@@ -23,15 +31,21 @@ namespace Ui {
class MainWindow;
}

// 组织工程树、当前页面/逻辑选择和工程对象的界面刷新
// 它不直接改 Project,所有增删改都转发给对应服务
/**
* 组织工程树、当前页面/逻辑选择和工程对象的界面刷新
*
* 它不直接改 Project,所有增删改都转发给对应服务
*/
class ProjectWorkspaceController final
{
public:
/** 报告工程操作结果,参数依次为操作名、消息和是否成功 */
using ResultReporter = std::function<void(
const QString &action, const QString &message, bool succeeded)>;
/** 向状态栏报告一条带超时的消息 */
using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>;
/** 通知主窗口编辑状态已经发生变化 */
using EditStateChanged = std::function<void()>;

ProjectWorkspaceController(
@@ -53,21 +67,34 @@ public:
StatusReporter status_reporter,
EditStateChanged edit_state_changed);

/** 连接工程树动作和选择变化信号 */
void configure();
/** 根据当前工程重新生成工程树和编辑器内容 */
void refresh();
/** 根据当前选中对象更新工程树动作的可用状态 */
void updateActions();
/** 新增一个 HMI 页面 */
void addPage();
/** 新增一个控制逻辑 */
void addLogic();
/** 重命名当前选中的工程树项目 */
void renameSelectedItem();
/** 删除当前选中的工程树项目 */
void deleteSelectedItem();
/** 按偏移量移动当前选中的工程树项目 */
void moveSelectedItem(int offset);
/** 把当前选中的 HMI 页面设为初始页面 */
void setSelectedPageAsInitial();
/** 启用或停用当前选中的控制逻辑 */
void toggleSelectedLogicEnabled();

/** 返回当前 HMI 页面标识 */
const std::string &currentHmiPageId() const;
/** 返回当前控制逻辑标识 */
const std::string &currentLogicId() const;

private:
/** 工程树项目的三种业务类型 */
enum class ItemKind
{
Root = 0,
@@ -75,34 +102,61 @@ private:
ControlLogic = 2
};

/** 处理工程树选择变化并刷新当前编辑器 */
void handleSelectionChanged();
/** 统一报告工程操作失败 */
void reportFailure(
const QString &action, const std::string &message) const;

/** 主窗口父对象,不由控制器拥有 */
QWidget &parent_;
/** 主窗口的 Designer 界面对象 */
Ui::MainWindow &ui_;
/** 工程服务,不由控制器拥有 */
ProjectService &project_service_;
/** HMI 编辑服务,不由控制器拥有 */
HmiEditorService &hmi_editor_service_;
/** 梯形图编辑服务,不由控制器拥有 */
LogicEditorService &logic_editor_service_;
/** 运行模式服务,不由控制器拥有 */
RuntimeModeService &runtime_mode_service_;
/** HMI 页面导航服务,不由控制器拥有 */
HmiNavigationService &hmi_navigation_service_;
/** 主窗口中的 HMI 编辑器 */
HmiEditorWidget &hmi_editor_widget_;
/** 主窗口中的梯形图编辑器 */
LogicEditorWidget &logic_editor_widget_;
/** 运行监控控件 */
RuntimeMonitorWidget &runtime_monitor_widget_;
/** 主窗口保存的当前 HMI 页面标识 */
std::string &current_hmi_page_id_;
/** 主窗口保存的当前控制逻辑标识 */
std::string &current_logic_id_;
/** 显示 HMI 控件属性的回调 */
std::function<void(const std::string &)> show_control_properties_;
/** 显示梯形图节点属性的回调 */
std::function<void(const std::string &)> show_logic_properties_;
/** 工程操作结果回调 */
ResultReporter result_reporter_;
/** 状态栏消息回调 */
StatusReporter status_reporter_;
/** 编辑状态变化回调 */
EditStateChanged edit_state_changed_;

/** 新增页面动作 */
QAction *add_page_action_ = nullptr;
/** 新增逻辑动作 */
QAction *add_logic_action_ = nullptr;
/** 重命名动作 */
QAction *rename_item_action_ = nullptr;
/** 删除工程树项目动作 */
QAction *delete_item_action_ = nullptr;
/** 上移工程树项目动作 */
QAction *move_item_up_action_ = nullptr;
/** 下移工程树项目动作 */
QAction *move_item_down_action_ = nullptr;
/** 设置初始页面动作 */
QAction *set_initial_page_action_ = nullptr;
/** 启停控制逻辑动作 */
QAction *toggle_logic_enabled_action_ = nullptr;
};

+ 43
- 2
app/src/ui/property_panel_controller.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file property_panel_controller.h
* @brief 定义 HMI 和梯形图属性面板控制器
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/control_logic_model.h"
@@ -19,13 +27,18 @@ namespace Ui {
class MainWindow;
}

// 组织 HMI/梯形图选择状态、属性面板和编辑器信号
// 属性提交统一经过编辑服务,以便校验、原子回滚和撤销/重做
/**
* 组织 HMI/梯形图选择状态、属性面板和编辑器信号
*
* 属性提交统一经过编辑服务,以便校验、原子回滚和撤销/重做
*/
class PropertyPanelController final
{
public:
/** 报告属性操作结果,参数依次为操作名、消息和是否成功 */
using ResultReporter = std::function<void(
const QString &action, const QString &message, bool succeeded)>;
/** 向状态栏报告一条带超时的消息 */
using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>;

@@ -43,39 +56,67 @@ public:
ResultReporter result_reporter,
StatusReporter status_reporter);

/** 连接属性面板按钮、输入框和服务信号 */
void configure();
/** 绑定 HMI 与梯形图编辑器,接收它们的选择变化 */
void bindEditorWidgets(
HmiEditorWidget &hmi_editor_widget,
LogicEditorWidget &logic_editor_widget);

/** 显示指定 HMI 控件的属性 */
void showControlProperties(const std::string &control_id);
/** 显示指定 HMI 页面尺寸属性 */
void showPageProperties(const std::string &page_id);
/** 显示指定梯形图节点的属性 */
void showLogicNodeProperties(const std::string &node_id);
/** 在当前页面添加指定类型的 HMI 控件 */
void addHmiControl(HmiControlType type);
/** 删除当前选中的 HMI 控件 */
void deleteSelectedControl();
/** 提交当前 HMI 控件属性 */
void applySelectedControlProperties();
/** 提交当前 HMI 页面属性 */
void applySelectedPageProperties();
/** 提交当前梯形图节点属性 */
void applySelectedLogicNodeProperties();

private:
/** 显示 HMI 编辑服务返回的错误 */
void handleHmiEditorError(const QString &message) const;
/** 显示梯形图编辑服务返回的错误 */
void handleLogicEditorError(const QString &message) const;
/** 打开颜色选择器并写回属性输入框 */
void chooseTextColor();
/** 统一报告属性操作失败 */
void reportFailure(
const QString &action, const std::string &message) const;

/** 主窗口父对象,不由控制器拥有 */
QWidget &parent_;
/** 主窗口的 Designer 界面对象 */
Ui::MainWindow &ui_;
/** 工程服务,不由控制器拥有 */
ProjectService &project_service_;
/** HMI 编辑服务,不由控制器拥有 */
HmiEditorService &hmi_editor_service_;
/** 梯形图编辑服务,不由控制器拥有 */
LogicEditorService &logic_editor_service_;
/** 获取当前 HMI 页面标识的回调 */
std::function<std::string()> current_page_id_;
/** 获取当前控制逻辑标识的回调 */
std::function<std::string()> current_logic_id_;
/** 主窗口保存的当前控件标识 */
std::string &selected_control_id_;
/** 主窗口保存的当前逻辑节点标识 */
std::string &selected_logic_node_id_;
/** 请求主窗口刷新工程界面的回调 */
std::function<void()> refresh_project_ui_;
/** 属性操作结果回调 */
ResultReporter result_reporter_;
/** 状态栏消息回调 */
StatusReporter status_reporter_;
/** 绑定后的 HMI 编辑器 */
HmiEditorWidget *hmi_editor_widget_ = nullptr;
/** 绑定后的梯形图编辑器 */
LogicEditorWidget *logic_editor_widget_ = nullptr;
};

+ 17
- 1
app/src/ui/register_comment_dialog.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file register_comment_dialog.h
* @brief 定义 M/D 寄存器工程注释编辑对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/register_address.h"
@@ -13,7 +21,7 @@ class RegisterCommentDialog;

class RegisterCommentService;

// 工程级 M/D 注释编辑对话框;注释不包含实时寄存器值
/** 工程级 M/D 注释编辑对话框;注释不包含实时寄存器值 */
class RegisterCommentDialog final : public QDialog
{
public:
@@ -23,13 +31,21 @@ public:
~RegisterCommentDialog() override;

private:
/** 重新加载注释列表,并尽量保持原选中地址 */
void reloadComments(const RegisterAddress *selected_address = nullptr);
/** 把当前选中地址的注释加载到编辑框 */
void loadSelectedComment();
/** 校验并保存当前地址的注释 */
void saveComment();
/** 删除当前选中地址的注释 */
void removeComment();
/** 从输入框读取寄存器地址 */
RegisterAddress addressFromInputs() const;
/** 返回列表中当前选中的寄存器地址,没有选择时返回空值 */
std::optional<RegisterAddress> selectedAddress() const;

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::RegisterCommentDialog> ui_;
/** 注释读写服务,不由对话框拥有 */
RegisterCommentService &service_;
};

+ 32
- 3
app/src/ui/runtime_monitor_widget.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file runtime_monitor_widget.h
* @brief 定义工程师运行监控主控件
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/runtime_state.h"
@@ -24,7 +32,7 @@ class LogicEditorWidget;
class RegisterMonitorService;
class ProjectService;

// 唯一的工程师运行监控投影,组合 HMI、梯形图轨迹和自由监控
/** 唯一的工程师运行监控投影,组合 HMI、梯形图轨迹和自由监控 */
class RuntimeMonitorWidget final : public QWidget
{
Q_OBJECT
@@ -41,34 +49,55 @@ public:
QWidget *parent = nullptr);
~RuntimeMonitorWidget() override;

/** 将当前页面和控制逻辑绑定到运行监控界面 */
void setProjectObjects(const std::string &page_id, const std::string &logic_id);
/** 切换运行监控中显示的 HMI 页面 */
void setRuntimePage(const std::string &page_id);
// 根据模式决定是否显示本地逻辑轨迹以及是否允许写入
/** 根据模式决定是否显示本地逻辑轨迹以及是否允许写入 */
void setMode(ApplicationMode mode, PlcConnectionState plc_state);
/** 从寄存器仓库刷新 HMI 和自由监控中的当前值 */
void refreshValues(ApplicationMode mode, PlcConnectionState plc_state);
/** 设置 HMI 控件是否允许写入运行值 */
void setHmiWriteEnabled(bool enabled);
/** 设置自由监控是否允许写入寄存器 */
void setFreeMonitorWriteEnabled(bool enabled);
// 只接收离线执行器轨迹;真机时由上层清空
/** 只接收离线执行器轨迹;真机时由上层清空 */
void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {});
/** 返回自由监控子控件,供上层刷新或连接信号 */
FreeMonitorWidget *freeMonitorWidget() const;
/** 返回当前选中的控制逻辑标识 */
std::string selectedLogicId() const;

signals:
/** 页面跳转失败时发出可直接显示的错误信息 */
void navigationFailed(const QString &message);
/** 用户请求退出运行态 */
void exitRequested();

private:
/** 显示指定的运行监控页面 */
void showRuntimePage(const std::string &page_id);
/** 选中指定控制逻辑并刷新梯形图投影 */
void selectRuntimeLogic(const std::string &logic_id);

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::RuntimeMonitorWidget> ui_;
/** 工程服务,不由控件拥有 */
ProjectService &project_service_;
/** HMI 页面导航服务,不由控件拥有 */
HmiNavigationService &hmi_navigation_service_;
/** 运行监控中的 HMI 视图 */
HmiEditorWidget *hmi_view_ = nullptr;
/** 运行监控中的梯形图视图 */
LogicEditorWidget *logic_view_ = nullptr;
/** 运行监控中的自由监控控件 */
FreeMonitorWidget *free_monitor_widget_ = nullptr;
/** 当前选中的控制逻辑标识 */
std::string selected_logic_id_;
/** 最近一次收到的离线逻辑执行轨迹 */
LogicTraceSnapshot latest_trace_;
/** 最近一次离线执行故障节点标识 */
std::string latest_fault_node_id_;
/** 是否已经收到过可显示的逻辑轨迹 */
bool has_logic_trace_ = false;
};

+ 18
- 4
app/src/ui/runtime_monitor_window.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file runtime_monitor_window.h
* @brief 定义独立的工程师运行监控窗口
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include <QMainWindow>
@@ -11,7 +19,7 @@ class RuntimeMonitorWindow;
class QCloseEvent;
class QWidget;

// 承载唯一工程师运行监控投影的独立顶层窗口
/** 承载唯一工程师运行监控投影的独立顶层窗口 */
class RuntimeMonitorWindow final : public QMainWindow
{
Q_OBJECT
@@ -20,22 +28,28 @@ public:
explicit RuntimeMonitorWindow(QWidget *parent = nullptr);
~RuntimeMonitorWindow() override;

/** 将运行监控控件放入窗口中央区域 */
void setMonitorWidget(QWidget &widget);
// 进入运行态时显示并最大化窗口
/** 进入运行态时显示并最大化窗口 */
void showForRuntime();
// 返回编辑态时隐藏窗口但保留唯一监控控件
/** 返回编辑态时隐藏窗口但保留唯一监控控件 */
void hideForEditing();
// 应用退出时允许真正关闭窗口
/** 应用退出时允许真正关闭窗口 */
void closeForApplicationExit();

signals:
/** 用户点击窗口关闭按钮后请求退出运行态 */
void exitRequested();

protected:
/** 根据当前生命周期决定关闭是隐藏还是销毁窗口 */
void closeEvent(QCloseEvent *event) override;

private:
/** Qt Designer 生成的窗口界面对象 */
std::unique_ptr<Ui::RuntimeMonitorWindow> ui_;
/** 当前是否处于运行态显示阶段 */
bool runtime_active_ = false;
/** 应用是否正在退出,决定关闭事件是否真正销毁窗口 */
bool application_exit_ = false;
};

+ 43
- 3
app/src/ui/runtime_panel_controller.h Zobrazit soubor

@@ -1,3 +1,11 @@
/**
* @file runtime_panel_controller.h
* @brief 定义运行监控面板控制器
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/runtime_state.h"
@@ -25,13 +33,16 @@ class RuntimeMonitorWindow;
class QTimer;
class QWidget;

// 组织运行监控工作台、运行刷新定时器和离线执行器反馈
/** 组织运行监控窗口、刷新定时器和离线执行器反馈 */
class RuntimePanelController final
{
public:
/** 向状态栏报告一条带超时的消息 */
using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>;
/** 向输出面板报告一条消息 */
using OutputReporter = std::function<void(const QString &message)>;
/** 请求上层退出当前运行态 */
using RuntimeExitRequester = std::function<void()>;

RuntimePanelController(
@@ -55,44 +66,73 @@ public:
RuntimeExitRequester runtime_exit_requester);
~RuntimePanelController();

/** 连接运行监控相关信号并完成初始配置 */
void configure();
// 创建/复用唯一监控窗口,并把当前工程对象投影到运行态
/** 创建或复用唯一监控窗口,并把当前工程投影到运行态 */
void enterRuntime(
const std::string &page_id,
const std::string &logic_id,
ApplicationMode mode,
PlcConnectionState plc_state);
// 停止刷新并隐藏监控窗口,离线服务的停止由运行模式服务负责
/** 停止刷新并隐藏监控窗口,离线服务的停止由运行模式服务负责 */
void leaveRuntime(ApplicationMode mode, PlcConnectionState plc_state);
/** 应用退出时关闭运行监控窗口并停止相关刷新 */
void closeForApplicationExit();
/** 根据离线执行器状态刷新运行界面 */
void updateSimulationUi(bool report_fault);
/** 返回当前唯一的运行监控控件 */
RuntimeMonitorWidget *runtimeMonitorWidget() const;

private:
/** 处理运行刷新定时器到期事件 */
void handleRuntimeTimer();
/** 处理离线执行器状态变化 */
void handleSimulationStateChanged();
/** 处理一次离线扫描完成事件 */
void handleScanCompleted();

/** 主窗口父对象,不由控制器拥有 */
QWidget &parent_;
/** 运行模式服务,不由控制器拥有 */
RuntimeModeService &runtime_mode_service_;
/** 工程服务,不由控制器拥有 */
ProjectService &project_service_;
/** HMI 编辑服务,不由控制器拥有 */
HmiEditorService &hmi_editor_service_;
/** HMI 运行服务,不由控制器拥有 */
HmiRuntimeService &hmi_runtime_service_;
/** 梯形图编辑服务,不由控制器拥有 */
LogicEditorService &logic_editor_service_;
/** HMI 页面导航服务,不由控制器拥有 */
HmiNavigationService &hmi_navigation_service_;
/** 报警服务,不由控制器拥有 */
AlarmService &alarm_service_;
/** 寄存器监控服务,不由控制器拥有 */
RegisterMonitorService &register_monitor_service_;
/** 主窗口中的 HMI 编辑器,不由控制器拥有 */
HmiEditorWidget &hmi_editor_widget_;
/** 主窗口中的梯形图编辑器,不由控制器拥有 */
LogicEditorWidget &logic_editor_widget_;
/** 显示离线执行器状态的标签 */
QLabel &executor_status_label_;
/** 获取当前控制逻辑标识的回调 */
std::function<std::string()> current_logic_id_;
/** 切换当前控制逻辑的回调 */
std::function<void(const std::string &)> select_logic_;
/** 显示控制逻辑属性的回调 */
std::function<void(const std::string &)> show_logic_properties_;
/** 向状态栏报告消息的回调 */
StatusReporter status_reporter_;
/** 向输出面板报告消息的回调 */
OutputReporter output_reporter_;
/** 请求主窗口退出运行态的回调 */
RuntimeExitRequester runtime_exit_requester_;
/** 唯一的运行监控顶层窗口 */
std::unique_ptr<RuntimeMonitorWindow> runtime_monitor_window_;
/** 运行监控窗口中的主控件,窗口拥有它 */
RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr;
/** 定时刷新运行监控数据的计时器 */
QTimer *runtime_refresh_timer_ = nullptr;
/** 当前是否已经进入运行监控会话 */
bool runtime_session_active_ = false;
};

+ 14
- 2
app/src/ui/toolbar_icon_factory.h Zobrazit soubor

@@ -1,8 +1,16 @@
/**
* @file toolbar_icon_factory.h
* @brief 定义工具栏图标类型和图标创建函数
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include <QIcon>

// 工具栏语义图标;具体 QIcon 绘制集中在 toolbar_icon_factory.cpp
/** 工具栏中使用的语义图标类型 */
enum class UiIcon
{
Edit,
@@ -56,5 +64,9 @@ enum class UiIcon
ClearList
};

// 根据语义图标类型创建 Qt 图标
/**
* @brief 根据语义图标类型创建 Qt 图标
* @param icon 要创建的图标类型
* @return 创建好的 Qt 图标
*/
QIcon makeUiIcon(UiIcon icon);

+ 143
- 11
app/tests/logic_editor_service_tests.cpp Zobrazit soubor

@@ -18,6 +18,13 @@ ContactNodeConfig contact(int address)
return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen};
}

std::string makeEmptyRung(LogicEditorService &service, const std::string &logic_id)
{
const LogicEditorResult result = service.addRung(logic_id);
require(result.succeeded, "test fixture must create an empty network");
return result.id;
}

int conditionColumns(const ConditionExpression &expression)
{
if (expression.kind == ConditionExpressionKind::Node)
@@ -56,13 +63,82 @@ int wireColumns(const ConditionExpression &expression)
return columns;
}

void testEmptyLogicCreatesNetworksOnFirstEdit()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
require(service.findLogic(logic_id)->rungs.empty(),
"a default control logic must start without an empty network");

const LogicEditorResult first_condition = service.appendCondition(
logic_id, {}, contact(0));
require(first_condition.succeeded
&& service.findLogic(logic_id)->rungs.size() == 1U,
"the first condition must create network 1 atomically");
const LadderRung &condition_rung = service.findLogic(logic_id)->rungs.front();
require(condition_rung.condition.has_value()
&& condition_rung.condition->kind == ConditionExpressionKind::Node,
"the first condition must not create an implicit horizontal wire");

require(service.removeRung(logic_id, condition_rung.id).succeeded
&& service.findLogic(logic_id)->rungs.empty(),
"the last network must be removable back to an empty logic");

const LogicEditorResult first_wire = service.appendWire(logic_id, {}, 1);
require(first_wire.succeeded
&& service.findLogic(logic_id)->rungs.size() == 1U
&& service.findLogic(logic_id)->rungs.front().condition->kind
== ConditionExpressionKind::Wire,
"the first horizontal wire must create a one-cell network");

require(service.removeRung(
logic_id, service.findLogic(logic_id)->rungs.front().id).succeeded,
"the wire-only network must be removable");
const LogicEditorResult first_output = service.setOutput(
logic_id,
{},
CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal});
require(first_output.succeeded
&& service.findLogic(logic_id)->rungs.size() == 1U
&& !service.findLogic(logic_id)->rungs.front().condition.has_value()
&& service.findLogic(logic_id)->rungs.front().output.has_value(),
"the first output must create an unconditional network");
}

void testAppendingWireMovesToNextNetworkAfterTenColumns()
{
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);
for (int index = 0; index < ProjectLimits::kMaximumConditionColumns; ++index)
{
require(service.appendWire(logic_id, rung_id).succeeded,
"ten horizontal wire cells must fit in one network");
}
const LogicEditorResult next = service.appendWire(logic_id, rung_id);
require(next.succeeded && service.findLogic(logic_id)->rungs.size() == 2U,
"the next appended wire must create the following network");
const std::string next_rung_id = service.findLogic(logic_id)->rungs.back().id;
require(service.findRung(logic_id, rung_id)->condition.has_value()
&& conditionColumns(*service.findRung(logic_id, rung_id)->condition)
== ProjectLimits::kMaximumConditionColumns
&& service.findRung(logic_id, next_rung_id)->condition.has_value(),
"automatic network rollover must preserve both network contents");
require(service.undo().succeeded && service.findLogic(logic_id)->rungs.size() == 1U,
"automatic network rollover must be undone as one edit");
}

void testStructuredEditingAndNormalization()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);

const LogicEditorResult first = service.appendCondition(logic_id, rung_id, contact(0));
const LogicEditorResult second = service.appendCondition(logic_id, rung_id, contact(1));
@@ -130,7 +206,7 @@ void testRangeParallelInsertion()
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);
service.appendCondition(logic_id, rung_id, contact(0));
service.appendCondition(logic_id, rung_id, contact(1));

@@ -154,13 +230,66 @@ void testRangeParallelInsertion()
"a non-contiguous selection must be rejected");
}

void testParallelBranchGridInsertion()
{
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);
const LogicEditorResult first = service.appendCondition(
logic_id, rung_id, contact(0));
const LogicEditorResult second = service.appendCondition(
logic_id, rung_id, contact(1));
const LogicEditorResult third = service.appendCondition(
logic_id, rung_id, contact(2));
const LogicEditorResult branch = service.addParallelBranch(
logic_id, rung_id, {first.id, second.id, third.id}, contact(10));
require(branch.succeeded,
"parallel grid insertion setup must create a short lower branch");

service.clearHistory();
const LogicEditorResult inserted = service.insertConditionInBranchAtColumn(
logic_id, rung_id, branch.id, 2, contact(12));
require(inserted.succeeded,
"a visible parallel branch padding cell must accept a condition");
const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Parallel,
"branch grid insertion must preserve the surrounding parallel expression");
const ConditionExpression &lower = rung->condition->children.at(1);
require(lower.kind == ConditionExpressionKind::Series
&& lower.children.size() == 3U
&& lower.children.at(0).node->id == branch.id
&& lower.children.at(1).kind == ConditionExpressionKind::Wire
&& lower.children.at(1).wire->columnSpan == 1
&& lower.children.at(2).node->id == inserted.id,
"a distant branch cell must persist only the required gap and new condition");

require(service.undo().succeeded,
"parallel branch grid insertion must be one undoable edit");
rung = service.findRung(logic_id, rung_id);
require(rung->condition->children.at(1).kind == ConditionExpressionKind::Node
&& rung->condition->children.at(1).node->id == branch.id,
"undo must restore the original short parallel branch");

service.clearHistory();
const bool modified_before = project_service.isModified();
const LogicEditorResult invalid = service.insertConditionInBranchAtColumn(
logic_id, rung_id, branch.id, 3, contact(13));
require(!invalid.succeeded && !service.canUndo()
&& project_service.isModified() == modified_before
&& service.findNode(logic_id, inserted.id) == nullptr,
"a cell outside the visible branch padding must fail atomically");
}

void testStructuredWireEditing()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);
service.appendCondition(logic_id, rung_id, contact(0));
service.appendCondition(logic_id, rung_id, contact(1));
service.appendCondition(logic_id, rung_id, contact(2));
@@ -220,7 +349,7 @@ void testBatchDeleteAllNodesInParallelBranch()
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);

const LogicEditorResult first = service.appendCondition(
logic_id, rung_id, contact(0));
@@ -274,7 +403,7 @@ void testConditionColumnLimit()
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);

for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
{
@@ -323,7 +452,7 @@ void testUnconditionalOutputEditing()
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);

require(service.setOutput(
logic_id,
@@ -345,7 +474,7 @@ void testColumnTargetedConditionInsertion()
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string first_rung_id = service.firstRungId(logic_id);
const std::string first_rung_id = makeEmptyRung(service, logic_id);

require(service.insertConditionAtColumn(
logic_id, first_rung_id, 4, contact(4)).succeeded,
@@ -429,7 +558,7 @@ void testWireColumnReplacement()
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);
const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4);
require(wire.succeeded,
"wire-column replacement test must create a four-column wire");
@@ -575,7 +704,7 @@ void testSequentialConditionInsertionConsumesFollowingWire()
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);
const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10);
require(wire.succeeded,
"sequential wire replacement must start with a full-width wire");
@@ -675,7 +804,7 @@ void testEdgeTimerNodesAndRungComments()
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);

const LogicEditorResult rising = service.appendCondition(
logic_id,
@@ -745,7 +874,7 @@ void testHistoryAndAtomicBatchDelete()
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string first_rung_id = service.firstRungId(logic_id);
const std::string first_rung_id = makeEmptyRung(service, logic_id);
const LogicEditorResult first = service.appendCondition(
logic_id, first_rung_id, contact(0));
const LogicEditorResult second = service.appendCondition(
@@ -816,8 +945,11 @@ int main()
{
try
{
testEmptyLogicCreatesNetworksOnFirstEdit();
testAppendingWireMovesToNextNetworkAfterTenColumns();
testStructuredEditingAndNormalization();
testRangeParallelInsertion();
testParallelBranchGridInsertion();
testStructuredWireEditing();
testBatchDeleteAllNodesInParallelBranch();
testConditionColumnLimit();


+ 151
- 8
app/tests/main_window_tests.cpp Zobrazit soubor

@@ -30,6 +30,7 @@
#include <QDialog>
#include <QDockWidget>
#include <QGraphicsItem>
#include <QGraphicsLineItem>
#include <QGraphicsScene>
#include <QImage>
#include <QLabel>
@@ -278,13 +279,20 @@ ContactNodeConfig logicContact(int address)
return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen};
}

std::string makeEmptyRung(LogicEditorService &service, const std::string &logic_id)
{
const LogicEditorResult result = service.addRung(logic_id);
require(result.succeeded, "UI test fixture must create an empty network");
return result.id;
}

void testLogicEditorBatchDeletesWholeParallelRow()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);

std::vector<std::string> top_ids;
for (int address = 0; address < 4; ++address)
@@ -688,7 +696,7 @@ void testLogicEmptyGridSlotInsertion()
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);
LogicEditorWidget editor(service);
editor.setLogicId(logic_id);
editor.resize(1400, 360);
@@ -792,13 +800,143 @@ void testLogicEmptyGridSlotInsertion()
"every newly added network must expose its own ten empty insertion slots");
}

void testLogicParallelPaddingGridInsertion()
{
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);
std::vector<std::string> top_ids;
for (int address = 0; address < 3; ++address)
{
const LogicEditorResult result = service.appendCondition(
logic_id, rung_id, logicContact(address));
require(result.succeeded,
"parallel grid UI setup must create the upper branch");
top_ids.push_back(result.id);
}
const LogicEditorResult lower = service.addParallelBranch(
logic_id, rung_id, top_ids, logicContact(10));
require(lower.succeeded,
"parallel grid UI setup must create a short lower branch");

LogicEditorWidget editor(service);
editor.setLogicId(logic_id);
editor.resize(1700, 480);
editor.show();
QApplication::processEvents();

QList<QGraphicsItem *> branch_slots;
for (QGraphicsItem *item : editor.scene()->items())
{
if (item->toolTip().startsWith(QStringLiteral("并联空网格")))
{
branch_slots.push_back(item);
}
}
std::sort(
branch_slots.begin(), branch_slots.end(),
[](const QGraphicsItem *left, const QGraphicsItem *right)
{
return left->scenePos().x() < right->scenePos().x();
});
require(branch_slots.size() == 2,
"every visible padding cell in a short parallel branch must be selectable");

QTest::mouseClick(
editor.viewport(),
Qt::LeftButton,
Qt::NoModifier,
editor.mapFromScene(branch_slots.back()->scenePos()));
require(editor.scene()->selectedItems().size() == 1
&& editor.scene()->selectedItems().front()->toolTip().startsWith(
QStringLiteral("并联空网格")),
"clicking a parallel padding line must select its grid cell, not the rung");
const LogicEditorResult inserted = editor.addCondition(logicContact(12));
require(inserted.succeeded,
"the normal condition action must insert into a selected parallel grid cell");

const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Parallel
&& rung->condition->children.at(1).kind
== ConditionExpressionKind::Series
&& rung->condition->children.at(1).children.size() == 3U,
"parallel grid insertion must update only the selected branch");
int remaining_branch_slots = 0;
int persisted_wire_cells = 0;
for (QGraphicsItem *item : editor.scene()->items())
{
remaining_branch_slots += item->toolTip().startsWith(
QStringLiteral("并联空网格")) ? 1 : 0;
persisted_wire_cells += item->toolTip().startsWith(
QStringLiteral("横线网格")) ? 1 : 0;
}
require(remaining_branch_slots == 0 && persisted_wire_cells == 1,
"using a distant branch cell must retain the skipped cell as a replaceable wire");
}

void testLogicParallelBranchConnectsShortBranchToRightJoin()
{
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);
std::vector<std::string> selected_ids;
for (int address = 0; address < 5; ++address)
{
const LogicEditorResult result = service.appendCondition(
logic_id, rung_id, logicContact(address));
require(result.succeeded,
"short parallel branch rendering setup must create the upper series");
selected_ids.push_back(result.id);
}
const LogicEditorResult branch = service.addParallelBranch(
logic_id, rung_id, selected_ids, logicContact(10));
require(branch.succeeded,
"parallel branch rendering setup must create a one-column lower branch");

LogicEditorWidget editor(service);
editor.setLogicId(logic_id);
editor.resize(1700, 480);
editor.show();
QApplication::processEvents();

// The lower one-column node must be visibly wired to the parallel right join
const qreal expected_lower_y = 28.0 + 52.0 + 1.5 * 120.0;
const qreal expected_branch_output_x = 68.0 + 128.0;
const qreal expected_right_join_x = 68.0 + 5.0 * 128.0;
bool has_right_join_wire = false;
for (QGraphicsItem *item : editor.scene()->items())
{
const QGraphicsLineItem *line = dynamic_cast<const QGraphicsLineItem *>(item);
if (line == nullptr || line->zValue() <= -1.0)
{
continue;
}
const QLineF segment = line->line();
if (qAbs(segment.y1() - expected_lower_y) < 0.1
&& qAbs(segment.y2() - expected_lower_y) < 0.1
&& qAbs(segment.x1() - expected_branch_output_x) < 0.1
&& qAbs(segment.x2() - expected_right_join_x) < 0.1)
{
has_right_join_wire = true;
break;
}
}
require(has_right_join_wire,
"a short parallel branch must connect its output to the right join");
}

void testLogicContinuousInsertionConsumesFullWire()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const std::string rung_id = makeEmptyRung(service, logic_id);
LogicEditorWidget editor(service);
editor.setLogicId(logic_id);
editor.resize(1400, 360);
@@ -1301,11 +1439,14 @@ void testModeActionsControlEditingAvailability()
QApplication::processEvents();
undo_action->trigger();
require(logic_editor_service.findLogic(logic_editor_service.firstLogicId())
->rungs.front().condition.has_value() == false,
->rungs.empty(),
"logic undo must use the ladder history when the ladder tab is active");
redo_action->trigger();
const LadderRung &restored_rung = logic_editor_service.findLogic(
logic_editor_service.firstLogicId())->rungs.front();
const ControlLogic *restored_logic = logic_editor_service.findLogic(
logic_editor_service.firstLogicId());
require(restored_logic != nullptr && restored_logic->rungs.size() == 1U,
"logic redo must restore the automatically created network");
const LadderRung &restored_rung = restored_logic->rungs.front();
require(restored_rung.condition.has_value(),
"logic redo must restore the ladder edit");
std::vector<const LogicNode *> restored_nodes;
@@ -2204,8 +2345,8 @@ void testMultiPageAndLogicMainWindowIntegration()
require(window.currentLogicId() == second_logic_id,
"selecting a logic tree node must change the current logic id");
requiredChild<QAction>(window, "addRungAction")->trigger();
require(logic_editor_service.findLogic(second_logic_id)->rungs.size() == 2U
&& logic_editor_service.findLogic(first_logic_id)->rungs.size() == 1U,
require(logic_editor_service.findLogic(second_logic_id)->rungs.size() == 1U
&& logic_editor_service.findLogic(first_logic_id)->rungs.empty(),
"logic toolbar actions must edit the selected logic module");

tree->setCurrentItem(tree->topLevelItem(0)->child(0));
@@ -2276,6 +2417,8 @@ int main(int argc, char *argv[])
testRuntimePageJumpDoesNotRequireRegisterWritePermission();
testRuntimeAlarmListInteraction();
testLogicEmptyGridSlotInsertion();
testLogicParallelPaddingGridInsertion();
testLogicParallelBranchConnectsShortBranchToRightJoin();
testLogicContinuousInsertionConsumesFullWire();
testWindowTitleTracksUnsavedProjectChanges();
testModeActionsControlEditingAvailability();


Načítá se…
Zrušit
Uložit