Parcourir la source

feat: 重构连续梯形图编辑与语法检查

main
suyu il y a 3 semaines
Parent
révision
4d44264d0a
39 fichiers modifiés avec 8625 ajouts et 9273 suppressions
  1. +352
    -552
      app/src/domain/control_logic_model.cpp
  2. +77
    -146
      app/src/domain/control_logic_model.h
  3. +23
    -22
      app/src/domain/project_limits.h
  4. +1
    -4
      app/src/domain/project_model.cpp
  5. +1
    -1
      app/src/domain/project_model.h
  6. +9
    -9
      app/src/domain/runtime_state.cpp
  7. +4
    -0
      app/src/domain/runtime_state.h
  8. +113
    -195
      app/src/infrastructure/json_project_storage.cpp
  9. +1
    -1
      app/src/infrastructure/json_project_storage.h
  10. +4
    -1
      app/src/main.cpp
  11. +54
    -61
      app/src/services/logic_command_service.cpp
  12. +3
    -3
      app/src/services/logic_command_service.h
  13. +2295
    -2541
      app/src/services/logic_editor_service.cpp
  14. +218
    -214
      app/src/services/logic_editor_service.h
  15. +1
    -4
      app/src/services/online_logic_monitor_service.cpp
  16. +1
    -1
      app/src/services/project_service.cpp
  17. +38
    -10
      app/src/services/runtime_mode_service.cpp
  18. +8
    -0
      app/src/services/runtime_mode_service.h
  19. +196
    -135
      app/src/services/software_logic_executor.cpp
  20. +8
    -24
      app/src/services/software_logic_executor.h
  21. +1952
    -2164
      app/src/ui/logic_editor_widget.cpp
  22. +73
    -111
      app/src/ui/logic_editor_widget.h
  23. +283
    -80
      app/src/ui/main_window.cpp
  24. +24
    -14
      app/src/ui/main_window.h
  25. +78
    -10
      app/src/ui/main_window.ui
  26. +2
    -4
      app/src/ui/runtime_monitor_widget.cpp
  27. +2
    -3
      app/src/ui/runtime_panel_controller.cpp
  28. +27
    -0
      app/src/ui/toolbar_icon_factory.cpp
  29. +3
    -0
      app/src/ui/toolbar_icon_factory.h
  30. +113
    -229
      app/tests/domain_tests.cpp
  31. +1308
    -1659
      app/tests/logic_editor_service_tests.cpp
  32. +226
    -124
      app/tests/offline_simulation_service_tests.cpp
  33. +14
    -10
      app/tests/performance_tests.cpp
  34. +12
    -2
      app/tests/plc_runtime_tests.cpp
  35. +2
    -0
      app/tests/plc_runtime_tests.pro
  36. +268
    -917
      app/tests/project_management_tests.cpp
  37. +115
    -19
      app/tests/runtime_mode_service_tests.cpp
  38. +2
    -0
      app/tests/runtime_mode_service_tests.pro
  39. +714
    -3
      app/tests/runtime_panel_controller_tests.cpp

+ 352
- 552
app/src/domain/control_logic_model.cpp
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 77
- 146
app/src/domain/control_logic_model.h Voir le fichier

@@ -9,253 +9,172 @@
#include <variant>
#include <vector>

// 普通触点的导通方式;常闭触点会对读取到的位值取反
enum class ContactMode
{
NormallyOpen, // 常开:位值为 1 时触点导通
NormallyClosed // 常闭:位值为 0 时触点导通
NormallyOpen,
NormallyClosed
};

// 线圈写入方式:普通写入、置位保持、复位清零
enum class CoilMode
{
Normal, // 普通写入:直接写入当前逻辑结果
Set, // 置位:逻辑结果为 1 时保持为 1
Reset // 复位:逻辑结果为 1 时清零
Normal,
Set,
Reset
};

// 上升沿/下降沿触点的边沿方向
enum class EdgeMode
{
Rising, // 上升沿:信号从 0 变成 1
Falling // 下降沿:信号从 1 变成 0
Rising,
Falling
};

// D 寄存器字比较,等于、不等于、大于、小于等 6 种比较
enum class ComparisonOperator
{
Equal, // 等于
NotEqual, // 不等于
LessThan, // 小于
LessThanOrEqual, // 小于或等于
GreaterThan, // 大于
GreaterThanOrEqual // 大于或等于
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual
};

// 字操作数可以来自常量,也可以来自 D 寄存器
enum class WordOperandKind
{
Constant, // 使用固定数值
Register // 使用 D 寄存器中的数值
Constant,
Register
};

// MOVE、ADD/SUB 共用的字操作数
struct WordOperand
{
WordOperandKind kind = WordOperandKind::Constant; // 操作数来源
RegisterAddress address{RegisterArea::D, 0}; // kind 为 Register 时使用的 D 地址
std::int16_t constant = 0; // kind 为 Constant 时使用的数值
WordOperandKind kind = WordOperandKind::Constant;
RegisterAddress address{RegisterArea::D, 0};
std::int16_t constant = 0;

// 常量始终有效;寄存器操作数必须是有效 D 地址
bool validate(std::string *error = nullptr) const;
};

// 普通 M 寄存器触点,地址 + 常开常闭
struct ContactNodeConfig
{
RegisterAddress address{RegisterArea::M, 0}; // 触点读取的 M 地址
ContactMode mode = ContactMode::NormallyOpen; // 常开或常闭
RegisterAddress address{RegisterArea::M, 0};
ContactMode mode = ContactMode::NormallyOpen;
};

// 边沿触点,地址 + 上升 / 下降沿
struct EdgeContactNodeConfig
{
// 用于检测 M 寄存器的上升沿或下降沿
RegisterAddress address{RegisterArea::M, 0};
EdgeMode mode = EdgeMode::Rising;
};

// 线圈输出配置,决定如何写入 M 寄存器
struct CoilNodeConfig
{
// 线圈要写入的 M 寄存器地址
RegisterAddress address{RegisterArea::M, 0};
CoilMode mode = CoilMode::Normal;
};

// D 寄存器比较配置
struct CompareNodeConfig
{
// 要参与比较的 D 寄存器地址
RegisterAddress address{RegisterArea::D, 0};
// 比较运算符
ComparisonOperator comparison = ComparisonOperator::Equal;
// 与寄存器值比较的常量
std::int16_t value = 0;
};

// MOVE 输出:把一个字操作数写入目标 D 地址
struct MoveNodeConfig
{
WordOperand source; // 要写入的源操作数
RegisterAddress destination{RegisterArea::D, 0}; // 接收数据的 D 地址
WordOperand source;
RegisterAddress destination{RegisterArea::D, 0};
};

enum class ArithmeticOperation
{
Add, // 加法
Subtract // 减法
Add,
Subtract
};

// ADD/SUB 输出:计算两个字操作数并写入目标 D 地址
struct ArithmeticNodeConfig
{
ArithmeticOperation operation = ArithmeticOperation::Add; // 加法或减法
WordOperand left; // 左操作数
WordOperand right; // 右操作数
RegisterAddress destination{RegisterArea::D, 0}; // 运算结果写入的 D 地址
ArithmeticOperation operation = ArithmeticOperation::Add;
WordOperand left;
WordOperand right;
RegisterAddress destination{RegisterArea::D, 0};
};

// 所有梯形图指令的强类型配置联合,避免用无关字段拼装指令
using LogicNodeConfig = std::variant<
ContactNodeConfig, // 普通常开或常闭触点
EdgeContactNodeConfig, // 上升沿或下降沿触点
CoilNodeConfig, // 普通、置位或复位线圈
CompareNodeConfig, // D 寄存器比较节点
MoveNodeConfig, // MOVE 数据传送输出
ArithmeticNodeConfig>; // ADD 或 SUB 算术输出
ContactNodeConfig,
EdgeContactNodeConfig,
CoilNodeConfig,
CompareNodeConfig,
MoveNodeConfig,
ArithmeticNodeConfig>;

// 返回节点最主要的 M/D 地址;复合输出请使用下面的收集函数
std::optional<RegisterAddress> registerAddressForLogicNode(
const LogicNodeConfig &config);
// 收集节点中所有显式 M/D 引用,用于 PLC 轮询集合构建
void collectRegisterAddressesForLogicNode(
const LogicNodeConfig &config,
std::vector<RegisterAddress> *addresses);

// 表达式中的一个指令节点;configured=false 表示编辑中的未完成草稿
struct LogicNode
{
std::string id; // 节点唯一 ID
LogicNodeConfig config; // 节点的具体指令配置
bool configured = true; // 是否已完成配置
std::string id;
LogicNodeConfig config;
bool configured = true;

// 校验节点 ID、配置类型和配置内容
bool validate(std::string *error = nullptr) const;
bool isConfigured() const;
// 判断节点能否放在条件区或固定输出槽
bool isCondition() const;
bool isOutput() const;
};

enum class ConditionExpressionKind
{
Node, // 一个实际的条件节点
Wire, // 一段横线
Gap, // 一段明确断开的空白网格
Series, // 多个条件串联,必须全部满足
Parallel // 多个条件并联,满足任意一条即可
};

// 条件区中的持久化横线;columnSpan 表示跨越的网格列数
struct WireSegment
enum class LadderCellKind
{
static constexpr int kMinimumColumnSpan = 1;
static constexpr int kMaximumColumnSpan =
ProjectLimits::kMaximumConditionColumns;

int columnSpan = 1; // 横线占用的网格列数

bool validate(std::string *error = nullptr) const;
Gap,
Wire,
Node
};

// 条件区中的持久化断路;columnSpan 表示连续空白网格列数
struct GapSegment
// 条件区的一个固定网格,横线和空白与条件节点具有同等持久化地位
struct LadderCell
{
static constexpr int kMinimumColumnSpan = 1;
static constexpr int kMaximumColumnSpan =
ProjectLimits::kMaximumConditionColumns;

int columnSpan = 1; // 断路占用的网格列数
std::string id;
LadderCellKind kind = LadderCellKind::Gap;
std::optional<LogicNode> node;

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

// 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接
struct ConditionExpression
// 两个相邻视觉行之间、指定列边界上的一段竖线
struct VerticalConnection
{
std::string id; // 表达式唯一 ID
ConditionExpressionKind kind = ConditionExpressionKind::Node; // 表达式类型
std::optional<LogicNode> node; // kind 为 Node 时保存的节点
std::optional<WireSegment> wire; // kind 为 Wire 时保存的横线
std::optional<GapSegment> gap; // kind 为 Gap 时保存的断路
std::vector<ConditionExpression> children; // 串联或并联的子表达式
std::string id;
std::string upperRungId;
std::string lowerRungId;
int columnBoundary = 0;

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

// 删除节点后整理表达式结构
void normalizeConditionExpression(std::optional<ConditionExpression> *expression);

// 按节点 ID 查找条件节点,只读版本
const LogicNode *findConditionNode(
const ConditionExpression &expression, const std::string &node_id);

// 按节点 ID 查找条件节点,可修改版本
LogicNode *findConditionNode(
ConditionExpression &expression, const std::string &node_id);

// 按表达式 ID 查找子表达式,只读版本
const ConditionExpression *findConditionExpression(
const ConditionExpression &expression, const std::string &expression_id);

// 按表达式 ID 查找子表达式,可修改版本
ConditionExpression *findConditionExpression(
ConditionExpression &expression, const std::string &expression_id);

// 收集表达式中的所有条件节点
void collectConditionNodes(
const ConditionExpression &expression, std::vector<const LogicNode *> *nodes);

// 收集表达式和子表达式的所有 ID
void collectConditionExpressionIds(
const ConditionExpression &expression, std::vector<std::string> *ids);

// 输出指令固定在第 11 列;有输出时前 10 列必须由显式条件、横线或断路占满
// 连续梯形图的一条视觉行,不再是隔离的网络容器
struct LadderRung
{
std::string id; // 网络唯一 ID
std::string name; // 网络名称
std::string comment; // 网络注释
std::optional<ConditionExpression> condition; // 条件区表达式;空值只用于空网络草稿
std::optional<LogicNode> output; // 网络右侧的输出指令
std::string id;
std::string name;
std::string comment;
std::optional<LogicNode> output;
std::vector<LadderCell> cells;

// 编辑态允许没有条件或输出,便于逐步搭建网络
bool validate(std::string *error = nullptr) const;
// 只检查表达式拓扑、列数和输出位置
bool validateStructure(std::string *error = nullptr) const;
// 运行态要求有输出且所有引用都已配置
bool validateForRunning(std::string *error = nullptr) const;
};

// 一组按顺序扫描的梯形图网络;enabled=false 时运行校验会跳过它
// 一张连续梯形图,网络由横竖连接关系自然形成
struct ControlLogic
{
std::string id; // 控制逻辑唯一 ID
std::string name; // 控制逻辑名称
std::vector<LadderRung> rungs; // 很多条 LadderRung(梯形图网络/梯级)
bool enabled = true; // 是否参与运行扫描
std::string id;
std::string name;
std::vector<LadderRung> rungs;
bool enabled = true;
std::vector<VerticalConnection> verticalConnections;

// 校验可保存结构
bool validate(std::string *error = nullptr) const;
bool validate(
const ProjectLimitSettings &limits,
@@ -264,9 +183,21 @@ struct ControlLogic
bool validateStructure(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
// 校验运行所需资源和网络配置
bool validateForRunning(std::string *error = nullptr) const;
bool validateForRunning(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
};

const LadderCell *findLadderCell(
const LadderRung &rung, const std::string &cell_id);
LadderCell *findLadderCell(
LadderRung &rung, const std::string &cell_id);
const VerticalConnection *findVerticalConnection(
const ControlLogic &logic, const std::string &connection_id);
VerticalConnection *findVerticalConnection(
ControlLogic &logic, const std::string &connection_id);
void collectConditionNodes(
const LadderRung &rung, std::vector<const LogicNode *> *nodes);
void collectLogicNodes(
const ControlLogic &logic, std::vector<const LogicNode *> *nodes);

+ 23
- 22
app/src/domain/project_limits.h Voir le fichier

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

#include <cstddef>

// 所有跨层共享的数量和范围边界集中在这里,避免 UI、服务和 JSON 各自写数字
// 程序允许的绝对硬上限统一放在这里,避免 UI、服务和文件读写各自使用不同数字
// application.ini 只能收紧其中支持配置的上限,不能突破这些硬上限
namespace ProjectLimits {

constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; // 一个工程文件最大 16 MiB,防止异常大文件占满内存
@@ -13,16 +14,14 @@ constexpr std::size_t kMaximumHmiControlsPerProject = 2048U; // 一个工程最
constexpr std::size_t kMaximumAlarmDefinitions = 256U; // 一个工程最多配置 256 条报警
constexpr std::size_t kMaximumRegisterComments = 8002U; // M0~M4000 和 D0~D4000 最多各写一条注释
constexpr std::size_t kMaximumControlLogics = 32U; // 一个工程最多放 32 组梯形图控制逻辑
constexpr std::size_t kMaximumRungsPerLogic = 256U; // 一组控制逻辑最多放 256 个网络
constexpr std::size_t kMaximumRungsPerProject = 2048U; // 一个工程最多保存 2048 个网络

constexpr std::size_t kMaximumExpressionNodesPerRung = 1024U; // 一个网络里的触点、横线和内部结构最多共 1024 个
constexpr std::size_t kMaximumExpressionDepth = 12U; // 并联套并联时最多套 12 层,防止结构无限变复杂
constexpr std::size_t kMaximumExpressionChildren = 64U; // 一个串联组或并联组最多放 64 个子项
constexpr int kMaximumConditionColumns = 10; // 一个网络前面的条件区最多 10 列
constexpr int kMaximumLadderColumns = 11; // 一个网络总共 11 列,最后 1 列专门放输出
constexpr int kMaximumLogicRows = 64; // 一个网络上下最多 64 行并联支路

constexpr std::size_t kMaximumRungsPerLogic = 256U; // 一组控制逻辑最多放 256 行
constexpr std::size_t kMaximumRungsPerProject = 2048U; // 一个工程最多保存 2048 行

constexpr int kMaximumConditionColumns = 10; // 每行固定 10 个条件网格
constexpr int kMaximumLadderColumns = 11; // 第 11 列固定用于输出
constexpr std::size_t kMaximumVerticalConnectionsPerLogic =
(kMaximumRungsPerLogic - 1U)
* static_cast<std::size_t>(kMaximumConditionColumns + 1); // 一组控制逻辑最多保存的竖线总数
static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列

constexpr std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性
@@ -65,34 +64,36 @@ constexpr int kMaximumPollIntervalMs = 10000; // PLC 最慢每 10 秒轮询一
constexpr std::size_t kMaximumPendingWrites = 1U; // 同时只允许等待 1 个 PLC 写入,避免写入顺序混乱
constexpr int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条

}
} // namespace ProjectLimits

// 用户可调的工程数量上限;默认值等于代码定义的绝对硬上限
// 本次程序实际使用的可配置上限;默认采用代码硬上限,INI 只能将它们调小
struct ProjectLimitSettings
{
std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages;
std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; // 一个工程允许的 HMI 页面数
std::size_t maximumHmiControlsPerPage =
ProjectLimits::kMaximumHmiControlsPerPage;
ProjectLimits::kMaximumHmiControlsPerPage; // 每个 HMI 页面允许的控件数
std::size_t maximumAlarmDefinitions =
ProjectLimits::kMaximumAlarmDefinitions;
std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics;
std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic;
int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages;
ProjectLimits::kMaximumAlarmDefinitions; // 一个工程允许的报警数
std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics; // 一个工程允许的控制逻辑组数
std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic; // 每组控制逻辑允许的梯形图行数
int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages; // 输出面板最多保留的消息数
};

// 新建 HMI 页面使用的默认尺寸;页面结构安全范围仍由上方硬限制控
// 新建 HMI 页面使用的默认尺寸;不会修改已有页面,也不能超出上面的宽高硬限
struct HmiDefaultSettings
{
int pageWidth = ProjectLimits::kDefaultHmiPageWidth;
int pageHeight = ProjectLimits::kDefaultHmiPageHeight;
int pageWidth = ProjectLimits::kDefaultHmiPageWidth; // 新建页面的默认宽度
int pageHeight = ProjectLimits::kDefaultHmiPageHeight; // 新建页面的默认高度
};

// 返回长期有效的默认上限,供没有传入 INI 配置的代码使用
inline const ProjectLimitSettings &defaultProjectLimitSettings()
{
static const ProjectLimitSettings settings;
return settings;
}

// 返回长期有效的默认页面尺寸,供没有传入 INI 配置的代码使用
inline const HmiDefaultSettings &defaultHmiSettings()
{
static const HmiDefaultSettings settings;


+ 1
- 4
app/src/domain/project_model.cpp Voir le fichier

@@ -401,10 +401,7 @@ bool Project::validate(
for (const LadderRung &rung : logic.rungs)
{
std::vector<const LogicNode *> nodes;
if (rung.condition.has_value())
{
collectConditionNodes(*rung.condition, &nodes);
}
collectConditionNodes(rung, &nodes);
if (rung.output.has_value())
{
nodes.push_back(&*rung.output);


+ 1
- 1
app/src/domain/project_model.h Voir le fichier

@@ -26,7 +26,7 @@ struct ProjectMetadata
// 工程显示名称
std::string name;
// 工程文件格式版本
std::string formatVersion = "1.0";
std::string formatVersion = "2.0";
};

// 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑


+ 9
- 9
app/src/domain/runtime_state.cpp Voir le fichier

@@ -15,41 +15,41 @@ ModeTransitionResult RuntimeState::enterEditing()
// 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点
if (mode_ == ApplicationMode::Editing)
{
return {false, ModeTransitionError::AlreadyInRequestedMode};
return {false, ModeTransitionError::AlreadyInRequestedMode, {}};
}
mode_ = ApplicationMode::Editing;
return {true, ModeTransitionError::None};
return {true, ModeTransitionError::None, {}};
}

ModeTransitionResult RuntimeState::enterOfflineRunning()
{
if (mode_ == ApplicationMode::OfflineRunning)
{
return {false, ModeTransitionError::AlreadyInRequestedMode};
return {false, ModeTransitionError::AlreadyInRequestedMode, {}};
}
if (mode_ != ApplicationMode::Editing)
{
return {false, ModeTransitionError::MustReturnToEditing};
return {false, ModeTransitionError::MustReturnToEditing, {}};
}
mode_ = ApplicationMode::OfflineRunning;
return {true, ModeTransitionError::None};
return {true, ModeTransitionError::None, {}};
}

ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_completed)
{
if (mode_ == ApplicationMode::OnlineRunning)
{
return {false, ModeTransitionError::AlreadyInRequestedMode};
return {false, ModeTransitionError::AlreadyInRequestedMode, {}};
}
if (mode_ != ApplicationMode::Editing)
{
return {false, ModeTransitionError::MustReturnToEditing};
return {false, ModeTransitionError::MustReturnToEditing, {}};
}
if (!initial_plc_read_completed)
{
// 未读取 PLC 时禁止进入真机态,防止用未知缓存值驱动界面
return {false, ModeTransitionError::InitialPlcReadRequired};
return {false, ModeTransitionError::InitialPlcReadRequired, {}};
}
mode_ = ApplicationMode::OnlineRunning;
return {true, ModeTransitionError::None};
return {true, ModeTransitionError::None, {}};
}

+ 4
- 0
app/src/domain/runtime_state.h Voir le fichier

@@ -1,5 +1,7 @@
#pragma once

#include <string>

// 应用当前运行模式;三个状态之间不能直接从离线切到真机
enum class ApplicationMode
{
@@ -86,6 +88,8 @@ struct ModeTransitionResult
bool succeeded = false;
// 切换失败原因
ModeTransitionError error = ModeTransitionError::None;
// 服务层补充的具体失败原因,状态机自身可以留空
std::string detail;
};

// 保存当前模式并执行最基本的状态机约束


+ 113
- 195
app/src/infrastructure/json_project_storage.cpp Voir le fichier

@@ -18,7 +18,7 @@
namespace {

// 当前读写实现支持的工程文件格式版本
constexpr const char *kCurrentFormatVersion = "1.0";
constexpr const char *kCurrentFormatVersion = "2.0";

// 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因
struct ParseState
@@ -1283,185 +1283,30 @@ bool parseLogicNode(
return true;
}

QString expressionKindText(ConditionExpressionKind kind)
{
switch (kind)
{
case ConditionExpressionKind::Node:
return QStringLiteral("node");
case ConditionExpressionKind::Wire:
return QStringLiteral("wire");
case ConditionExpressionKind::Gap:
return QStringLiteral("gap");
case ConditionExpressionKind::Series:
return QStringLiteral("series");
case ConditionExpressionKind::Parallel:
return QStringLiteral("parallel");
}
return {};
}

QJsonObject serializeConditionExpression(const ConditionExpression &expression)
{
QJsonObject object;
object.insert(QStringLiteral("id"), fromUtf8(expression.id));
object.insert(QStringLiteral("kind"), expressionKindText(expression.kind));
if (expression.kind == ConditionExpressionKind::Node)
{
object.insert(QStringLiteral("node"), serializeLogicNode(*expression.node));
}
else if (expression.kind == ConditionExpressionKind::Wire)
{
object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan);
}
else if (expression.kind == ConditionExpressionKind::Gap)
{
object.insert(QStringLiteral("columnSpan"), expression.gap->columnSpan);
}
else
{
QJsonArray children;
for (const ConditionExpression &child : expression.children)
{
children.append(serializeConditionExpression(child));
}
object.insert(QStringLiteral("children"), children);
}
return object;
}

bool parseConditionExpression(
const QJsonObject &object,
const std::string &context,
ConditionExpression *expression,
ParseState *state,
std::size_t depth,
std::size_t *node_count)
{
if (depth > ProjectLimits::kMaximumExpressionDepth)
{
return state->fail(
ProjectStorageError::InvalidField,
context + " 的嵌套深度超过 12 层");
}
++*node_count;
if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung)
{
return state->fail(
ProjectStorageError::InvalidField,
context + " 的表达式节点总数超过 1024 个");
}
std::string kind;
if (!readString(
object, "id", context, &expression->id, state,
ProjectLimits::kMaximumIdBytes)
|| !readString(object, "kind", context, &kind, state))
{
return false;
}
if (kind == "node")
{
QJsonObject node;
if (!readObject(object, "node", context, &node, state))
{
return false;
}
LogicNode parsed_node;
if (!parseLogicNode(node, context + ".node", &parsed_node, state))
{
return false;
}
expression->kind = ConditionExpressionKind::Node;
expression->node = std::move(parsed_node);
return true;
}
if (kind == "wire")
{
int column_span = 0;
if (!readInt(
object,
"columnSpan",
context,
WireSegment::kMinimumColumnSpan,
WireSegment::kMaximumColumnSpan,
&column_span,
state))
{
return false;
}
expression->kind = ConditionExpressionKind::Wire;
expression->wire = WireSegment{column_span};
return true;
}
if (kind == "gap")
{
int column_span = 0;
if (!readInt(
object,
"columnSpan",
context,
GapSegment::kMinimumColumnSpan,
GapSegment::kMaximumColumnSpan,
&column_span,
state))
{
return false;
}
expression->kind = ConditionExpressionKind::Gap;
expression->gap = GapSegment{column_span};
return true;
}
if (kind != "series" && kind != "parallel")
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".kind 必须是 node、wire、gap、series 或 parallel");
}
QJsonArray children;
if (!readArray(
object, "children", context, &children, state,
static_cast<int>(ProjectLimits::kMaximumExpressionChildren)))
{
return false;
}
expression->kind = kind == "series"
? ConditionExpressionKind::Series : ConditionExpressionKind::Parallel;
expression->children.reserve(static_cast<std::size_t>(children.size()));
for (int index = 0; index < children.size(); ++index)
{
if (!children.at(index).isObject())
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".children 的元素必须是对象");
}
ConditionExpression child;
if (!parseConditionExpression(
children.at(index).toObject(),
context + ".children[" + std::to_string(index) + ']',
&child,
state,
depth + 1U,
node_count))
{
return false;
}
expression->children.push_back(std::move(child));
}
return true;
}

QJsonObject serializeLadderRung(const LadderRung &rung)
{
QJsonObject object;
object.insert(QStringLiteral("id"), fromUtf8(rung.id));
object.insert(QStringLiteral("name"), fromUtf8(rung.name));
object.insert(QStringLiteral("comment"), fromUtf8(rung.comment));
object.insert(
QStringLiteral("condition"),
rung.condition.has_value()
? QJsonValue(serializeConditionExpression(*rung.condition))
: QJsonValue(QJsonValue::Null));
QJsonArray cells;
for (const LadderCell &cell : rung.cells)
{
QJsonObject cell_object;
cell_object.insert(QStringLiteral("id"), fromUtf8(cell.id));
cell_object.insert(
QStringLiteral("kind"),
cell.kind == LadderCellKind::Node
? QStringLiteral("node")
: cell.kind == LadderCellKind::Wire
? QStringLiteral("wire") : QStringLiteral("gap"));
if (cell.node.has_value())
{
cell_object.insert(QStringLiteral("node"), serializeLogicNode(*cell.node));
}
cells.append(cell_object);
}
object.insert(QStringLiteral("cells"), cells);
object.insert(
QStringLiteral("output"),
rung.output.has_value() ? QJsonValue(serializeLogicNode(*rung.output))
@@ -1475,42 +1320,71 @@ bool parseLadderRung(
LadderRung *rung,
ParseState *state)
{
QJsonValue condition;
QJsonArray cells;
QJsonValue output;
if (!readString(
object, "id", context, &rung->id, state,
ProjectLimits::kMaximumIdBytes)
|| !readString(object, "name", context, &rung->name, state)
|| !readString(object, "comment", context, &rung->comment, state)
|| !readArray(object, "cells", context, &cells, state,
ProjectLimits::kMaximumConditionColumns)
|| !readValue(object, "output", context, &output, state))
{
return false;
}
if (!readValue(object, "condition", context, &condition, state))
{
return false;
}
if (condition.isNull())
{
rung->condition.reset();
}
else if (!condition.isObject())
if (cells.size() != ProjectLimits::kMaximumConditionColumns)
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".condition 必须是对象或 null");
return state->fail(ProjectStorageError::InvalidField,
context + ".cells 必须严格包含 10 个网格");
}
else
for (int index = 0; index < cells.size(); ++index)
{
ConditionExpression parsed_condition;
std::size_t expression_node_count = 0U;
if (!parseConditionExpression(
condition.toObject(), context + ".condition", &parsed_condition,
state, 1U, &expression_node_count))
if (!cells.at(index).isObject())
{
return state->fail(ProjectStorageError::InvalidField,
context + ".cells 的元素必须是对象");
}
const QJsonObject cell_object = cells.at(index).toObject();
const std::string cell_context = context + ".cells["
+ std::to_string(index) + ']';
LadderCell cell;
std::string kind;
if (!readString(cell_object, "id", cell_context, &cell.id, state,
ProjectLimits::kMaximumIdBytes)
|| !readString(cell_object, "kind", cell_context, &kind, state))
{
return false;
}
rung->condition = std::move(parsed_condition);
if (kind == "gap")
{
cell.kind = LadderCellKind::Gap;
}
else if (kind == "wire")
{
cell.kind = LadderCellKind::Wire;
}
else if (kind == "node")
{
cell.kind = LadderCellKind::Node;
QJsonObject node_object;
if (!readObject(cell_object, "node", cell_context, &node_object, state))
{
return false;
}
LogicNode node;
if (!parseLogicNode(node_object, cell_context + ".node", &node, state))
{
return false;
}
cell.node = std::move(node);
}
else
{
return state->fail(ProjectStorageError::InvalidField,
cell_context + ".kind 必须是 gap、wire 或 node");
}
rung->cells.push_back(std::move(cell));
}
if (output.isNull())
{
@@ -1539,11 +1413,22 @@ QJsonObject serializeControlLogic(const ControlLogic &logic)
{
rungs.append(serializeLadderRung(rung));
}
QJsonArray connections;
for (const VerticalConnection &connection : logic.verticalConnections)
{
QJsonObject item;
item.insert(QStringLiteral("id"), fromUtf8(connection.id));
item.insert(QStringLiteral("upperRungId"), fromUtf8(connection.upperRungId));
item.insert(QStringLiteral("lowerRungId"), fromUtf8(connection.lowerRungId));
item.insert(QStringLiteral("columnBoundary"), connection.columnBoundary);
connections.append(item);
}
QJsonObject object;
object.insert(QStringLiteral("id"), fromUtf8(logic.id));
object.insert(QStringLiteral("name"), fromUtf8(logic.name));
object.insert(QStringLiteral("enabled"), logic.enabled);
object.insert(QStringLiteral("rungs"), rungs);
object.insert(QStringLiteral("verticalConnections"), connections);
return object;
}

@@ -1555,6 +1440,7 @@ bool parseControlLogic(
ParseState *state)
{
QJsonArray rungs;
QJsonArray connections;
if (!readString(
object, "id", context, &logic->id, state,
ProjectLimits::kMaximumIdBytes)
@@ -1562,7 +1448,10 @@ bool parseControlLogic(
|| !readBool(object, "enabled", context, &logic->enabled, state)
|| !readArray(
object, "rungs", context, &rungs, state,
static_cast<int>(limits.maximumRungsPerLogic)))
static_cast<int>(limits.maximumRungsPerLogic))
|| !readArray(
object, "verticalConnections", context, &connections, state,
static_cast<int>(ProjectLimits::kMaximumVerticalConnectionsPerLogic)))
{
return false;
}
@@ -1586,6 +1475,35 @@ bool parseControlLogic(
}
logic->rungs.push_back(std::move(rung));
}
for (int index = 0; index < connections.size(); ++index)
{
if (!connections.at(index).isObject())
{
return state->fail(ProjectStorageError::InvalidField,
context + ".verticalConnections 的元素必须是对象");
}
const QJsonObject item = connections.at(index).toObject();
VerticalConnection connection;
int boundary = 0;
const std::string item_context = context + ".verticalConnections["
+ std::to_string(index) + ']';
if (!readString(item, "id", item_context, &connection.id, state,
ProjectLimits::kMaximumIdBytes)
|| !readString(item, "upperRungId", item_context,
&connection.upperRungId, state,
ProjectLimits::kMaximumIdBytes)
|| !readString(item, "lowerRungId", item_context,
&connection.lowerRungId, state,
ProjectLimits::kMaximumIdBytes)
|| !readInt(item, "columnBoundary", item_context, 0,
ProjectLimits::kMaximumConditionColumns,
&boundary, state))
{
return false;
}
connection.columnBoundary = boundary;
logic->verticalConnections.push_back(std::move(connection));
}
return true;
}



+ 1
- 1
app/src/infrastructure/json_project_storage.h Voir le fichier

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

#include "domain/project_storage.h"

// 严格读写当前 1.0 JSON 工程格式
// 严格读写当前 2.0 JSON 工程格式
class JsonProjectStorage final : public ProjectStorage
{
public:


+ 4
- 1
app/src/main.cpp Voir le fichier

@@ -141,7 +141,10 @@ int main(int argc, char *argv[])
OnlineLogicMonitorService online_logic_monitor_service(plc_register_repository);
// 管理三种运行模式,并控制离线仿真和真机只读轨迹的生命周期
RuntimeModeService runtime_mode_service(
project_service, offline_simulation_service, online_logic_monitor_service);
project_service,
logic_editor_service,
offline_simulation_service,
online_logic_monitor_service);
// 把 PLC 通信和两套寄存器接入运行模式,切换模式时才能切换数据来源
runtime_mode_service.configurePlc(
plc_communication_service,


+ 54
- 61
app/src/services/logic_command_service.cpp Voir le fichier

@@ -201,7 +201,7 @@ bool parseInt16(
LogicCommandResult executionFailure(
const std::string &message, LogicCommandOpcode opcode)
{
return {false, message, {}, {}, opcode};
return {false, message, {}, {}, opcode, {}, false};
}

} // namespace
@@ -425,6 +425,8 @@ LogicCommandResult LogicCommandService::execute(
}
const LogicCommandOpcode opcode = parsed.command.opcode;
LogicEditorResult edited;
LogicEditCursor next_cursor;
bool has_next_cursor = false;

if (request.target.kind == LogicCommandTargetKind::ExistingNode)
{
@@ -439,7 +441,7 @@ LogicCommandResult LogicCommandService::execute(
if (!isLoad(opcode))
{
return executionFailure(
"已有条件节点只能替换为 LD/LDI/LDP/LDF 指令", opcode);
"已有条件节点只能替换为触点或比较指令", opcode);
}
}
else if (existing->isOutput())
@@ -461,12 +463,7 @@ LogicCommandResult LogicCommandService::execute(
}
else if (isCondition(opcode))
{
if (isLoad(opcode) && request.continuing)
{
edited = editor_service_.appendCondition(
request.logicId, {}, parsed.command.config, true);
}
else if (isOr(opcode))
if (isOr(opcode))
{
const std::string rung_id = request.continuing
? request.currentRungId : request.target.rungId;
@@ -489,20 +486,19 @@ LogicCommandResult LogicCommandService::execute(
parsed.command.config,
true);
}
}
else if (request.continuing)
{
const LadderRung *rung = editor_service_.findRung(
request.logicId, request.currentRungId);
if (rung != nullptr && rung->output.has_value())
if (edited.succeeded)
{
return executionFailure(
"当前网络已有输出,下一条只能输入 LD/LDI/LDP/LDF 新建网络",
opcode);
const std::string edited_rung = editor_service_.rungIdForNode(
request.logicId, edited.id);
const int next_column = std::min(
request.target.column + 1,
ProjectLimits::kMaximumConditionColumns);
next_cursor = {
edited_rung.empty() ? rung_id : edited_rung,
next_column,
next_column == ProjectLimits::kMaximumConditionColumns};
has_next_cursor = true;
}
edited = editor_service_.appendCondition(
request.logicId, request.currentRungId,
parsed.command.config, true);
}
else
{
@@ -513,46 +509,35 @@ LogicCommandResult LogicCommandService::execute(
}
if ((opcode == LogicCommandOpcode::And
|| opcode == LogicCommandOpcode::AndInverse)
&& (!request.target.rungId.empty()
&& (editor_service_.findRung(
request.logicId, request.target.rungId) == nullptr
|| !editor_service_.findRung(
request.logicId, request.target.rungId)
->condition.has_value())))
&& !request.target.rungId.empty())
{
return executionFailure(
"AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode);
}
switch (request.target.kind)
{
case LogicCommandTargetKind::EmptyColumn:
edited = editor_service_.insertConditionAtColumn(
request.logicId, request.target.rungId,
request.target.column, parsed.command.config, true);
break;
case LogicCommandTargetKind::BranchEmptyColumn:
edited = editor_service_.insertConditionInBranchAtColumn(
request.logicId, request.target.rungId,
request.target.expressionId, request.target.column,
parsed.command.config, true);
break;
case LogicCommandTargetKind::WireColumn:
edited = editor_service_.replaceWireColumnWithCondition(
request.logicId, request.target.rungId,
request.target.expressionId, request.target.column,
parsed.command.config, true);
break;
case LogicCommandTargetKind::GapColumn:
edited = editor_service_.replaceGapColumnWithCondition(
request.logicId, request.target.rungId,
request.target.expressionId, request.target.column,
parsed.command.config, true);
break;
case LogicCommandTargetKind::Output:
break;
case LogicCommandTargetKind::ExistingNode:
break;
const LadderRung *target_rung = editor_service_.findRung(
request.logicId, request.target.rungId);
const bool has_condition = target_rung != nullptr
&& std::any_of(
target_rung->cells.cbegin(), target_rung->cells.cend(),
[](const LadderCell &cell)
{
return cell.kind == LadderCellKind::Node;
});
if (!has_condition)
{
return executionFailure(
"AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode);
}
}
const LogicEditResult applied =
editor_service_.applyConditionAndAdvance(
request.logicId,
{request.continuing
? request.currentRungId : request.target.rungId,
request.target.column,
false},
parsed.command.config,
true);
edited = applied.edit;
next_cursor = applied.nextCursor;
has_next_cursor = edited.succeeded;
}
}
else
@@ -573,8 +558,14 @@ LogicCommandResult LogicCommandService::execute(
"当前网络已经有输出,下一条请输入 LD/LDI/LDP/LDF 新建网络",
opcode);
}
edited = editor_service_.setOutput(
request.logicId, rung_id, parsed.command.config, true);
const LogicEditResult applied = editor_service_.applyOutputAndAdvance(
request.logicId,
{rung_id, ProjectLimits::kMaximumConditionColumns, true},
parsed.command.config,
true);
edited = applied.edit;
next_cursor = applied.nextCursor;
has_next_cursor = edited.succeeded;
}

if (!edited.succeeded)
@@ -584,5 +575,7 @@ LogicCommandResult LogicCommandService::execute(
return {
true, {}, edited.id,
editor_service_.rungIdForNode(request.logicId, edited.id),
opcode};
opcode,
std::move(next_cursor),
has_next_cursor};
}

+ 3
- 3
app/src/services/logic_command_service.h Voir le fichier

@@ -1,12 +1,10 @@
#pragma once

#include "domain/control_logic_model.h"
#include "logic_editor_service.h"

#include <string>
#include <vector>

class LogicEditorService;

enum class LogicCommandOpcode
{
Load,
@@ -88,6 +86,8 @@ struct LogicCommandResult
std::string id;
std::string rungId;
LogicCommandOpcode opcode = LogicCommandOpcode::Load;
LogicEditCursor nextCursor;
bool hasNextCursor = false;
};

// 将单条命令语解析并原子转换为现有结构化梯形图编辑操作


+ 2295
- 2541
app/src/services/logic_editor_service.cpp
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 218
- 214
app/src/services/logic_editor_service.h Voir le fichier

@@ -9,357 +9,361 @@

class ProjectService;

// 梯形图编辑失败分类
enum class LogicEditorError
{
None, // 操作成功或没有错误
LogicNotFound, // 控制逻辑不存在
RungNotFound, // 网络不存在
ExpressionNotFound, // 条件表达式不存在
NodeNotFound, // 节点不存在
InvalidNode, // 节点配置或节点位置无效
InvalidOperation, // 操作参数或当前结构不允许该操作
UnsupportedNodeChange, // 不允许把条件节点改成输出节点,或反向修改
DuplicateName, // 控制逻辑名称与其他逻辑重复
LastLogicRequired // 删除后不能少于一个控制逻辑
None,
LogicNotFound,
RungNotFound,
CellNotFound,
ConnectionNotFound,
NodeNotFound,
InvalidNode,
InvalidOperation,
UnsupportedNodeChange,
DuplicateName,
LastLogicRequired
};

// 梯形图编辑结果;成功时 id 通常是新建或更新对象的稳定 ID
struct LogicEditorResult
{
bool succeeded = false; // 操作是否成功
LogicEditorError error = LogicEditorError::None; // 失败时的分类
std::string message; // 面向用户的 UTF-8 成功说明或失败原因
std::string id; // 成功时返回新建或更新对象的稳定 ID
bool succeeded = false;
LogicEditorError error = LogicEditorError::None;
std::string message;
std::string id;
};

enum class LogicConditionPasteTargetKind
struct LogicSelectionDeleteRequest
{
Append,
EmptyColumn,
BranchEmptyColumn,
AfterNode,
ReplaceWire,
ReplaceWireColumn,
ReplaceGapColumn
std::vector<std::pair<std::string, int>> cells;
std::vector<std::string> outputRungIds;
std::vector<std::string> verticalConnectionIds;
};

struct LogicConditionPasteTarget
enum class LogicClipboardMode
{
LogicConditionPasteTargetKind kind = LogicConditionPasteTargetKind::Append;
std::string expressionId;
GridObjects,
WholeRows
};

struct LogicClipboardCell
{
int relativeRow = 0;
int relativeColumn = 0;
LadderCellKind kind = LadderCellKind::Gap;
std::optional<LogicNode> node;
};

struct LogicClipboardOutput
{
int relativeRow = 0;
int relativeColumn = 0;
LogicNode node;
};

struct LogicClipboardVerticalConnection
{
int upperRelativeRow = 0;
int relativeColumnBoundary = 0;
};

struct LogicClipboardRow
{
std::string comment;
std::vector<LadderCell> cells;
std::optional<LogicNode> output;
};

// 普通片段只保存选中的对象,整行片段才保存 Gap 和网络注释
struct LogicClipboardFragment
{
LogicClipboardMode mode = LogicClipboardMode::GridObjects;
std::vector<LogicClipboardCell> cells;
std::vector<LogicClipboardOutput> outputs;
std::vector<LogicClipboardVerticalConnection> verticalConnections;
std::vector<LogicClipboardRow> rows;
int rowSpan = 0;
int columnSpan = 0;
};

struct LogicSelectionCopyRequest
{
std::vector<std::pair<std::string, int>> cells;
std::vector<std::string> outputRungIds;
std::vector<std::string> verticalConnectionIds;
std::vector<std::string> wholeRungIds;
};

struct LogicClipboardCopyResult
{
LogicEditorResult copy;
LogicClipboardFragment fragment;
};

struct LogicPasteTarget
{
std::string rungId;
int column = 0;
bool output = false;
bool boundary = false;
};

// 负责把 UI 编辑命令转换为结构化表达式树操作,并维护撤销/重做
struct LogicClipboardPasteResult
{
LogicEditorResult edit;
LogicSelectionDeleteRequest selection;
std::vector<std::string> wholeRungIds;
};

struct LogicEditCursor
{
std::string rungId;
int column = 0;
bool output = false;
};

struct LogicEditResult
{
LogicEditorResult edit;
LogicEditCursor nextCursor;
};

struct LogicSyntaxLocation
{
std::string logicId;
std::string rungId;
int network = 0;
int row = 0;
int column = 0;
};

struct LogicSyntaxCheckResult
{
bool completed = false;
bool valid = false;
bool changed = false;
std::size_t checkedLogicCount = 0U;
std::size_t removedWireCells = 0U;
std::size_t removedVerticalConnections = 0U;
std::string message;
std::optional<LogicSyntaxLocation> location;
};

// 连续网格的所有修改都经此服务原子提交,并进入同一份撤销历史
class LogicEditorService
{
public:
/**
* @brief 创建梯形图编辑服务
* @param project_service 用于读取和修改当前工程的项目服务
*/
explicit LogicEditorService(ProjectService &project_service);

// 以下查询接口只读工程模型,供编辑器投影和属性面板使用
/** @brief 按 ID 查找控制逻辑,未找到时返回空指针 */
const ControlLogic *findLogic(const std::string &logic_id) const;
/** @brief 在指定逻辑中按 ID 查找网络,未找到时返回空指针 */
const LadderRung *findRung(
const std::string &logic_id, const std::string &rung_id) const;
/** @brief 在指定逻辑的所有网络中按 ID 查找节点,未找到时返回空指针 */
const LogicNode *findNode(
const std::string &logic_id, const std::string &node_id) const;
/** @brief 在指定网络中按 ID 查找条件表达式,未找到时返回空指针 */
const ConditionExpression *findExpression(
const LadderCell *findCell(
const std::string &logic_id,
const std::string &rung_id,
int column) const;
const LadderCell *findCell(
const std::string &logic_id,
const std::string &rung_id,
const std::string &expression_id) const;
/** @brief 返回工程中第一个控制逻辑 ID,没有逻辑时返回空字符串 */
const std::string &cell_id) const;
const VerticalConnection *findConnection(
const std::string &logic_id,
const std::string &connection_id) const;
const LogicNode *findNode(
const std::string &logic_id, const std::string &node_id) const;
std::string firstLogicId() const;
/** @brief 返回指定逻辑中第一个网络 ID,没有网络或逻辑不存在时返回空字符串 */
std::string firstRungId(const std::string &logic_id) const;
/** @brief 查找节点所属网络 ID,未找到时返回空字符串 */
std::string rungIdForNode(
const std::string &logic_id, const std::string &node_id) const;
/** @brief 返回指定 M/D 地址的工程注释,没有注释时返回空字符串 */
std::string registerCommentFor(const RegisterAddress &address) const;

/**
* @brief 确保工程至少有一组可编辑逻辑
* @return 已有或新建逻辑的成功结果及其 ID
*
* 新建逻辑时允许暂时没有网络,第一次实际编辑网络内容时再创建网络
*/
LogicEditorResult ensureDefaultLogic();
/** @brief 添加控制逻辑;名称不能为空且必须唯一 */
LogicEditorResult addLogic(const std::string &name);
/** @brief 修改控制逻辑名称;名称不能为空且必须唯一 */
LogicEditorResult renameLogic(
const std::string &logic_id, const std::string &name);
/** @brief 删除控制逻辑;工程至少保留一组逻辑 */
LogicEditorResult removeLogic(const std::string &logic_id);
/** @brief 将逻辑在工程列表中上移或下移一位,offset 只能为 -1 或 1 */
LogicEditorResult moveLogic(const std::string &logic_id, int offset);
/** @brief 启用或停用指定控制逻辑 */
LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled);
/** @brief 在指定逻辑末尾添加空网络 */

/** 规整并检查指定控制逻辑,规整改动作为一次撤销操作 */
LogicSyntaxCheckResult checkSyntax(const std::string &logic_id);
/** 单独检查指定控制逻辑中的重复 M 线圈输出 */
LogicSyntaxCheckResult checkDoubleCoils(const std::string &logic_id) const;
/** 运行前规整并检查工程中全部已启用控制逻辑 */
LogicSyntaxCheckResult checkEnabledSyntax();

LogicEditorResult addRung(const std::string &logic_id);
/** @brief 删除指定网络 */
LogicEditorResult insertRung(
const std::string &logic_id,
const std::string &reference_rung_id,
bool after);
LogicEditorResult removeRung(
const std::string &logic_id, const std::string &rung_id);
/** @brief 批量删除指定网络,失败时整体回滚 */
LogicEditorResult removeRungs(
const std::string &logic_id,
const std::vector<std::string> &rung_ids);
/** @brief 修改网络注释;注释长度和换行规则由领域校验约束 */
LogicEditorResult updateRungComment(
const std::string &logic_id,
const std::string &rung_id,
const std::string &comment);
// 条件区编辑:串联、按列插入、横线和并联分支
/** @brief 在网络条件末尾追加一个条件节点 */
LogicEditorResult appendCondition(

LogicEditorResult setConditionAtColumn(
const std::string &logic_id,
const std::string &rung_id,
int column,
const LogicNodeConfig &config,
bool configured = false);
LogicEditResult applyConditionAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor,
const LogicNodeConfig &config,
bool configured = false);
/**
* @brief 按绝对条件列插入条件节点
* @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区
*/
LogicEditorResult insertConditionAtColumn(
const std::string &logic_id,
const std::string &rung_id,
int column,
const LogicNodeConfig &config,
bool configured = false);
/**
* @brief 在直属并联分支的指定视觉列插入条件节点
*
* 目标列可以是分支已有横线或 UI 投影出的补线格,服务会原子补齐必要横线
*/
LogicEditorResult insertConditionInBranchAtColumn(
LogicEditorResult appendCondition(
const std::string &logic_id,
const std::string &rung_id,
const std::string &branch_expression_id,
int column,
const LogicNodeConfig &config,
bool configured = false);
/** @brief 在网络条件末尾追加指定列宽的横线 */
LogicEditorResult setHorizontalWireRange(
const std::string &logic_id,
const std::string &rung_id,
int first_column,
int last_column,
bool connected);
LogicEditorResult appendWire(
const std::string &logic_id,
const std::string &rung_id,
int column_span = 1);
/** @brief 在目标节点后串联插入条件节点 */
LogicEditorResult insertConditionAfter(
LogicEditResult applyWireAndAdvance(
const std::string &logic_id,
const std::string &rung_id,
const std::string &target_node_id,
const LogicNodeConfig &config);
/** @brief 在目标表达式后串联插入横线 */
LogicEditorResult insertWireAfter(
const LogicEditCursor &cursor);
LogicEditorResult setWireCells(
const std::string &logic_id,
const std::string &rung_id,
const std::string &target_expression_id,
int column_span = 1);
/** @brief 用条件节点整体替换一条横线表达式 */
LogicEditorResult replaceWireWithCondition(
const std::vector<std::pair<std::string, int>> &cells,
bool connected);
LogicEditorResult clearCells(
const std::string &logic_id,
const std::string &rung_id,
const std::string &wire_expression_id,
const LogicNodeConfig &config);
/** @brief 只替换横线表达式中的一个指定列单元格 */
LogicEditorResult replaceWireColumnWithCondition(
const std::vector<std::pair<std::string, int>> &cells);
LogicEditorResult setVerticalConnection(
const std::string &logic_id,
const std::string &rung_id,
const std::string &wire_expression_id,
int column_offset,
const LogicNodeConfig &config,
bool configured = false);
/** @brief 用条件节点替换断路表达式中的一个指定列单元格 */
LogicEditorResult replaceGapColumnWithCondition(
const std::string &upper_rung_id,
const std::string &lower_rung_id,
int column_boundary,
bool connected);
LogicEditorResult setVerticalConnectionRange(
const std::string &logic_id,
const std::string &rung_id,
const std::string &gap_expression_id,
int column_offset,
const LogicNodeConfig &config,
bool configured = false);
/** @brief 用一格横线修复断路表达式中的指定网格 */
LogicEditorResult replaceGapColumnWithWire(
const std::string &first_rung_id,
const std::string &last_rung_id,
int column_boundary,
bool connected);
LogicEditorResult removeVerticalConnections(
const std::string &logic_id,
const std::string &rung_id,
const std::string &gap_expression_id,
int column_offset);
/**
* @brief 将选中的条件节点建立为并联分支
* @param selected_node_ids 同一网络中按视觉连续范围选择的条件节点 ID
*/
const std::vector<std::string> &connection_ids);
LogicEditorResult deleteSelection(
const std::string &logic_id,
const LogicSelectionDeleteRequest &selection);

LogicEditorResult addParallelBranch(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &selected_node_ids,
const LogicNodeConfig &config,
bool configured = false);
/** @brief 将当前网络的完整条件表达式与一个新条件并联 */
LogicEditorResult addParallelToWholeCondition(
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config,
bool configured = false);
/** @brief 将选中的横线表达式建立为并联旁路 */
LogicEditorResult addParallelWireBranch(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &selected_expression_ids);
/**
* @brief 按选中的横线网格建立并联旁路
*
* 所有网格必须来自同一条横线,且列号连续;操作失败时不保留部分修改
*/
LogicEditorResult addParallelWireBranchAtCells(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::pair<std::string, int>> &selected_wire_cells);
/**
* @brief 设置网络唯一的输出节点
* @param configured 是否将新输出标记为已完成配置
*
* 输出节点固定位于网络输出槽,替换已有输出时保持一次原子编辑
*/
LogicEditorResult setOutput(
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config,
bool configured = false);
// 节点属性和删除操作
/** @brief 更新节点配置;条件节点和输出节点不能互相改型 */
LogicEditResult applyOutputAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor,
const LogicNodeConfig &config,
bool configured = false);
LogicEditorResult updateNodeConfig(
const std::string &logic_id,
const std::string &node_id,
const LogicNodeConfig &config);
/**
* @brief 批量粘贴条件节点到目标网络末尾
* @param logic_id 目标控制逻辑
* @param rung_id 目标网络;为空时自动创建网络
* @param nodes 待复制节点,只读取配置和 configured 状态
* @return 成功时返回第一个新节点 ID,失败时整批回滚
*/
LogicClipboardCopyResult copySelection(
const std::string &logic_id,
const LogicSelectionCopyRequest &selection) const;
LogicClipboardPasteResult pasteClipboard(
const std::string &logic_id,
const LogicClipboardFragment &fragment,
const LogicPasteTarget &target);
LogicEditorResult pasteConditionNodes(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<LogicNode> &nodes,
const LogicConditionPasteTarget &target = {});
/** 判断所选条件是否位于同一串联层级并且视觉连续 */
int start_column = -1);
bool areConditionNodesContiguous(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &node_ids) const;
/**
* @brief 将整条网络复制到目标控制逻辑末尾
* @param logic_id 目标控制逻辑
* @param source 要复制的网络
* @return 成功时返回新网络 ID,所有节点和表达式都会获得新 ID
*/
LogicEditorResult pasteRung(
const std::string &logic_id,
const LadderRung &source);
/** @brief 删除指定节点并归一化受影响的表达式树 */
LogicEditorResult removeNode(
const std::string &logic_id, const std::string &node_id);
/**
* @brief 批量删除节点
* @param node_ids 节点 ID 列表,不能为空且不能包含重复或不存在的 ID
* @return 成功时作为一次编辑记录,失败时整体回滚
*/
LogicEditorResult removeNodes(
const std::string &logic_id,
const std::vector<std::string> &node_ids);
/** @brief 删除指定条件表达式并归一化表达式树 */
LogicEditorResult removeExpression(
const std::string &logic_id,
const std::string &rung_id,
const std::string &expression_id);
/** @brief 批量删除条件表达式,失败时整体回滚 */
LogicEditorResult removeExpressions(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &expression_ids);
/** @brief 把选中的横线网格替换为断路,保留网络列位置 */
LogicEditorResult disconnectWireCells(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::pair<std::string, int>> &wire_cells);
/** @brief 把整段横线替换为等宽断路 */
LogicEditorResult disconnectWires(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &wire_ids);

// 当前编辑会话的撤销/重做
/** @brief 判断是否存在可撤销的梯形图编辑操作 */
bool canUndo() const;
/** @brief 判断是否存在可重做的梯形图编辑操作 */
bool canRedo() const;
/** @brief 撤销最近一次成功的梯形图编辑操作 */
LogicEditorResult undo();
/** @brief 重做最近一次被撤销的梯形图编辑操作 */
LogicEditorResult redo();
/** @brief 清空撤销/重做历史,不修改当前工程内容 */
void clearHistory();

private:
// 只保存逻辑集合快照,当前选择等 UI 会话状态不进入历史
struct HistoryState
{
std::vector<ControlLogic> logics; // 编辑前或编辑后的完整逻辑集合
std::vector<ControlLogic> logics;
};

// 捕获当前工程中的全部控制逻辑
HistoryState captureState() const;
// 比较编辑前后状态并记录实际发生的修改
void recordHistory(HistoryState before);
// 失败时恢复工程内容和操作前脏标记,保证编辑原子性
void rollbackEdit(HistoryState before, bool modified_before);
// 比较两个历史快照是否完全相同
static bool statesEqual(
const HistoryState &left, const HistoryState &right);
static bool logicsEqual(
const ControlLogic &left, const ControlLogic &right);
static bool rungsEqual(
const LadderRung &left, const LadderRung &right);
static bool expressionsEqual(
const ConditionExpression &left, const ConditionExpression &right);
static bool cellsEqual(
const LadderCell &left, const LadderCell &right);
static bool nodesEqual(const LogicNode &left, const LogicNode &right);
static bool configsEqual(
const LogicNodeConfig &left, const LogicNodeConfig &right);
static LogicEditorResult historyFailure(const std::string &message);

// 判断节点配置是否属于条件节点
static bool isConditionConfig(const LogicNodeConfig &config);
// 判断节点配置是否属于输出节点
static bool isOutputConfig(const LogicNodeConfig &config);
// 返回节点配置对应的稳定 ID 前缀
static std::string nodePrefix(const LogicNodeConfig &config);
// 在一个控制逻辑的全部网络中生成唯一节点 ID
static std::string makeUniqueNodeId(
static std::string makeUniqueId(
const ControlLogic &logic, const std::string &prefix);
// 生成唯一横线表达式 ID
static std::string makeUniqueWireId(const ControlLogic &logic);
// 生成控制逻辑内唯一的断路表达式 ID
static std::string makeUniqueGapId(const ControlLogic &logic);
// 生成唯一容器表达式 ID
static std::string makeUniqueExpressionId(const ControlLogic &logic);
// 生成唯一网络 ID
static std::string makeUniqueRungId(const ControlLogic &logic);
// 把并联补线和输出前连接线转换为显式 Wire,并保持输出网络十列布局
static bool repairExplicitLayout(
ControlLogic &logic, LadderRung &rung, std::string *error = nullptr);
// 创建统一的失败结果
static LadderRung makeEmptyRung(
const ControlLogic &logic, std::size_t visual_index);
static std::string insertEmptyRungAt(
ControlLogic *logic, std::size_t position);
static void removeRungAt(ControlLogic *logic, std::size_t position);
static void refreshRungNames(ControlLogic *logic);
static LogicEditorResult failure(
LogicEditorError error, const std::string &message);
LogicEditResult applyCellAndAdvance(
const std::string &logic_id,
const LogicEditCursor &cursor,
const LogicNodeConfig *config,
bool configured);
LogicSyntaxCheckResult checkSyntaxForLogics(
const std::vector<std::string> &logic_ids);

ProjectService &project_service_; // 不拥有的工程服务依赖
EditorHistory<HistoryState> history_; // 当前编辑会话的撤销/重做历史
bool suppress_history_ = false; // 复合粘贴期间由外层统一记录一次历史
ProjectService &project_service_;
EditorHistory<HistoryState> history_;
};

+ 1
- 4
app/src/services/online_logic_monitor_service.cpp Voir le fichier

@@ -59,10 +59,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start(
for (const LadderRung &rung : logic.rungs)
{
std::vector<const LogicNode *> nodes;
if (rung.condition.has_value())
{
collectConditionNodes(*rung.condition, &nodes);
}
collectConditionNodes(rung, &nodes);
if (rung.output.has_value())
{
nodes.push_back(&*rung.output);


+ 1
- 1
app/src/services/project_service.cpp Voir le fichier

@@ -202,7 +202,7 @@ Project ProjectService::makeNewProject(const std::string &name)
project.metadata.id = generateProjectId();
project.metadata.name = name;
// 新工程固定使用当前存储格式版本
project.metadata.formatVersion = "1.0";
project.metadata.formatVersion = "2.0";
return project;
}



+ 38
- 10
app/src/services/runtime_mode_service.cpp Voir le fichier

@@ -13,12 +13,15 @@
#include "domain/project_limits.h"

#include <algorithm>
#include <utility>

RuntimeModeService::RuntimeModeService(
const ProjectService &project_service,
LogicEditorService &logic_editor_service,
OfflineSimulationService &offline_simulation_service,
OnlineLogicMonitorService &online_logic_monitor_service)
: project_service_(project_service),
logic_editor_service_(logic_editor_service),
offline_simulation_service_(offline_simulation_service),
online_logic_monitor_service_(online_logic_monitor_service)
{
@@ -68,11 +71,10 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning()
{
return state_.enterOfflineRunning();
}
std::string error;
if (!project_service_.project().validateForRunning(
project_service_.projectLimits(), &error))
const ModeTransitionResult preparation = prepareProjectForRunning();
if (!preparation.succeeded)
{
return {false, ModeTransitionError::ProjectNotReady};
return preparation;
}
// 离线仿真必须先切到虚拟仓库,避免扫描结果写入 PLC 缓存
if (active_repository_ != nullptr && virtual_repository_ != nullptr)
@@ -84,7 +86,7 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning()
project_service_.project().controlLogics);
if (!start_result.succeeded)
{
return {false, ModeTransitionError::SimulationStartFailed};
return {false, ModeTransitionError::SimulationStartFailed, {}};
}
const ModeTransitionResult transition = state_.enterOfflineRunning();
if (!transition.succeeded)
@@ -104,12 +106,17 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning()
{
return state_.enterOnlineRunning(false);
}
const ModeTransitionResult preparation = prepareProjectForRunning();
if (!preparation.succeeded)
{
return preparation;
}
const OnlineLogicMonitorStartResult start_result =
online_logic_monitor_service_.start(
project_service_.project().controlLogics);
if (!start_result.succeeded)
{
return {false, ModeTransitionError::SimulationStartFailed};
return {false, ModeTransitionError::SimulationStartFailed, {}};
}
const ModeTransitionResult result = state_.enterOnlineRunning(true);
if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr)
@@ -162,6 +169,30 @@ OnlineLogicMonitorService &RuntimeModeService::onlineLogicMonitorService()
return online_logic_monitor_service_;
}

const LogicSyntaxCheckResult &RuntimeModeService::lastSyntaxCheck() const
{
return last_syntax_check_;
}

ModeTransitionResult RuntimeModeService::prepareProjectForRunning()
{
last_syntax_check_ = logic_editor_service_.checkEnabledSyntax();
if (!last_syntax_check_.completed || !last_syntax_check_.valid)
{
return {
false,
ModeTransitionError::ProjectNotReady,
last_syntax_check_.message};
}
std::string error;
if (!project_service_.project().validateForRunning(
project_service_.projectLimits(), &error))
{
return {false, ModeTransitionError::ProjectNotReady, std::move(error)};
}
return {true, ModeTransitionError::None, {}};
}

void RuntimeModeService::configurePlc(
PlcCommunicationGateway &gateway,
ActiveRegisterRepository &active_repository,
@@ -304,10 +335,7 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses()
for (const LadderRung &rung : logic.rungs)
{
std::vector<const LogicNode *> nodes;
if (rung.condition.has_value())
{
collectConditionNodes(*rung.condition, &nodes);
}
collectConditionNodes(rung, &nodes);
if (rung.output.has_value())
{
nodes.push_back(&*rung.output);


+ 8
- 0
app/src/services/runtime_mode_service.h Voir le fichier

@@ -9,6 +9,7 @@
#pragma once

#include "domain/runtime_state.h"
#include "logic_editor_service.h"
#include "offline_simulation_service.h"
#include "online_logic_monitor_service.h"
#include "plc_communication_gateway.h"
@@ -32,6 +33,7 @@ public:
*/
RuntimeModeService(
const ProjectService &project_service,
LogicEditorService &logic_editor_service,
OfflineSimulationService &offline_simulation_service,
OnlineLogicMonitorService &online_logic_monitor_service);

@@ -86,6 +88,8 @@ public:
/** @brief 返回服务持有的离线仿真服务引用 */
OfflineSimulationService &offlineSimulationService();
OnlineLogicMonitorService &onlineLogicMonitorService();
/** 返回最近一次进入运行模式前执行的梯形图语法检查结果 */
const LogicSyntaxCheckResult &lastSyntaxCheck() const;

/**
* @brief 注入 PLC 网关、活动仓库和两种实际数据源
@@ -137,7 +141,10 @@ public:
void setPlcStatusChangedCallback(std::function<void()> callback);

private:
ModeTransitionResult prepareProjectForRunning();

const ProjectService &project_service_; // 不拥有的只读工程服务
LogicEditorService &logic_editor_service_; // 统一执行运行前规整和语法检查
OfflineSimulationService &offline_simulation_service_; // 不拥有的离线仿真服务
OnlineLogicMonitorService &online_logic_monitor_service_; // 不拥有的真机只读轨迹服务
RuntimeState state_; // 编辑、离线和真机模式状态机
@@ -150,4 +157,5 @@ private:
std::function<void()> plc_status_changed_callback_; // PLC 状态变化通知
std::vector<RegisterAddress> monitor_addresses_; // 自由监控额外引用的地址
std::vector<RegisterAddress> monitor_float32_starts_; // 自由监控中的 Float32 起始地址
LogicSyntaxCheckResult last_syntax_check_; // 最近一次运行前梯形图检查结果
};

+ 196
- 135
app/src/services/software_logic_executor.cpp Voir le fichier

@@ -75,9 +75,10 @@ void LogicTraceValues::clear()
{
nodeValues.clear();
nodePowerValues.clear();
expressionValues.clear();
expressionInputValues.clear();
expressionPowerValues.clear();
cellValues.clear();
cellInputPowerValues.clear();
cellPowerValues.clear();
verticalConnectionValues.clear();
rungValues.clear();
wordValues.clear();
}
@@ -97,9 +98,11 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic(
{
projection.nodeValues = values->second.nodeValues;
projection.nodePowerValues = values->second.nodePowerValues;
projection.expressionValues = values->second.expressionValues;
projection.expressionInputValues = values->second.expressionInputValues;
projection.expressionPowerValues = values->second.expressionPowerValues;
projection.cellValues = values->second.cellValues;
projection.cellInputPowerValues = values->second.cellInputPowerValues;
projection.cellPowerValues = values->second.cellPowerValues;
projection.verticalConnectionValues =
values->second.verticalConnectionValues;
projection.rungValues = values->second.rungValues;
projection.wordValues = values->second.wordValues;
}
@@ -192,7 +195,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
{
trace->clear();
}
// 按工程顺序扫描;前面网络写入的 M/D 值对后面网络立即可见
// 互相没有竖线连接的行仍按先后顺序扫描,保持原有网络间可见性
for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
@@ -201,56 +204,195 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
}
LogicTraceValues *logic_trace = trace == nullptr
? nullptr : &trace->logicValues[logic.id];
for (const LadderRung &rung : logic.rungs)
std::size_t group_start = 0U;
while (group_start < logic.rungs.size())
{
if (!rung.condition.has_value() && !rung.output.has_value())
std::size_t group_end = group_start;
while (group_end + 1U < logic.rungs.size())
{
continue;
const std::string &upper_id = logic.rungs[group_end].id;
const std::string &lower_id = logic.rungs[group_end + 1U].id;
const bool connected = std::any_of(
logic.verticalConnections.cbegin(),
logic.verticalConnections.cend(),
[&upper_id, &lower_id](const VerticalConnection &connection)
{
return connection.upperRungId == upper_id
&& connection.lowerRungId == lower_id;
});
if (!connected)
{
break;
}
++group_end;
}

// 输出网络的显式 Wire 会计算为真;空网络只作为编辑草稿跳过
bool rung_value = true;
LogicScanResult result = success();
if (rung.condition.has_value())
{
result = evaluateExpression(
logic.id,
*rung.condition,
repository,
logic_trace,
true,
&rung_value);
}
if (!result.succeeded)
{
result.logicId = logic.id;
result.rungId = rung.id;
return result;
}
if (logic_trace != nullptr)
const std::size_t group_size = group_end - group_start + 1U;
std::vector<bool> power(group_size, true);
for (int boundary = 0;
boundary <= ProjectLimits::kMaximumConditionColumns;
++boundary)
{
logic_trace->rungValues[rung.id] = rung_value;
logic_trace->nodeValues[rung.output->id] = rung_value;
logic_trace->nodePowerValues[rung.output->id] = rung_value;
}
bool output_value = false;
result = executeOutput(
*rung.output,
rung_value,
repository,
logic_trace,
&output_value);
if (!result.succeeded)
{
result.logicId = logic.id;
result.rungId = rung.id;
return result;
std::vector<std::size_t> parent(group_size);
for (std::size_t index = 0U; index < group_size; ++index)
{
parent[index] = index;
}
const auto root = [&parent](std::size_t index)
{
while (parent[index] != index)
{
index = parent[index];
}
return index;
};
const auto unite = [&parent, &root](
std::size_t left, std::size_t right)
{
const std::size_t left_root = root(left);
const std::size_t right_root = root(right);
if (left_root != right_root)
{
parent[right_root] = left_root;
}
};

for (const VerticalConnection &connection
: logic.verticalConnections)
{
if (connection.columnBoundary != boundary)
{
continue;
}
for (std::size_t row = group_start;
row < group_end;
++row)
{
if (logic.rungs[row].id == connection.upperRungId
&& logic.rungs[row + 1U].id
== connection.lowerRungId)
{
unite(row - group_start, row + 1U - group_start);
break;
}
}
}

std::vector<bool> component_power(group_size, false);
for (std::size_t index = 0U; index < group_size; ++index)
{
component_power[root(index)] =
component_power[root(index)] || power[index];
}
for (std::size_t index = 0U; index < group_size; ++index)
{
power[index] = component_power[root(index)];
}
if (logic_trace != nullptr)
{
for (const VerticalConnection &connection
: logic.verticalConnections)
{
if (connection.columnBoundary != boundary)
{
continue;
}
for (std::size_t row = group_start;
row < group_end;
++row)
{
if (logic.rungs[row].id == connection.upperRungId
&& logic.rungs[row + 1U].id
== connection.lowerRungId)
{
logic_trace->verticalConnectionValues[
connection.id] = power[row - group_start];
break;
}
}
}
}
if (boundary == ProjectLimits::kMaximumConditionColumns)
{
break;
}

for (std::size_t local_row = 0U;
local_row < group_size;
++local_row)
{
const LadderRung &rung =
logic.rungs[group_start + local_row];
const LadderCell &cell = rung.cells[
static_cast<std::size_t>(boundary)];
const bool input_power = power[local_row];
bool cell_value = cell.kind == LadderCellKind::Wire;
LogicScanResult result = success();
if (cell.kind == LadderCellKind::Node)
{
result = evaluateCondition(
logic.id,
*cell.node,
repository,
&cell_value);
}
if (!result.succeeded)
{
result.logicId = logic.id;
result.rungId = rung.id;
return result;
}
power[local_row] = input_power && cell_value;
if (logic_trace != nullptr)
{
logic_trace->cellValues[cell.id] = cell_value;
logic_trace->cellInputPowerValues[cell.id] = input_power;
logic_trace->cellPowerValues[cell.id] = power[local_row];
if (cell.node.has_value())
{
logic_trace->nodeValues[cell.node->id] = cell_value;
logic_trace->nodePowerValues[cell.node->id] =
power[local_row];
}
}
}
}
if (logic_trace != nullptr)

// 同一个竖线连通组先完成条件传播,再按视觉行顺序执行输出
for (std::size_t local_row = 0U;
local_row < group_size;
++local_row)
{
logic_trace->nodeValues[rung.output->id] = output_value;
logic_trace->nodePowerValues[rung.output->id] = output_value;
const LadderRung &rung = logic.rungs[group_start + local_row];
const bool rung_value = power[local_row];
if (logic_trace != nullptr)
{
logic_trace->rungValues[rung.id] = rung_value;
}
if (!rung.output.has_value())
{
continue;
}
bool output_value = false;
LogicScanResult result = executeOutput(
*rung.output,
rung_value,
repository,
logic_trace,
&output_value);
if (!result.succeeded)
{
result.logicId = logic.id;
result.rungId = rung.id;
return result;
}
if (logic_trace != nullptr)
{
logic_trace->nodeValues[rung.output->id] = output_value;
logic_trace->nodePowerValues[rung.output->id] = rung_value;
}
}
group_start = group_end + 1U;
}
}
if (trace != nullptr)
@@ -265,9 +407,11 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
{
trace->nodeValues = values->second.nodeValues;
trace->nodePowerValues = values->second.nodePowerValues;
trace->expressionValues = values->second.expressionValues;
trace->expressionInputValues = values->second.expressionInputValues;
trace->expressionPowerValues = values->second.expressionPowerValues;
trace->cellValues = values->second.cellValues;
trace->cellInputPowerValues = values->second.cellInputPowerValues;
trace->cellPowerValues = values->second.cellPowerValues;
trace->verticalConnectionValues =
values->second.verticalConnectionValues;
trace->rungValues = values->second.rungValues;
trace->wordValues = values->second.wordValues;
}
@@ -276,89 +420,6 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
return success();
}

LogicScanResult SoftwareLogicExecutor::evaluateExpression(
const std::string &logic_id,
const ConditionExpression &expression,
RegisterRepository &repository,
LogicTraceValues *trace,
bool input_power,
bool *value)
{
if (value == nullptr)
{
return failure(LogicScanError::InvalidLogic, "缺少表达式结果接收对象");
}
if (trace != nullptr)
{
trace->expressionInputValues[expression.id] = input_power;
}
if (expression.kind == ConditionExpressionKind::Wire)
{
*value = true;
if (trace != nullptr)
{
trace->expressionValues[expression.id] = true;
trace->expressionPowerValues[expression.id] = input_power;
}
return success();
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return failure(
LogicScanError::InvalidLogic,
"梯形图条件区存在未连接的空白网格",
logic_id,
{},
expression.id);
}
if (expression.kind == ConditionExpressionKind::Node)
{
LogicScanResult result = evaluateCondition(
logic_id, *expression.node, repository, value);
if (result.succeeded && trace != nullptr)
{
trace->nodeValues[expression.node->id] = *value;
trace->nodePowerValues[expression.node->id] = input_power && *value;
trace->expressionValues[expression.id] = *value;
trace->expressionPowerValues[expression.id] = input_power && *value;
}
return result;
}

bool accumulated = expression.kind == ConditionExpressionKind::Series;
bool power = input_power;
for (const ConditionExpression &child : expression.children)
{
bool child_value = false;
const bool child_input = expression.kind == ConditionExpressionKind::Series
? power : input_power;
LogicScanResult result = evaluateExpression(
logic_id,
child,
repository,
trace,
child_input,
&child_value);
if (!result.succeeded)
{
return result;
}
accumulated = expression.kind == ConditionExpressionKind::Series
? accumulated && child_value : accumulated || child_value;
if (expression.kind == ConditionExpressionKind::Series)
{
power = power && child_value;
}
}
*value = accumulated;
if (trace != nullptr)
{
trace->expressionValues[expression.id] = accumulated;
trace->expressionPowerValues[expression.id] = input_power && accumulated;
}
return success();
}

LogicScanResult SoftwareLogicExecutor::evaluateCondition(
const std::string &logic_id,
const LogicNode &node,


+ 8
- 24
app/src/services/software_logic_executor.h Voir le fichier

@@ -38,15 +38,16 @@ struct WordTraceValue
bool overflow = false; // ADD/SUB 是否发生饱和溢出
};

// 一条控制逻辑的运行轨迹快照;各映射使用节点、表达式或网络 ID 作为键
// 一张连续梯形图的运行轨迹快照
struct LogicTraceValues
{
std::unordered_map<std::string, bool> nodeValues; // 节点最终逻辑值
std::unordered_map<std::string, bool> nodePowerValues; // 节点输入电源状态
std::unordered_map<std::string, bool> expressionValues; // 条件表达式最终值
std::unordered_map<std::string, bool> expressionInputValues; // 表达式输入状态
std::unordered_map<std::string, bool> expressionPowerValues; // 表达式输出电源状态
std::unordered_map<std::string, bool> rungValues; // 网络最终逻辑值
std::unordered_map<std::string, bool> nodeValues; // 节点自身结果
std::unordered_map<std::string, bool> nodePowerValues; // 节点后的带电状态
std::unordered_map<std::string, bool> cellValues; // 网格自身是否导通
std::unordered_map<std::string, bool> cellInputPowerValues; // 网格左边界电源
std::unordered_map<std::string, bool> cellPowerValues; // 网格右边界电源
std::unordered_map<std::string, bool> verticalConnectionValues; // 竖线所在边界电源
std::unordered_map<std::string, bool> rungValues; // 行末输出槽电源
std::unordered_map<std::string, WordTraceValue> wordValues; // MOVE/ADD/SUB 节点状态

// 清除当前逻辑或网络的全部轨迹
@@ -109,23 +110,6 @@ private:
const LogicNode &node,
RegisterRepository &repository,
bool *value);
/**
* @brief 递归计算串联、并联或叶子条件表达式
* @param logic_id 所属控制逻辑 ID,用于传递给叶子节点状态计算
* @param expression 待读取的条件表达式树
* @param repository 提供 M/D 值的寄存器仓库
* @param trace 可选的轨迹输出,用于记录表达式值、电源输入和电源输出
* @param input_power 进入当前表达式的电源状态
* @param value 输出表达式最终值,不能为空
* @return 成功结果,或包含失败节点 ID 的扫描错误
*/
LogicScanResult evaluateExpression(
const std::string &logic_id,
const ConditionExpression &expression,
RegisterRepository &repository,
LogicTraceValues *trace,
bool input_power,
bool *value);
/**
* @brief 在网络结果驱动下执行单个输出节点
* @param node 待读取配置并执行的输出节点


+ 1952
- 2164
app/src/ui/logic_editor_widget.cpp
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 73
- 111
app/src/ui/logic_editor_widget.h Voir le fichier

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

#pragma once

#include "domain/control_logic_model.h"
@@ -14,6 +6,7 @@
#include "services/software_logic_executor.h"

#include <QGraphicsView>
#include <QPoint>

#include <string>
#include <utility>
@@ -27,180 +20,149 @@ class QMouseEvent;
class QRubberBand;
class QResizeEvent;

/** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */
class LogicEditorWidget final : public QGraphicsView
{
Q_OBJECT

public:
/** 梯形图中的条件、输出、横线和辅助选择图元类型 */
class NodeItem;
class WireItem;
class WireCellItem;
class GapCellItem;
class VerticalConnectorItem;
class RungItem;
class EmptySlotItem;
class OutputSlotItem;
enum class MouseWireMode { Select, Draw, Erase };

/** 创建梯形图画布并绑定编辑服务 */
explicit LogicEditorWidget(
LogicEditorService &editor_service,
QWidget *parent = nullptr);

/** 切换当前显示的控制逻辑;不存在时显示空场景 */
void setLogicId(const std::string &logic_id);
/** 设置是否允许编辑梯形图 */
void setEditingEnabled(bool enabled);
/** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */
void setMouseWireMode(MouseWireMode mode);
MouseWireMode mouseWireMode() const;
void setRuntimeTrace(
const LogicTraceSnapshot &trace,
const std::string &fault_node_id = {});
/** 清除当前运行轨迹和故障节点标记 */
void clearRuntimeTrace();
/** 返回当前是否正在显示运行轨迹 */
bool runtimeTraceEnabled() const;
/** 根据当前逻辑模型重建梯形图场景 */
void reloadLogic();
/** 选中指定节点并滚动到可见区域 */
void clearSelection();
void selectNode(const std::string &node_id);
/** 返回当前选中的单个节点标识 */
/** 选中并滚动到语法错误位置,column 使用界面显示的 1 基列号 */
void focusSyntaxLocation(const std::string &rung_id, int column);
std::string selectedNodeId() const;
/** 返回当前选中的多个节点标识 */
std::vector<std::string> selectedNodeIds() const;
/** 返回当前选中的网络标识 */
std::string selectedRungId() const;
bool hasCopyableSelection() const;
LogicClipboardCopyResult copySelection() const;
LogicPasteTarget pasteTarget() const;

/** 新增一个空的梯形图网络 */
LogicEditorResult addRung();
/** 将当前选择转换为服务调用并新增串联条件 */
LogicEditorResult insertRung(bool after);
LogicEditorResult deleteRung();
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 pasteConditionNodes(const std::vector<LogicNode> &nodes);
/** 删除当前选中的节点、线段或网络 */
LogicClipboardPasteResult pasteClipboard(
const LogicClipboardFragment &fragment);
LogicEditorResult deleteSelected();

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

protected:
/** 在任意网络区域开始鼠标框选 */
void mousePressEvent(QMouseEvent *event) override;
/** 更新鼠标框选区域 */
void mouseMoveEvent(QMouseEvent *event) override;
/** 完成鼠标框选并提交场景选择 */
void mouseReleaseEvent(QMouseEvent *event) override;
/** 在条件网格或输出槽双击时打开命令输入框 */
void mouseDoubleClickEvent(QMouseEvent *event) override;
/** 在窗口大小变化后重新布局梯形图场景 */
void resizeEvent(QResizeEvent *event) override;
/** 滚动画布时同步命令输入框位置 */
void scrollContentsBy(int dx, int dy) override;
/** 处理命令输入框的回车、取消和按键事件 */
bool eventFilter(QObject *watched, QEvent *event) override;

private:
/** 处理图形场景选择变化 */
void handleSelectionChanged();
/** 把编辑服务结果转换为错误信号 */
struct RowLayout
{
std::string rungId;
int row = -1;
qreal gridTop = 0.0;
qreal centerY = 0.0;
qreal bottom = 0.0;
bool networkHead = false;
};

struct Hit
{
std::string rungId;
int column = -1;
LadderCellKind cellKind = LadderCellKind::Gap;
bool output = false;
bool vertical = false;
bool boundary = false;
bool rowHeader = false;
std::string lowerRungId;
std::string objectId;
};

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<std::pair<std::string, int>> selectedEmptySlots() const;
/** 收集当前选中的横线网格位置 */
std::vector<std::pair<std::string, int>> selectedWireCells() const;
/** 收集当前选中的显式断路网格位置 */
std::vector<std::pair<std::string, int>> selectedGapCells() const;
/** 判断当前是否选中了网络内的图元 */
bool hasSelectedRungItem() const;
/** 收集当前明确选中的网络图元 */
std::vector<std::string> selectedRungItemIds() const;
/** 打开指定目标位置的命令输入框 */
void beginCommandInput(
const LogicCommandTarget &target,
const QPointF &scene_center);
/** 提交当前命令输入;失败时保留输入框并标红 */
Hit hitAt(const QPointF &scene_position) const;
void beginGesture(const Hit &hit);
void updateGesture(const Hit &hit);
void finishGesture(const Hit &hit);
void showCommandEditor(const Hit &hit);
void commitCommandInput();
/** 关闭命令输入框并清理临时状态 */
void cancelCommandInput();
/** 根据网络、列和支路标识查找下一输入位置 */
bool beginNextCommandInput();
/** 按目标定位输入框的屏幕矩形 */
void positionCommandInput(const QPointF &scene_center);
/** 返回当前场景目标的中心位置 */
bool findCommandTargetCenter(
const LogicCommandTarget &target,
QPointF *scene_center) const;
/** 将成功命令选中并刷新属性面板 */
void selectCommandResult(const LogicCommandResult &result);
LogicCommandTarget commandTargetForCursor(
const LogicEditCursor &cursor) const;
LogicEditCursor conditionInsertionCursor() const;
void moveToCursor(const LogicEditCursor &cursor);
LogicEditorResult finishCursorEdit(const LogicEditResult &result);
void rebuildScene();
void selectObject(const Hit &hit, bool extend_node_selection = false);
void selectObjectsInBand(const QRect &viewport_rect, bool extend_selection);
void clearObjectSelection();
void synchronizeSelectedNodes();
void notifySelectionChanged();
void rebuildRowLayout(const ControlLogic &logic);
const RowLayout *layoutForRung(const std::string &rung_id) const;
int rowAt(const std::string &rung_id) const;

/** 梯形图编辑服务,不由控件拥有 */
LogicEditorService &editor_service_;
/** 命令语解析和结构化编辑适配服务 */
LogicCommandService command_service_;
/** 承载梯形图图元的场景 */
QGraphicsScene *scene_ = nullptr;
/** 当前显示的控制逻辑标识 */
std::string logic_id_;
/** 当前选中的网络标识 */
std::string current_rung_id_;
/** 最近一次收到的运行轨迹 */
std::string selected_rung_id_;
std::vector<std::string> selected_node_ids_;
std::vector<std::pair<std::string, int>> selected_cells_;
std::vector<std::string> selected_output_rung_ids_;
std::vector<std::string> selected_vertical_connection_ids_;
std::vector<std::string> selected_row_ids_;
std::vector<RowLayout> row_layouts_;
LogicTraceSnapshot trace_;
/** 最近一次运行故障节点标识 */
std::string fault_node_id_;
/** 是否显示运行轨迹 */
bool runtime_trace_enabled_ = false;
/** 是否允许编辑梯形图 */
bool editing_enabled_ = true;
/** 鼠标框选覆盖层 */
MouseWireMode mouse_wire_mode_ = MouseWireMode::Select;
int selected_column_ = -1;
bool selected_cell_ = false;
bool selected_output_ = false;
bool selected_boundary_ = false;
std::string selected_vertical_connection_id_;
QRubberBand *selection_band_ = nullptr;
/** 框选起点(视口坐标) */
QPoint selection_origin_;
/** 框选时是否已超过拖拽阈值 */
bool selection_pressed_ = false;
bool selection_dragging_ = false;
/** 框选起始时的键盘修饰键 */
Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier;
/** 命令输入框,仅在编辑态临时创建 */
bool gesture_active_ = false;
Hit gesture_origin_;
Hit gesture_current_;
QLineEdit *command_editor_ = nullptr;
/** 命令输入补全器 */
QCompleter *command_completer_ = nullptr;
/** 当前命令输入目标 */
LogicCommandTarget command_target_;
/** 是否沿当前命令序列继续输入 */
bool command_continuing_ = false;
/** OR/ORI 只增加支路,不推进主表达式列 */
bool command_keep_column_ = false;
/** 连续输入对应的当前网络 */
std::string command_current_rung_id_;
/** 编辑已有网络 OR 时保留的选择范围 */
std::vector<std::string> command_parallel_node_ids_;
};

+ 283
- 80
app/src/ui/main_window.cpp Voir le fichier

@@ -40,6 +40,8 @@
#include <QIcon>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QListWidgetItem>
#include <QKeyEvent>
#include <QKeySequence>
#include <QMessageBox>
@@ -55,11 +57,16 @@
#include <QDir>
#include <QFileInfo>
#include <QProgressDialog>
#include <QSignalBlocker>

#include <algorithm>

namespace {

constexpr int kSyntaxLogicIdRole = Qt::UserRole + 1;
constexpr int kSyntaxRungIdRole = Qt::UserRole + 2;
constexpr int kSyntaxColumnRole = Qt::UserRole + 3;

bool isTextEditingObject(QObject *object)
{
QWidget *widget = qobject_cast<QWidget *>(object);
@@ -612,6 +619,33 @@ void MainWindow::configureActions()
this, &MainWindow::deleteActiveSelection);
connect(ui_->clearSelectionAction, &QAction::triggered,
this, &MainWindow::clearActiveSelection);
connect(ui_->syntaxCheckAction, &QAction::triggered,
this, &MainWindow::runLogicSyntaxCheck);
connect(ui_->doubleCoilCheckAction, &QAction::triggered,
this, &MainWindow::runDoubleCoilCheck);
connect(ui_->outputList, &QListWidget::itemDoubleClicked,
this,
[this](QListWidgetItem *item)
{
if (item == nullptr)
{
return;
}
const std::string logic_id = toUtf8(
item->data(kSyntaxLogicIdRole).toString());
const std::string rung_id = toUtf8(
item->data(kSyntaxRungIdRole).toString());
if (logic_id.empty() || rung_id.empty())
{
return;
}
focusLogicSyntaxLocation({
logic_id,
rung_id,
0,
0,
item->data(kSyntaxColumnRole).toInt()});
});

connect(ui_->addButtonAction, &QAction::triggered, this,
[this] { addHmiControl(HmiControlType::Button); });
@@ -641,6 +675,44 @@ void MainWindow::configureActions()

connect(ui_->addRungAction, &QAction::triggered,
this, &MainWindow::addLogicRung);
connect(ui_->insertRungAboveAction, &QAction::triggered,
this, &MainWindow::insertLogicRungAbove);
connect(ui_->insertRungBelowAction, &QAction::triggered,
this, &MainWindow::insertLogicRungBelow);
connect(ui_->deleteRungAction, &QAction::triggered,
this, &MainWindow::deleteLogicRung);
connect(ui_->mouseDrawWireAction, &QAction::triggered,
this,
[this](bool checked)
{
if (checked)
{
const QSignalBlocker blocker(ui_->mouseEraseWireAction);
ui_->mouseEraseWireAction->setChecked(false);
}
logic_editor_widget_->setMouseWireMode(
checked
? LogicEditorWidget::MouseWireMode::Draw
: ui_->mouseEraseWireAction->isChecked()
? LogicEditorWidget::MouseWireMode::Erase
: LogicEditorWidget::MouseWireMode::Select);
});
connect(ui_->mouseEraseWireAction, &QAction::triggered,
this,
[this](bool checked)
{
if (checked)
{
const QSignalBlocker blocker(ui_->mouseDrawWireAction);
ui_->mouseDrawWireAction->setChecked(false);
}
logic_editor_widget_->setMouseWireMode(
checked
? LogicEditorWidget::MouseWireMode::Erase
: ui_->mouseDrawWireAction->isChecked()
? LogicEditorWidget::MouseWireMode::Draw
: LogicEditorWidget::MouseWireMode::Select);
});
connect(ui_->insertHorizontalWireAction, &QAction::triggered,
this, &MainWindow::addLogicHorizontalWire);
connect(ui_->insertVerticalWireAction, &QAction::triggered,
@@ -911,6 +983,11 @@ void MainWindow::configureAppearance()
ui_->configureAlarmsAction->setIcon(makeUiIcon(UiIcon::AlarmSettings));
ui_->deleteControlAction->setIcon(makeUiIcon(UiIcon::Delete));
ui_->addRungAction->setIcon(makeUiIcon(UiIcon::AddRung));
ui_->insertRungAboveAction->setIcon(makeUiIcon(UiIcon::AddRung));
ui_->insertRungBelowAction->setIcon(makeUiIcon(UiIcon::AddRung));
ui_->deleteRungAction->setIcon(makeUiIcon(UiIcon::Delete));
ui_->mouseDrawWireAction->setIcon(makeUiIcon(UiIcon::MouseDrawWire));
ui_->mouseEraseWireAction->setIcon(makeUiIcon(UiIcon::MouseEraseWire));
ui_->parallelInsertAction->setIcon(makeUiIcon(UiIcon::ParallelBranch));
ui_->insertHorizontalWireAction->setIcon(makeUiIcon(UiIcon::HorizontalWire));
ui_->insertVerticalWireAction->setIcon(makeUiIcon(UiIcon::VerticalWire));
@@ -928,6 +1005,8 @@ void MainWindow::configureAppearance()
ui_->addSubAction->setIcon(makeUiIcon(UiIcon::Subtract));
ui_->addCompareAction->setIcon(makeUiIcon(UiIcon::Compare));
ui_->editRungCommentAction->setIcon(makeUiIcon(UiIcon::Comment));
ui_->syntaxCheckAction->setIcon(makeUiIcon(UiIcon::SyntaxCheck));
ui_->doubleCoilCheckAction->setIcon(makeUiIcon(UiIcon::Coil));
ui_->deleteLogicAction->setIcon(makeUiIcon(UiIcon::Delete));
ui_->editorTabWidget->setTabIcon(0, makeUiIcon(UiIcon::HmiPage));
ui_->editorTabWidget->setTabIcon(1, makeUiIcon(UiIcon::Logic));
@@ -992,6 +1071,12 @@ void MainWindow::configureLogicEditor()
layout->addWidget(logic_editor_widget_);
property_panel_controller_->bindEditorWidgets(
*hmi_editor_widget_, *logic_editor_widget_);
connect(
hmi_editor_widget_, &HmiEditorWidget::controlSelected,
this, [this](const QString &) { updateEditActions(); });
connect(
logic_editor_widget_, &LogicEditorWidget::nodeSelected,
this, [this](const QString &) { updateEditActions(); });
}

void MainWindow::configureRuntimeMonitor()
@@ -1275,57 +1360,29 @@ void MainWindow::copyActiveSelection()
}
else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab)
{
const std::vector<std::string> ids = logic_editor_widget_->selectedNodeIds();
if (!ids.empty())
LogicClipboardCopyResult result = logic_editor_widget_->copySelection();
if (!result.copy.succeeded)
{
const std::string rung_id = logic_editor_widget_->selectedRungId();
if (rung_id.empty())
{
statusBar()->showMessage(tr("只能复制同一网络中的梯形图指令"), 3000);
return;
}
std::vector<LogicNode> nodes;
nodes.reserve(ids.size());
bool all_conditions = true;
for (const std::string &id : ids)
{
const LogicNode *node = logic_editor_service_.findNode(
current_logic_id_, id);
if (node == nullptr)
{
return;
}
all_conditions = all_conditions && node->isCondition();
nodes.push_back(*node);
}
if (all_conditions
&& logic_editor_service_.areConditionNodesContiguous(
current_logic_id_, rung_id, ids))
{
editor_clipboard_ = LogicNodesClipboardData{std::move(nodes)};
statusBar()->showMessage(tr("已复制 %1 个梯形图条件").arg(ids.size()), 3000);
}
else if (ids.size() == 1U && nodes.front().isOutput())
{
editor_clipboard_ = LogicOutputClipboardData{nodes.front()};
statusBar()->showMessage(tr("已复制梯形图输出指令"), 3000);
}
else
{
statusBar()->showMessage(
tr("只能复制同一串联层级中连续的条件,或单个输出指令"), 3000);
}
updateEditActions();
statusBar()->showMessage(fromUtf8(result.copy.message), 5000);
return;
}

const std::string rung_id = logic_editor_widget_->selectedRungId();
const LadderRung *rung = logic_editor_service_.findRung(
current_logic_id_, rung_id);
if (rung != nullptr)
const bool whole_rows =
result.fragment.mode == LogicClipboardMode::WholeRows;
const std::size_t object_count = result.fragment.cells.size()
+ result.fragment.outputs.size()
+ result.fragment.verticalConnections.size();
editor_clipboard_ = LogicClipboardData{std::move(result.fragment)};
if (whole_rows)
{
editor_clipboard_ = LogicRungClipboardData{*rung};
statusBar()->showMessage(tr("已复制整条梯形图网络"), 3000);
const auto *data = std::get_if<LogicClipboardData>(&editor_clipboard_);
statusBar()->showMessage(
tr("已复制 %1 行梯形图").arg(data->fragment.rows.size()),
3000);
}
else
{
statusBar()->showMessage(
tr("已复制 %1 个梯形图对象").arg(object_count), 3000);
}
}
updateEditActions();
@@ -1362,37 +1419,24 @@ void MainWindow::pasteActiveSelection()
}
else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab)
{
LogicEditorResult result;
if (const auto *data = std::get_if<LogicNodesClipboardData>(&editor_clipboard_))
{
result = logic_editor_widget_->pasteConditionNodes(data->nodes);
if (result.succeeded)
{
selected_logic_node_id_ = result.id;
showLogicNodeProperties(result.id);
}
}
else if (const auto *data = std::get_if<LogicOutputClipboardData>(&editor_clipboard_))
{
result = logic_editor_widget_->setOutput(
data->output.config, data->output.configured);
}
else if (const auto *data = std::get_if<LogicRungClipboardData>(&editor_clipboard_))
const auto *data = std::get_if<LogicClipboardData>(&editor_clipboard_);
if (data == nullptr)
{
result = logic_editor_service_.pasteRung(current_logic_id_, data->rung);
if (result.succeeded)
{
logic_editor_widget_->reloadLogic();
}
return;
}
if (!result.succeeded)
const LogicClipboardPasteResult result =
logic_editor_widget_->pasteClipboard(data->fragment);
if (!result.edit.succeeded)
{
if (!result.message.empty())
if (!result.edit.message.empty())
{
showProjectResult(tr("粘贴梯形图"), fromUtf8(result.message), false);
showProjectResult(
tr("粘贴梯形图"), fromUtf8(result.edit.message), false);
}
return;
}
selected_logic_node_id_ = logic_editor_widget_->selectedNodeId();
showLogicNodeProperties(selected_logic_node_id_);
refreshProjectUi();
statusBar()->showMessage(tr("已粘贴梯形图对象"), 3000);
}
@@ -1421,7 +1465,7 @@ void MainWindow::clearActiveSelection()
}
else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab)
{
logic_editor_widget_->scene()->clearSelection();
logic_editor_widget_->clearSelection();
selected_logic_node_id_.clear();
showLogicNodeProperties({});
}
@@ -1439,19 +1483,32 @@ void MainWindow::updateEditActions()
ui_->redoAction->setEnabled(
editable && ((hmi_active && hmi_editor_service_.canRedo())
|| (logic_active && logic_editor_service_.canRedo())));
ui_->copyAction->setEnabled(editable && (hmi_active || logic_active));
const bool hmi_selection = hmi_active
&& !hmi_editor_widget_->selectedControlIds().empty();
const bool logic_selection = logic_active
&& logic_editor_widget_->hasCopyableSelection();
ui_->copyAction->setEnabled(
editable && (hmi_selection || logic_selection));
const bool hmi_clipboard = std::holds_alternative<HmiClipboardData>(editor_clipboard_);
const bool logic_clipboard = std::holds_alternative<LogicNodesClipboardData>(editor_clipboard_)
|| std::holds_alternative<LogicOutputClipboardData>(editor_clipboard_)
|| std::holds_alternative<LogicRungClipboardData>(editor_clipboard_);
const bool logic_clipboard =
std::holds_alternative<LogicClipboardData>(editor_clipboard_);
ui_->pasteAction->setEnabled(editable && ((hmi_active && hmi_clipboard)
|| (logic_active && logic_clipboard)));
ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active));
ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active));
ui_->insertHorizontalWireAction->setEnabled(editable && logic_active);
ui_->insertVerticalWireAction->setEnabled(editable && logic_active);
ui_->insertRungAboveAction->setEnabled(editable && logic_active);
ui_->insertRungBelowAction->setEnabled(editable && logic_active);
ui_->deleteRungAction->setEnabled(editable && logic_active);
ui_->deleteHorizontalWireAction->setEnabled(editable && logic_active);
ui_->deleteVerticalWireAction->setEnabled(editable && logic_active);
ui_->mouseDrawWireAction->setEnabled(editable && logic_active);
ui_->mouseEraseWireAction->setEnabled(editable && logic_active);
ui_->syntaxCheckAction->setEnabled(
editable && !current_logic_id_.empty());
ui_->doubleCoilCheckAction->setEnabled(
editable && !current_logic_id_.empty());
}

void MainWindow::clearEditorHistories()
@@ -1645,7 +1702,7 @@ void MainWindow::addLogicVerticalWire()
selected_logic_node_id_.clear();
showLogicNodeProperties({});
refreshProjectUi();
statusBar()->showMessage(tr("已建立横线旁路和竖线连接"), 3000);
statusBar()->showMessage(tr("竖线连接已建立"), 3000);
}

void MainWindow::deleteLogicHorizontalWire()
@@ -1673,7 +1730,7 @@ void MainWindow::deleteLogicVerticalWire()
selected_logic_node_id_.clear();
showLogicNodeProperties({});
refreshProjectUi();
statusBar()->showMessage(tr("竖线及对应并联支路已删除"), 3000);
statusBar()->showMessage(tr("竖线已删除,网络已拆分"), 3000);
}

void MainWindow::setLogicOutput(const LogicNodeConfig &config)
@@ -1725,6 +1782,48 @@ void MainWindow::addLogicRung()
statusBar()->showMessage(tr("已新建网络"), 3000);
}

void MainWindow::insertLogicRungAbove()
{
const LogicEditorResult result = logic_editor_widget_->insertRung(false);
if (!result.succeeded)
{
showProjectResult(tr("上方插入行"), fromUtf8(result.message), false);
return;
}
selected_logic_node_id_.clear();
showLogicNodeProperties({});
refreshProjectUi();
statusBar()->showMessage(tr("已在当前行上方插入空白行"), 3000);
}

void MainWindow::insertLogicRungBelow()
{
const LogicEditorResult result = logic_editor_widget_->insertRung(true);
if (!result.succeeded)
{
showProjectResult(tr("下方插入行"), fromUtf8(result.message), false);
return;
}
selected_logic_node_id_.clear();
showLogicNodeProperties({});
refreshProjectUi();
statusBar()->showMessage(tr("已在当前行下方插入空白行"), 3000);
}

void MainWindow::deleteLogicRung()
{
const LogicEditorResult result = logic_editor_widget_->deleteRung();
if (!result.succeeded)
{
showProjectResult(tr("删除行"), fromUtf8(result.message), false);
return;
}
selected_logic_node_id_.clear();
showLogicNodeProperties({});
refreshProjectUi();
statusBar()->showMessage(tr("当前行已删除,竖线连接已重新整理"), 3000);
}

void MainWindow::editSelectedRungComment()
{
const std::string rung_id = logic_editor_widget_->selectedRungId();
@@ -1763,6 +1862,62 @@ void MainWindow::editSelectedRungComment()
statusBar()->showMessage(tr("网络注释已更新"), 3000);
}

void MainWindow::runLogicSyntaxCheck()
{
const LogicSyntaxCheckResult result = logic_editor_service_.checkSyntax(
current_logic_id_);
reportLogicSyntaxCheck(result, tr("语法检查"));
}

void MainWindow::runDoubleCoilCheck()
{
const LogicSyntaxCheckResult result =
logic_editor_service_.checkDoubleCoils(current_logic_id_);
reportLogicSyntaxCheck(result, tr("双线圈检查"));
}

void MainWindow::reportLogicSyntaxCheck(
const LogicSyntaxCheckResult &result, const QString &action)
{
if (result.changed)
{
logic_editor_widget_->reloadLogic();
refreshProjectUi();
}
QString message = action + QStringLiteral(": ") + fromUtf8(result.message);
if (result.removedWireCells > 0U
|| result.removedVerticalConnections > 0U)
{
message += tr(";已规整 %1 格横线、%2 段竖线")
.arg(result.removedWireCells)
.arg(result.removedVerticalConnections);
}
appendOutputMessage(message, result.location);
ui_->outputDock->show();
ui_->outputDock->raise();
statusBar()->showMessage(message, 6000);
if (!result.valid && result.location.has_value())
{
focusLogicSyntaxLocation(*result.location);
}
updateEditActions();
}

void MainWindow::focusLogicSyntaxLocation(
const LogicSyntaxLocation &location)
{
if (logic_editor_service_.findRung(
location.logicId, location.rungId) == nullptr)
{
return;
}
current_logic_id_ = location.logicId;
ui_->editorTabWidget->setCurrentWidget(ui_->logicEditorTab);
refreshProjectUi();
logic_editor_widget_->focusSyntaxLocation(
location.rungId, location.column);
}

void MainWindow::deleteSelectedLogicObject()
{
const LogicEditorResult result = logic_editor_widget_->deleteSelected();
@@ -1902,6 +2057,16 @@ void MainWindow::loadProject()

void MainWindow::exportRuntimeProgram()
{
const LogicSyntaxCheckResult syntax =
logic_editor_service_.checkEnabledSyntax();
if (syntax.changed || !syntax.completed || !syntax.valid)
{
reportLogicSyntaxCheck(syntax, tr("导出前语法检查"));
}
if (!syntax.completed || !syntax.valid)
{
return;
}
std::string validation_error;
if (!project_service_.project().validateForRunning(
project_service_.projectLimits(), &validation_error))
@@ -2106,7 +2271,9 @@ void MainWindow::showProjectResult(
}
}

void MainWindow::appendOutputMessage(const QString &message)
void MainWindow::appendOutputMessage(
const QString &message,
const std::optional<LogicSyntaxLocation> &location)
{
const int maximum = application_settings_result_
.settings.projectLimits.maximumOutputMessages;
@@ -2114,7 +2281,19 @@ void MainWindow::appendOutputMessage(const QString &message)
{
delete ui_->outputList->takeItem(0);
}
ui_->outputList->addItem(message);
auto *item = new QListWidgetItem(message, ui_->outputList);
if (location.has_value())
{
item->setData(
kSyntaxLogicIdRole, fromUtf8(location->logicId));
item->setData(
kSyntaxRungIdRole, fromUtf8(location->rungId));
item->setData(kSyntaxColumnRole, location->column);
item->setToolTip(tr("双击定位到网络 %1,第 %2 行第 %3 列")
.arg(location->network)
.arg(location->row)
.arg(location->column));
}
ui_->outputList->scrollToBottom();
}

@@ -2147,6 +2326,19 @@ bool MainWindow::requestMode(ApplicationMode requested_mode)
return false;
}
}
const bool runtime_request = requested_mode == ApplicationMode::OfflineRunning
|| requested_mode == ApplicationMode::OnlineRunning;
if (runtime_request
&& (result.succeeded
|| result.error == ModeTransitionError::ProjectNotReady))
{
const LogicSyntaxCheckResult &syntax =
runtime_mode_service_.lastSyntaxCheck();
if (syntax.completed && (syntax.changed || !syntax.valid))
{
reportLogicSyntaxCheck(syntax, tr("运行前语法检查"));
}
}
if (!result.succeeded)
{
restoreCurrentModeAction();
@@ -2155,6 +2347,10 @@ bool MainWindow::requestMode(ApplicationMode requested_mode)
{
message += tr(";当前状态:%1").arg(plcStatusText(runtime_mode_service_));
}
if (!result.detail.empty())
{
message += QStringLiteral(": ") + fromUtf8(result.detail);
}
if (result.error == ModeTransitionError::SimulationStartFailed)
{
const LogicScanResult &error = requested_mode
@@ -2211,6 +2407,13 @@ void MainWindow::updateModeUi(const QString &message)
hmi_editor_widget_->setRuntimeActive(
policy.usesVirtualRegisters || policy.usesPlcRegisters);
logic_editor_widget_->setEditingEnabled(policy.allowsProjectEditing);
if (!policy.allowsProjectEditing)
{
const QSignalBlocker draw_blocker(ui_->mouseDrawWireAction);
const QSignalBlocker erase_blocker(ui_->mouseEraseWireAction);
ui_->mouseDrawWireAction->setChecked(false);
ui_->mouseEraseWireAction->setChecked(false);
}
if (mode == ApplicationMode::Editing)
{
alarm_service_.reset();


+ 24
- 14
app/src/ui/main_window.h Voir le fichier

@@ -13,10 +13,12 @@
#include "domain/runtime_state.h"
#include "services/plc_communication_gateway.h"
#include "services/application_settings.h"
#include "services/logic_editor_service.h"

#include <QMainWindow>

#include <memory>
#include <optional>
#include <variant>
#include <vector>

@@ -193,8 +195,24 @@ private:
void configureAndSetLogicOutput(const LogicNodeConfig &config);
/** 新增一个梯形图网络 */
void addLogicRung();
/** 在当前网络上方插入一行 */
void insertLogicRungAbove();
/** 在当前网络下方插入一行 */
void insertLogicRungBelow();
/** 删除当前选中的梯形图行 */
void deleteLogicRung();
/** 编辑当前网络的注释 */
void editSelectedRungComment();
/** 规整并检查当前梯形图 */
void runLogicSyntaxCheck();
/** 单独检查当前梯形图中的重复线圈输出 */
void runDoubleCoilCheck();
/** 将语法检查结果写入输出栏并定位错误 */
void reportLogicSyntaxCheck(
const LogicSyntaxCheckResult &result,
const QString &action);
/** 切换到并聚焦语法错误所在的逻辑位置 */
void focusLogicSyntaxLocation(const LogicSyntaxLocation &location);
/** 删除当前选中的逻辑节点或网络 */
void deleteSelectedLogicObject();
/** 打开 PLC 连接 */
@@ -216,7 +234,9 @@ private:
/** 在状态栏和输出面板显示工程操作结果 */
void showProjectResult(const QString &action, const QString &message, bool succeeded);
/** 向输出面板追加一条消息 */
void appendOutputMessage(const QString &message);
void appendOutputMessage(
const QString &message,
const std::optional<LogicSyntaxLocation> &location = std::nullopt);

/**
* @brief 请求服务层切换模式并同步界面状态
@@ -321,23 +341,13 @@ private:
std::vector<HmiControl> controls;
int pasteCount = 0;
};
struct LogicNodesClipboardData
struct LogicClipboardData
{
std::vector<LogicNode> nodes;
};
struct LogicOutputClipboardData
{
LogicNode output;
};
struct LogicRungClipboardData
{
LadderRung rung;
LogicClipboardFragment fragment;
};
using EditorClipboard = std::variant<
std::monostate,
HmiClipboardData,
LogicNodesClipboardData,
LogicOutputClipboardData,
LogicRungClipboardData>;
LogicClipboardData>;
EditorClipboard editor_clipboard_;
};

+ 78
- 10
app/src/ui/main_window.ui Voir le fichier

@@ -261,14 +261,20 @@
<addaction name="pasteAction"/>
<addaction name="separator"/>
<addaction name="deleteSelectionAction"/>
<addaction name="insertRungAboveAction"/>
<addaction name="insertRungBelowAction"/>
<addaction name="deleteRungAction"/>
<addaction name="deleteHorizontalWireAction"/>
<addaction name="deleteVerticalWireAction"/>
<addaction name="clearSelectionAction"/>
</widget>
<widget class="QMenu" name="runMenu">
<property name="title">
<string>运行(&amp;R)</string>
</property>
<property name="title">
<string>运行(&amp;R)</string>
</property>
<addaction name="syntaxCheckAction"/>
<addaction name="doubleCoilCheckAction"/>
<addaction name="separator"/>
<addaction name="editingModeAction"/>
<addaction name="offlineModeAction"/>
<addaction name="onlineModeAction"/>
@@ -344,6 +350,12 @@
<bool>true</bool>
</attribute>
<addaction name="addRungAction"/>
<addaction name="insertRungAboveAction"/>
<addaction name="insertRungBelowAction"/>
<addaction name="deleteRungAction"/>
<addaction name="mouseDrawWireAction"/>
<addaction name="mouseEraseWireAction"/>
<addaction name="separator"/>
<addaction name="insertHorizontalWireAction"/>
<addaction name="insertVerticalWireAction"/>
<addaction name="parallelInsertAction"/>
@@ -353,6 +365,8 @@
<addaction name="addNormallyClosedAction"/>
<addaction name="addNormalCoilAction"/>
<addaction name="editRungCommentAction"/>
<addaction name="separator"/>
<addaction name="syntaxCheckAction"/>
</widget>
<widget class="QDockWidget" name="projectDock">
<property name="minimumSize">
@@ -1139,12 +1153,47 @@
<string>在梯形图末尾新增网络</string>
</property>
</action>
<action name="insertRungAboveAction">
<property name="text"><string>上方插入行</string></property>
<property name="toolTip"><string>在当前选中行的上方插入一条空白行,并保持竖线连接</string></property>
</action>
<action name="insertRungBelowAction">
<property name="text"><string>下方插入行</string></property>
<property name="toolTip"><string>在当前选中行的下方插入一条空白行,并保持竖线连接</string></property>
</action>
<action name="deleteRungAction">
<property name="text"><string>删除行</string></property>
<property name="toolTip"><string>删除当前选中行;上下同列竖线会自动合并</string></property>
<property name="shortcut"><string>Shift+Delete</string></property>
</action>
<action name="mouseDrawWireAction">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>鼠标画线</string>
</property>
<property name="toolTip">
<string>拖动鼠标画横线或竖线;横线按网格补齐,竖线按相邻行分段连接</string>
</property>
</action>
<action name="mouseEraseWireAction">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>鼠标删线</string>
</property>
<property name="toolTip">
<string>拖动鼠标删除经过的横线格;点击竖线只删除命中的连接段</string>
</property>
</action>
<action name="insertHorizontalWireAction">
<property name="text">
<string>横线</string>
</property>
<property name="toolTip">
<string>在所选条件或横线后插入横线;没有选择时追加到当前网络</string>
<string>在当前条件格插入横线并自动右移;空逻辑会先创建首行</string>
</property>
<property name="shortcut">
<string>F11</string>
@@ -1155,7 +1204,7 @@
<string>竖线</string>
</property>
<property name="toolTip">
<string>为所选连续逻辑范围建立横线旁路和竖线连接</string>
<string>在当前行与下一行的选中列边界建立一段竖线连接</string>
</property>
<property name="shortcut">
<string>F12</string>
@@ -1177,7 +1226,7 @@
<string>删除竖线</string>
</property>
<property name="toolTip">
<string>删除画布中选中的竖线及其对应并联支路</string>
<string>删除画布中选中的竖线连接,让网络在此处拆分</string>
</property>
<property name="shortcut">
<string>Shift+F12</string>
@@ -1196,7 +1245,7 @@
<string>常开</string>
</property>
<property name="toolTip">
<string>在选中的空网格或横线处添加常开触点;选中条件时在其后插入</string>
<string>在当前条件格添加常开触点并自动右移;空逻辑会先创建首行</string>
</property>
</action>
<action name="addNormallyClosedAction">
@@ -1204,7 +1253,7 @@
<string>常闭</string>
</property>
<property name="toolTip">
<string>在选中的空网格或横线处添加常闭触点;选中条件时在其后插入</string>
<string>在当前条件格添加常闭触点并自动右移;空逻辑会先创建首行</string>
</property>
</action>
<action name="addRisingEdgeAction">
@@ -1277,10 +1326,29 @@
</action>
<action name="deleteLogicAction">
<property name="text">
<string>删除节点/网络</string>
<string>删除所选对象</string>
</property>
<property name="toolTip">
<string>删除选中的逻辑节点、横线格或竖线;整行请使用删除行</string>
</property>
</action>
<action name="syntaxCheckAction">
<property name="text">
<string>语法检查</string>
</property>
<property name="toolTip">
<string>规整当前梯形图并检查输出路径</string>
</property>
<property name="shortcut">
<string>Ctrl+G</string>
</property>
</action>
<action name="doubleCoilCheckAction">
<property name="text">
<string>双线圈检查</string>
</property>
<property name="toolTip">
<string>删除选中的逻辑节点或网络</string>
<string>检查当前梯形图中的重复 M 线圈输出</string>
</property>
</action>
<action name="editingModeAction">


+ 2
- 4
app/src/ui/runtime_monitor_widget.cpp Voir le fichier

@@ -231,8 +231,7 @@ void RuntimeMonitorWidget::setLogicTrace(
latest_trace_ = trace;
latest_fault_node_id_ = fault_node_id;
has_logic_trace_ = true;
logic_view_->setRuntimeTrace(
trace.forLogic(selected_logic_id_), fault_node_id);
logic_view_->setRuntimeTrace(trace, fault_node_id);
}

FreeMonitorWidget *RuntimeMonitorWidget::freeMonitorWidget() const
@@ -265,7 +264,6 @@ void RuntimeMonitorWidget::selectRuntimeLogic(const std::string &logic_id)
logic_view_->reloadLogic();
if (has_logic_trace_)
{
logic_view_->setRuntimeTrace(
latest_trace_.forLogic(logic_id), latest_fault_node_id_);
logic_view_->setRuntimeTrace(latest_trace_, latest_fault_node_id_);
}
}

+ 2
- 3
app/src/ui/runtime_panel_controller.cpp Voir le fichier

@@ -313,8 +313,7 @@ void RuntimePanelController::updateSimulationUi(bool report_fault)
hmi_navigation_service_.currentPageId(), logic_id);
}
logic_editor_widget_.setRuntimeTrace(
runtime_mode_service_.offlineSimulationService()
.traceSnapshot().forLogic(logic_id),
runtime_mode_service_.offlineSimulationService().traceSnapshot(),
error.nodeId);
if (runtime_monitor_widget_ != nullptr)
{
@@ -393,7 +392,7 @@ void RuntimePanelController::handleScanCompleted()
const LogicTraceSnapshot &trace = offline_running
? runtime_mode_service_.offlineSimulationService().traceSnapshot()
: runtime_mode_service_.onlineLogicMonitorService().traceSnapshot();
logic_editor_widget_.setRuntimeTrace(trace.forLogic(logic_id));
logic_editor_widget_.setRuntimeTrace(trace);
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setLogicTrace(trace);


+ 27
- 0
app/src/ui/toolbar_icon_factory.cpp Voir le fichier

@@ -351,6 +351,23 @@ QPixmap renderIcon(UiIcon icon, int size)
painter.drawLine(QPointF(7, 12), QPointF(17, 12));
break;
}
case UiIcon::MouseDrawWire:
{
painter.drawLine(QPointF(3, 16), QPointF(18, 16));
painter.drawLine(QPointF(18, 16), QPointF(18, 6));
painter.setPen(iconPen(kAccent, 2.0));
painter.drawLine(QPointF(6, 12), QPointF(6, 20));
painter.drawLine(QPointF(2, 16), QPointF(10, 16));
break;
}
case UiIcon::MouseEraseWire:
{
painter.drawLine(QPointF(3, 16), QPointF(21, 16));
painter.drawLine(QPointF(15, 16), QPointF(15, 6));
painter.setPen(iconPen(kDanger, 2.2));
painter.drawLine(QPointF(6, 20), QPointF(20, 5));
break;
}
case UiIcon::HorizontalWire:
{
painter.drawLine(QPointF(3, 12), QPointF(21, 12));
@@ -460,6 +477,16 @@ QPixmap renderIcon(UiIcon icon, int size)
painter.drawEllipse(QPointF(18, 12), 1.7, 1.7);
break;
}
case UiIcon::SyntaxCheck:
{
painter.drawLine(QPointF(3, 3), QPointF(3, 21));
painter.drawLine(QPointF(3, 8), QPointF(13, 8));
painter.drawLine(QPointF(3, 16), QPointF(10, 16));
painter.setPen(iconPen(kAccent, 2.0));
painter.drawLine(QPointF(11, 15), QPointF(15, 19));
painter.drawLine(QPointF(15, 19), QPointF(22, 9));
break;
}
case UiIcon::HmiPage:
{
painter.drawRoundedRect(QRectF(2, 4, 20, 14), 1.5, 1.5);


+ 3
- 0
app/src/ui/toolbar_icon_factory.h Voir le fichier

@@ -36,6 +36,8 @@ enum class UiIcon
AlarmSettings, // 报警配置
Delete, // 删除
AddRung, // 新增网络
MouseDrawWire, // 鼠标画线模式
MouseEraseWire, // 鼠标删线模式
HorizontalWire, // 横线
VerticalWire, // 竖线
ParallelBranch, // 并联支路
@@ -51,6 +53,7 @@ enum class UiIcon
Subtract, // SUB 指令
Compare, // 比较指令
Comment, // 注释
SyntaxCheck, // 梯形图语法检查与规整
More, // 更多操作
HmiPage, // HMI 页面
Logic, // 控制逻辑


+ 113
- 229
app/tests/domain_tests.cpp Voir le fichier

@@ -13,7 +13,6 @@
#include <cmath>
#include <cstdint>
#include <exception>
#include <functional>
#include <iostream>
#include <limits>
#include <set>
@@ -24,30 +23,6 @@ namespace {

using TestSupport::require;

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

void testRegisterAddressBoundaries()
{
// 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径
@@ -336,18 +311,20 @@ Project makeValidProject()
LadderRung rung;
rung.id = "rung-1";
rung.name = "Network 1";
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);
for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
rung.cells.push_back({
"start-cell-" + std::to_string(column),
column == 0 ? LadderCellKind::Node : LadderCellKind::Wire,
column == 0 ? std::optional<LogicNode>{contact} : std::nullopt});
}
rung.output = coil;
logic.rungs.push_back(rung);

Project project;
project.metadata = {"sample-project", "Sample project", "1.0"};
project.metadata = {"sample-project", "Sample project", "2.0"};
project.hmiPages.push_back(page);
project.initialHmiPageId = page.id;
project.controlLogics.push_back(logic);
@@ -402,8 +379,10 @@ void testMultiPageAndLogicDomainRules()
disabled_draft.id = "draft-logic";
disabled_draft.name = "Draft logic";
disabled_draft.enabled = false;
disabled_draft.rungs.push_back(
{"rung-1", "Draft network", {}, std::nullopt, std::nullopt});
LadderRung draft_rung;
draft_rung.id = "rung-1";
draft_rung.name = "Draft network";
disabled_draft.rungs.push_back(std::move(draft_rung));
project.controlLogics.push_back(disabled_draft);
require(project.validateForRunning(),
"a disabled draft logic must not block offline running");
@@ -521,60 +500,10 @@ void testQuantityBoundaries()
project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1;
require(!project.validate(), "an HMI page height of 199 must be rejected");

ConditionExpression leaf = ConditionExpression::fromNode({
"depth-node-0",
ContactNodeConfig{RegisterAddress{RegisterArea::M, 0}},
true});
std::function<ConditionExpression(int, int *)> makeNested =
[&makeNested](int depth, int *next_address)
{
if (depth == 1)
{
const int address = (*next_address)++;
return ConditionExpression::fromNode({
"depth-node-" + std::to_string(address),
ContactNodeConfig{RegisterAddress{RegisterArea::M, address}},
true});
}
const int address = (*next_address)++;
ConditionExpression nested = makeNested(depth - 1, next_address);
ConditionExpression sibling = ConditionExpression::fromNode({
"depth-node-" + std::to_string(address),
ContactNodeConfig{RegisterAddress{RegisterArea::M, address}},
true});
ConditionExpression expression;
expression.id = "depth-expression-" + std::to_string(address);
expression.kind = depth % 2 == 0
? ConditionExpressionKind::Parallel
: ConditionExpressionKind::Series;
if (expression.kind == ConditionExpressionKind::Parallel
&& expressionColumns(nested) > 1)
{
ConditionExpression padded_sibling;
padded_sibling.id = "depth-padding-" + std::to_string(address);
padded_sibling.kind = ConditionExpressionKind::Series;
padded_sibling.children = {
std::move(sibling),
ConditionExpression::fromWire(
"depth-wire-" + std::to_string(address),
expressionColumns(nested) - 1)};
sibling = std::move(padded_sibling);
}
expression.children = {std::move(nested), std::move(sibling)};
return expression;
};
int next_address = 1;
ConditionExpression maximum_depth = makeNested(
static_cast<int>(ProjectLimits::kMaximumExpressionDepth), &next_address);
require(maximum_depth.validate(),
"an expression depth at the configured limit must be accepted");
ConditionExpression excessive_depth = makeNested(
static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1,
&next_address);
require(!excessive_depth.validate(),
"an expression depth above the configured limit must be rejected");

(void)leaf;
project = makeValidProject();
project.controlLogics.front().rungs.front().cells.pop_back();
require(!project.validate(),
"a ladder row with fewer than ten cells must be rejected");
}

void testLogicNodeConfigurationBoundaries()
@@ -674,151 +603,106 @@ void testDataInstructionBoundaries()

void testLadderLogicBoundaries()
{
LogicNode stop;
stop.id = "stop";
stop.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 1},
ContactMode::NormallyClosed};

LogicNode start;
start.id = "start";
start.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen};

LogicNode run_contact;
run_contact.id = "run-contact";
run_contact.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 1},
ContactMode::NormallyOpen};

LogicNode coil;
coil.id = "run-coil";
coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 1},
CoilMode::Normal};

ControlLogic logic;
logic.id = "hold-logic";
logic.name = "Hold logic";
ConditionExpression start_parallel;
start_parallel.id = "parallel-start";
start_parallel.kind = ConditionExpressionKind::Parallel;
start_parallel.children = {
ConditionExpression::fromNode(start),
ConditionExpression::fromNode(run_contact)};
ConditionExpression root;
root.id = "series-root";
root.kind = ConditionExpressionKind::Series;
root.children = {
ConditionExpression::fromNode(stop),
start_parallel,
ConditionExpression::fromWire("hold-output-wire", 8)};
LadderRung rung;
rung.id = "rung-1";
rung.name = "Self hold";
rung.condition = root;
rung.output = coil;
logic.rungs.push_back(rung);
require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid");

ConditionExpression wire = ConditionExpression::fromWire("wire-1", 2);
require(wire.validate() && wire.validateForRunning()
&& wire.wire->columnSpan == 2,
"a configured horizontal wire must be a valid runnable expression leaf");
ConditionExpression invalid_wire = ConditionExpression::fromWire("wire-invalid", 0);
require(!invalid_wire.validate(), "a zero-column horizontal wire must be rejected");
invalid_wire = ConditionExpression::fromWire(
"wire-too-wide", WireSegment::kMaximumColumnSpan + 1);
require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected");

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

ConditionExpression maximum_columns;
maximum_columns.id = "maximum-columns";
maximum_columns.kind = ConditionExpressionKind::Series;
for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
logic.id = "grid-logic";
logic.name = "Grid logic";
LadderRung upper;
upper.id = "rung-1";
upper.name = "Row 1";
LadderRung lower;
lower.id = "rung-2";
lower.name = "Row 2";
for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
LogicNode node;
node.id = "column-" + std::to_string(column + 1);
node.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, column},
ContactMode::NormallyOpen};
maximum_columns.children.push_back(
ConditionExpression::fromNode(std::move(node)));
upper.cells.push_back({
"upper-cell-" + std::to_string(column),
LadderCellKind::Wire,
std::nullopt});
lower.cells.push_back({
"lower-cell-" + std::to_string(column),
LadderCellKind::Gap,
std::nullopt});
}
require(maximum_columns.validate(),
"ten condition columns must be accepted");
LogicNode extra_column;
extra_column.id = "column-11";
extra_column.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 10},
ContactMode::NormallyOpen};
maximum_columns.children.push_back(
ConditionExpression::fromNode(std::move(extra_column)));
require(!maximum_columns.validate(),
"an eleventh condition column must be rejected");

ConditionExpression wired_series;
wired_series.id = "wired-series";
wired_series.kind = ConditionExpressionKind::Series;
wired_series.children = {
ConditionExpression::fromNode(stop),
ConditionExpression::fromWire("wire-series"),
start_parallel};
require(wired_series.validateForRunning(),
"a wire must preserve a valid structured series expression");

logic.rungs.front().condition->children.front() =
ConditionExpression::fromNode(coil);
require(!logic.validate(), "a ladder condition expression must reject coils");
logic.rungs.front().condition = root;
logic.rungs.front().output = start;
require(!logic.validate(), "a ladder output must be a coil");
upper.cells[0].kind = LadderCellKind::Node;
upper.cells[0].node = LogicNode{
"start",
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen},
true};
upper.output = LogicNode{
"run-coil",
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 1}, CoilMode::Normal},
true};
logic.rungs = {upper, lower};
logic.verticalConnections = {
{"vertical-left", "rung-1", "rung-2", 0},
{"vertical-right", "rung-1", "rung-2", 1}};
require(logic.validate() && logic.validateForRunning(),
"a ten-cell grid with adjacent vertical edges must be valid");

logic.rungs.front().cells[5].kind = LadderCellKind::Gap;
std::string connectivity_error;
require(
logic.validate() && !logic.validateForRunning(&connectivity_error)
&& connectivity_error.find("第 1 行") != std::string::npos
&& connectivity_error.find("第 6 列") != std::string::npos,
"a disconnected output must report its visual row and break column");

logic.rungs.front() = upper;
logic.rungs.front().cells[5].kind = LadderCellKind::Gap;
for (LadderCell &cell : logic.rungs.back().cells)
{
cell.kind = LadderCellKind::Wire;
cell.node.reset();
}
logic.verticalConnections = {
{"vertical-left", "rung-1", "rung-2", 0},
{"vertical-bypass", "rung-1", "rung-2", 6}};
require(
logic.validateForRunning(),
"a vertical branch that bypasses a gap must keep the output reachable");

logic.rungs = {upper, lower};
logic.verticalConnections = {
{"vertical-left", "rung-1", "rung-2", 0},
{"vertical-right", "rung-1", "rung-2", 1}};
logic.rungs.front().cells.front().node = LogicNode{
"invalid-coil",
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 2}, CoilMode::Normal},
true};
require(!logic.validate(), "a condition cell must reject output nodes");

logic.rungs.front() = upper;
logic.rungs.front().output = LogicNode{
"invalid-contact",
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 2}, ContactMode::NormallyOpen},
true};
require(!logic.validate(), "the output slot must reject condition nodes");

logic.rungs.front() = upper;
logic.rungs.front().output.reset();
require(logic.validate(), "incomplete ladder may remain in an editable draft");
require(!logic.validateForRunning(),
"conditions without an output must block runtime validation");

LadderRung empty_rung{
"rung-empty", "Empty network", {}, std::nullopt, std::nullopt};
require(empty_rung.validate(), "an empty editing network must be valid");

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

logic.rungs.front().output = coil;
logic.rungs.front().condition = root;
logic.rungs.front().condition->children.at(1).children.at(1).node->id = start.id;
require(!logic.validate(), "logic node ids must be unique");

ConditionExpression nested_parallel;
nested_parallel.id = "parallel-nested";
nested_parallel.kind = ConditionExpressionKind::Parallel;
nested_parallel.children = {
ConditionExpression::fromNode(start),
root};
require(!nested_parallel.validate(),
"parallel branches with unequal explicit widths must be rejected");
ConditionExpression padded_start;
padded_start.id = "padded-start";
padded_start.kind = ConditionExpressionKind::Series;
padded_start.children = {
ConditionExpression::fromNode(start),
ConditionExpression::fromWire("nested-parallel-wire", 9)};
nested_parallel.children.front() = std::move(padded_start);
require(nested_parallel.validate(),
"parallel branches padded with explicit wires must be valid");
require(logic.validate() && logic.validateForRunning(),
"a row without an output may act as a connected branch");

logic.rungs.front() = upper;
logic.rungs.front().cells[1].id = logic.rungs.front().cells[0].id;
require(!logic.validate(), "cell ids must be unique within a logic");

logic.rungs.front() = upper;
logic.verticalConnections.front().lowerRungId = "missing-rung";
require(!logic.validate(), "vertical edges must reference adjacent rows");

logic.verticalConnections = {
{"vertical-left", "rung-1", "rung-2", 0},
{"vertical-copy", "rung-1", "rung-2", 0}};
require(!logic.validate(),
"one row boundary must not contain duplicate vertical edges");
}

void testModelsValidateBindingsAndIdentifiers()


+ 1308
- 1659
app/tests/logic_editor_service_tests.cpp
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 226
- 124
app/tests/offline_simulation_service_tests.cpp Voir le fichier

@@ -101,58 +101,21 @@ LadderRung rung(const std::string &id,
LadderRung result;
result.id = id;
result.name = id;
std::vector<ConditionExpression> series_children;
for (std::size_t index = 0; index < stages.size(); ++index)
result.cells.reserve(ProjectLimits::kMaximumConditionColumns);
for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
std::vector<ConditionExpression> parallel_children;
for (const LogicNode &node : stages[index])
LadderCell cell;
cell.id = id + "-cell-" + std::to_string(column);
cell.kind = LadderCellKind::Wire;
if (column < static_cast<int>(stages.size())
&& !stages[static_cast<std::size_t>(column)].empty())
{
parallel_children.push_back(ConditionExpression::fromNode(node));
}
if (parallel_children.size() == 1U)
{
series_children.push_back(std::move(parallel_children.front()));
}
else
{
ConditionExpression parallel;
parallel.id = id + "-parallel-" + std::to_string(index);
parallel.kind = ConditionExpressionKind::Parallel;
parallel.children = std::move(parallel_children);
series_children.push_back(std::move(parallel));
}
}
if (series_children.size() == 1U)
{
result.condition = std::move(series_children.front());
}
else
{
ConditionExpression series;
series.id = id + "-series";
series.kind = ConditionExpressionKind::Series;
series.children = std::move(series_children);
result.condition = std::move(series);
}
const int occupied_columns = static_cast<int>(stages.size());
if (occupied_columns < ProjectLimits::kMaximumConditionColumns)
{
ConditionExpression wire = ConditionExpression::fromWire(
id + "-output-wire",
ProjectLimits::kMaximumConditionColumns - occupied_columns);
if (result.condition->kind == ConditionExpressionKind::Series)
{
result.condition->children.push_back(std::move(wire));
}
else
{
ConditionExpression series;
series.id = id + "-explicit-series";
series.kind = ConditionExpressionKind::Series;
series.children.push_back(std::move(*result.condition));
series.children.push_back(std::move(wire));
result.condition = std::move(series);
cell.kind = LadderCellKind::Node;
cell.node = stages[static_cast<std::size_t>(column)].front();
}
result.cells.push_back(std::move(cell));
}
result.output = output;
return result;
@@ -191,58 +154,42 @@ std::int16_t readWord(RegisterRepository &repository, int address)
return result.value;
}

void testNestedSeriesParallelExpression()
void testParallelRowsAndColumnPropagation()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
ConditionExpression nested_series;
nested_series.id = "nested-series";
nested_series.kind = ConditionExpressionKind::Series;
nested_series.children = {
ConditionExpression::fromNode(contact("b", 1)),
ConditionExpression::fromNode(contact("c", 2))};
ConditionExpression padded_a;
padded_a.id = "padded-a";
padded_a.kind = ConditionExpressionKind::Series;
padded_a.children = {
ConditionExpression::fromNode(contact("a", 0)),
ConditionExpression::fromWire("a-branch-wire", 1)};
ConditionExpression parallel;
parallel.id = "root-parallel";
parallel.kind = ConditionExpressionKind::Parallel;
parallel.children = {std::move(padded_a), nested_series};
ConditionExpression root;
root.id = "nested-output-series";
root.kind = ConditionExpressionKind::Series;
root.children = {
std::move(parallel),
ConditionExpression::fromWire("nested-output-wire", 8)};
LadderRung nested_rung;
nested_rung.id = "nested-rung";
nested_rung.name = "nested-rung";
nested_rung.condition = root;
nested_rung.output = coil("nested-output", 10);
const ControlLogic program = logic({nested_rung});
LadderRung first = rung(
"first", {{contact("a", 0)}},
coil("first-output", 10));
LadderRung second = rung(
"second", {{contact("c", 2)}},
coil("second-output", 11));
second.output.reset();
ControlLogic program = logic({first, second});
program.verticalConnections = {
{"left-bridge", "first", "second", 0},
{"right-bridge", "first", "second", 1}};

writeBit(repository, 1, true);
writeBit(repository, 2, true);
writeBit(repository, 0, true);
LogicTraceSnapshot trace;
require(executor.executeScan({program}, repository, &trace).succeeded,
"nested expression scan must succeed");
require(readBit(repository, 10), "B AND C branch must energize A OR (B AND C)");
require(trace.expressionValues.at("nested-series")
&& trace.expressionValues.at("root-parallel")
&& trace.rungValues.at("nested-rung"),
"scan trace must expose active nested expression and rung values");
"parallel row scan must succeed");
require(readBit(repository, 10), "the first row must energize its output");

writeBit(repository, 2, false);
writeBit(repository, 0, false);
writeBit(repository, 2, true);
require(executor.executeScan({program}, repository, &trace).succeeded,
"nested false scan must succeed");
require(!readBit(repository, 10), "incomplete B AND C branch must be false");
writeBit(repository, 0, true);
"second parallel row scan must succeed");
require(readBit(repository, 10),
"the lower branch must feed the shared output through the right edge");
require(trace.verticalConnectionValues.at("left-bridge"),
"vertical connection trace must expose the boundary power");

program.verticalConnections.pop_back();
require(executor.executeScan({program}, repository, &trace).succeeded,
"alternate branch scan must succeed");
require(readBit(repository, 10), "A branch must independently energize output");
"the split network scan must succeed");
require(!readBit(repository, 10),
"deleting the right edge must split the branch from the output");
}

void testUnconditionalCoil()
@@ -252,8 +199,15 @@ void testUnconditionalCoil()
LadderRung unconditional;
unconditional.id = "unconditional-rung";
unconditional.name = "unconditional-rung";
unconditional.condition = ConditionExpression::fromWire(
"unconditional-wire", ProjectLimits::kMaximumConditionColumns);
for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
unconditional.cells.push_back({
"unconditional-cell-" + std::to_string(column),
LadderCellKind::Wire,
std::nullopt});
}
unconditional.output = coil("unconditional-coil", 10);
const ControlLogic program = logic({unconditional});

@@ -264,27 +218,52 @@ void testUnconditionalCoil()
"a full-width wire network scan must succeed");
require(readBit(repository, 10),
"a full-width wire network must energize its coil as a constant-true rung");
require(trace.rungValues.at("unconditional-rung")
&& trace.nodePowerValues.at("unconditional-coil"),
require(trace.rungValues.at("unconditional-rung"),
"an unconditional rung must report energized power flow");
}

void testEnabledEmptyRowIsRejectedBeforeScanning()
{
SoftwareLogicExecutor executor;
LadderRung empty;
empty.id = "empty-enabled-rung";
empty.name = "Empty enabled rung";
const ControlLogic program = logic({empty});
const LogicScanResult validation = executor.validate({program});
require(!validation.succeeded
&& validation.error == LogicScanError::InvalidLogic,
"an enabled empty row must be rejected before fixed-grid scanning");
}

void testWirePassThroughAndPowerTrace()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
ConditionExpression root;
root.id = "wire-series";
root.kind = ConditionExpressionKind::Series;
root.children = {
ConditionExpression::fromNode(contact("wire-input", 0)),
ConditionExpression::fromWire("wire-segment", 2),
ConditionExpression::fromNode(contact("wire-output", 1)),
ConditionExpression::fromWire("wire-output-padding", 6)};
LadderRung wired_rung;
wired_rung.id = "wired-rung";
wired_rung.name = "wired-rung";
wired_rung.condition = root;
for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
wired_rung.cells.push_back({
"wire-cell-" + std::to_string(column),
LadderCellKind::Gap,
std::nullopt});
}
wired_rung.cells[0].kind = LadderCellKind::Node;
wired_rung.cells[0].node = contact("wire-input", 0);
wired_rung.cells[1].kind = LadderCellKind::Wire;
wired_rung.cells[2].kind = LadderCellKind::Wire;
wired_rung.cells[3].kind = LadderCellKind::Node;
wired_rung.cells[3].node = contact("wire-output", 1);
for (int column = 4;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
wired_rung.cells[static_cast<std::size_t>(column)].kind =
LadderCellKind::Wire;
}
wired_rung.output = coil("wired-coil", 10);
const ControlLogic program = logic({wired_rung});

@@ -294,17 +273,17 @@ void testWirePassThroughAndPowerTrace()
"wire expression scan must succeed");
require(!readBit(repository, 10),
"a horizontal wire must not bypass a false upstream series contact");
require(trace.expressionValues.at("wire-segment")
&& !trace.expressionInputValues.at("wire-segment")
&& !trace.expressionPowerValues.at("wire-segment"),
require(trace.cellValues.at("wire-cell-1")
&& !trace.cellInputPowerValues.at("wire-cell-1")
&& !trace.cellPowerValues.at("wire-cell-1"),
"a wire must remain logically true without showing false upstream power");

writeBit(repository, 0, true);
require(executor.executeScan({program}, repository, &trace).succeeded,
"powered wire expression scan must succeed");
require(readBit(repository, 10)
&& trace.expressionInputValues.at("wire-segment")
&& trace.expressionPowerValues.at("wire-segment"),
&& trace.cellInputPowerValues.at("wire-cell-1")
&& trace.cellPowerValues.at("wire-cell-1"),
"a powered horizontal wire must pass current to the downstream contact");
}

@@ -312,12 +291,20 @@ void testSeriesParallelContactsAndSequentialVisibility()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
const ControlLogic program = logic({
rung("rung-1",
{{contact("start", 0), contact("alternate", 1)},
{contact("stop", 2, ContactMode::NormallyClosed)}},
coil("run", 3)),
rung("rung-2", {{contact("run-feedback", 3)}}, coil("downstream", 4))});
LadderRung primary = rung(
"rung-1",
{{contact("start", 0)},
{contact("stop-primary", 2, ContactMode::NormallyClosed)}},
coil("run", 3));
LadderRung alternate = rung(
"rung-2",
{{contact("alternate", 1)},
{contact("stop-alternate", 2, ContactMode::NormallyClosed)}},
coil("downstream", 4));
ControlLogic program = logic({primary, alternate});
program.verticalConnections = {
{"parallel-left", "rung-1", "rung-2", 0},
{"parallel-right", "rung-1", "rung-2", 2}};

writeBit(repository, 1, true);
require(executor.executeScan({program}, repository).succeeded,
@@ -333,6 +320,106 @@ void testSeriesParallelContactsAndSequentialVisibility()
require(!readBit(repository, 4), "downstream normal coil must follow the new value");
}

void testMotorForwardReverseSelfHoldAndInterlockTruthTable()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
LadderRung forward = rung(
"forward-rung",
{{contact("forward-start", 0)},
{contact("forward-stop", 2, ContactMode::NormallyClosed)},
{contact("forward-interlock", 11, ContactMode::NormallyClosed)}},
coil("forward-coil", 10));
LadderRung forward_hold = rung(
"forward-hold-rung",
{{contact("forward-hold", 10)},
{contact("forward-hold-stop", 2, ContactMode::NormallyClosed)},
{contact("forward-hold-interlock", 11, ContactMode::NormallyClosed)}},
coil("unused-forward-branch-output", 20));
forward_hold.output.reset();
LadderRung reverse = rung(
"reverse-rung",
{{contact("reverse-start", 1)},
{contact("reverse-stop", 2, ContactMode::NormallyClosed)},
{contact("reverse-interlock", 10, ContactMode::NormallyClosed)}},
coil("reverse-coil", 11));
LadderRung reverse_hold = rung(
"reverse-hold-rung",
{{contact("reverse-hold", 11)},
{contact("reverse-hold-stop", 2, ContactMode::NormallyClosed)},
{contact("reverse-hold-interlock", 10, ContactMode::NormallyClosed)}},
coil("unused-reverse-branch-output", 21));
reverse_hold.output.reset();
ControlLogic program = logic({
forward, forward_hold, reverse, reverse_hold});
program.id = "motor-control-logic";
program.verticalConnections = {
{"forward-left", "forward-rung", "forward-hold-rung", 0},
{"forward-right", "forward-rung", "forward-hold-rung", 10},
{"reverse-left", "reverse-rung", "reverse-hold-rung", 0},
{"reverse-right", "reverse-rung", "reverse-hold-rung", 10}};

LogicTraceSnapshot trace;
const auto scan = [&]
{
require(executor.executeScan({program}, repository, &trace).succeeded,
"the motor truth-table scan must succeed");
};

scan();
require(!readBit(repository, 10) && !readBit(repository, 11),
"both motor directions must be off in the stopped state");
require(
trace.cellInputPowerValues.at("forward-rung-cell-0")
&& !trace.cellPowerValues.at("forward-rung-cell-0")
&& !trace.rungValues.at("forward-rung"),
"a false start contact must keep only its left terminal energized");

writeBit(repository, 0, true);
scan();
require(readBit(repository, 10) && !readBit(repository, 11),
"the forward start input must energize only M10");
require(
trace.cellInputPowerValues.at("forward-rung-cell-0")
&& trace.cellPowerValues.at("forward-rung-cell-0")
&& trace.verticalConnectionValues.at("forward-right")
&& trace.nodeValues.at("forward-coil"),
"the forward trace must reach the output and connected branch edge");

writeBit(repository, 0, false);
scan();
require(readBit(repository, 10) && !readBit(repository, 11),
"M10 must remain energized through the forward self-hold branch");

writeBit(repository, 1, true);
scan();
require(readBit(repository, 10) && !readBit(repository, 11),
"the reverse start input must be blocked while forward is active");

writeBit(repository, 2, true);
scan();
require(!readBit(repository, 10) && !readBit(repository, 11),
"the stop input must release both direction outputs");

writeBit(repository, 1, false);
writeBit(repository, 2, false);
scan();
writeBit(repository, 1, true);
scan();
require(!readBit(repository, 10) && readBit(repository, 11),
"the reverse start input must energize only M11 after stopping");

writeBit(repository, 1, false);
scan();
require(!readBit(repository, 10) && readBit(repository, 11),
"M11 must remain energized through the reverse self-hold branch");

writeBit(repository, 0, true);
scan();
require(!readBit(repository, 10) && readBit(repository, 11),
"the forward start input must be blocked while reverse is active");
}

void testAllComparisons()
{
const std::array<ComparisonOperator, 6> operations{
@@ -412,8 +499,10 @@ void testMultipleLogicScanOrderAndTraceIsolation()
disabled_draft.id = "logic-draft";
disabled_draft.name = "Draft";
disabled_draft.enabled = false;
disabled_draft.rungs.push_back(
{"rung-1", "Draft", {}, std::nullopt, std::nullopt});
LadderRung draft_rung;
draft_rung.id = "rung-1";
draft_rung.name = "Draft";
disabled_draft.rungs.push_back(std::move(draft_rung));
require(executor.validate({first, disabled_draft}).succeeded,
"a disabled incomplete logic module must not block offline execution");
}
@@ -614,11 +703,22 @@ void testHmiSimulationClosedLoop()
indicator.bounds = {0, 40, 80, 30};
indicator.text = "run";
indicator.binding = RegisterAddress{RegisterArea::M, 2};
const ControlLogic program = logic({
rung("hold-rung",
{{contact("stop", 1, ContactMode::NormallyClosed)},
{contact("start", 0), contact("feedback", 2)}},
coil("run", 2))});
LadderRung start_path = rung(
"hold-start",
{{contact("stop", 1, ContactMode::NormallyClosed)},
{contact("start", 0)}},
coil("run", 2));
LadderRung feedback_path = rung(
"hold-feedback",
{{contact("feedback-padding", 4000)}, {contact("feedback", 2)}},
coil("unused-output", 4000));
feedback_path.cells[0].kind = LadderCellKind::Gap;
feedback_path.cells[0].node.reset();
feedback_path.output.reset();
ControlLogic program = logic({start_path, feedback_path});
program.verticalConnections = {
{"hold-left", "hold-start", "hold-feedback", 1},
{"hold-right", "hold-start", "hold-feedback", 2}};

require(hmi.operateButton(start, HmiButtonEvent::Pressed).succeeded,
"HMI start button press must write virtual M");
@@ -779,9 +879,11 @@ int main(int argc, char *argv[])
try
{
testSeriesParallelContactsAndSequentialVisibility();
testNestedSeriesParallelExpression();
testParallelRowsAndColumnPropagation();
testUnconditionalCoil();
testEnabledEmptyRowIsRejectedBeforeScanning();
testWirePassThroughAndPowerTrace();
testMotorForwardReverseSelfHoldAndInterlockTruthTable();
testAllComparisons();
testSetResetAndDisabledLogic();
testMultipleLogicScanOrderAndTraceIsolation();


+ 14
- 10
app/tests/performance_tests.cpp Voir le fichier

@@ -36,20 +36,24 @@ ControlLogic makeLogic(int index)
LadderRung rung;
rung.id = "rung-" + std::to_string(index);
rung.name = rung.id;
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);
for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
rung.cells.push_back({
rung.id + "-cell-" + std::to_string(column),
column == 0 ? LadderCellKind::Node : LadderCellKind::Wire,
column == 0
? std::optional<LogicNode>{contact(
"contact-" + std::to_string(index), index)}
: std::nullopt});
}
rung.output = coil("coil-" + std::to_string(index), index + 100);
return {"logic-" + std::to_string(index),
"logic-" + std::to_string(index),
{rung},
true};
true,
{}};
}

class PerformanceTests final : public QObject


+ 12
- 2
app/tests/plc_runtime_tests.cpp Voir le fichier

@@ -11,6 +11,7 @@
#include "services/plc_discovery_gateway.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"
#include "services/logic_editor_service.h"

#include <functional>
#include <iostream>
@@ -187,15 +188,20 @@ void testRuntimeRepositorySwitchingAndDisconnect()
indicator.text = "Run";
indicator.binding = RegisterAddress{RegisterArea::M, 5};
page.controls.push_back(indicator);
project_service.editProject().initialHmiPageId = page.id;
project_service.editProject().hmiPages.push_back(page);
VirtualRegisterRepository virtual_repository;
PlcRegisterRepository plc_repository;
ActiveRegisterRepository active_repository(virtual_repository);
OfflineSimulationService simulation_service(virtual_repository);
OnlineLogicMonitorService online_monitor_service(plc_repository);
LogicEditorService logic_editor_service(project_service);
FakePlcGateway gateway;
RuntimeModeService service(
project_service, simulation_service, online_monitor_service);
project_service,
logic_editor_service,
simulation_service,
online_monitor_service);
service.configurePlc(
gateway, active_repository, virtual_repository, plc_repository);

@@ -246,9 +252,13 @@ void testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect()
ActiveRegisterRepository active_repository(virtual_repository);
OfflineSimulationService simulation_service(virtual_repository);
OnlineLogicMonitorService online_monitor_service(plc_repository);
LogicEditorService logic_editor_service(project_service);
FakePlcGateway gateway;
RuntimeModeService service(
project_service, simulation_service, online_monitor_service);
project_service,
logic_editor_service,
simulation_service,
online_monitor_service);
service.configurePlc(
gateway, active_repository, virtual_repository, plc_repository);



+ 2
- 0
app/tests/plc_runtime_tests.pro Voir le fichier

@@ -10,6 +10,7 @@ SOURCES += \
plc_runtime_tests.cpp \
$$DOMAIN_ALL_SOURCES \
$$SERVICE_PROJECT_SOURCES \
$$SERVICE_LOGIC_SOURCES \
$$SERVICE_OFFLINE_SOURCES \
$$SERVICE_RUNTIME_SOURCES \
$$INFRASTRUCTURE_PLC_SOURCES
@@ -17,6 +18,7 @@ SOURCES += \
HEADERS += \
$$DOMAIN_ALL_HEADERS \
$$SERVICE_PROJECT_HEADERS \
$$SERVICE_LOGIC_HEADERS \
$$SERVICE_OFFLINE_HEADERS \
$$SERVICE_RUNTIME_HEADERS \
$$INFRASTRUCTURE_PLC_HEADERS \


+ 268
- 917
app/tests/project_management_tests.cpp
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 115
- 19
app/tests/runtime_mode_service_tests.cpp Voir le fichier

@@ -1,4 +1,5 @@
#include "services/runtime_mode_service.h"
#include "services/logic_editor_service.h"
#include "services/offline_simulation_service.h"
#include "services/project_service.h"
#include "domain/active_register_repository.h"
@@ -100,17 +101,17 @@ private:

using TestSupport::require;

ConditionExpression conditionWithOutputWire(
LogicNode node,
const std::string &wire_id)
void setConditionPath(LadderRung *rung, LogicNode node)
{
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;
for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
rung->cells.push_back({
rung->id + "-cell-" + std::to_string(column),
column == 0 ? LadderCellKind::Node : LadderCellKind::Wire,
column == 0 ? std::optional<LogicNode>{node} : std::nullopt});
}
}

void testModeTransitions()
@@ -123,9 +124,13 @@ void testModeTransitions()
ActiveRegisterRepository active_repository(virtual_repository);
OfflineSimulationService simulation_service(virtual_repository);
OnlineLogicMonitorService online_monitor_service(plc_repository);
LogicEditorService logic_editor_service(project_service);
ReadyPlcGateway gateway;
RuntimeModeService service(
project_service, simulation_service, online_monitor_service);
project_service,
logic_editor_service,
simulation_service,
online_monitor_service);
service.configurePlc(
gateway, active_repository, virtual_repository, plc_repository);

@@ -152,8 +157,7 @@ void testModeTransitions()
LadderRung edge_rung;
edge_rung.id = "poll-edge-rung";
edge_rung.name = "Poll edge";
edge_rung.condition = conditionWithOutputWire(
edge, "poll-edge-output-wire");
setConditionPath(&edge_rung, edge);
edge_rung.output = edge_coil;
logic.rungs.push_back(edge_rung);
LogicNode comparison;
@@ -167,8 +171,7 @@ void testModeTransitions()
LadderRung comparison_rung;
comparison_rung.id = "poll-comparison-rung";
comparison_rung.name = "Poll comparison";
comparison_rung.condition = conditionWithOutputWire(
comparison, "poll-comparison-output-wire");
setConditionPath(&comparison_rung, comparison);
comparison_rung.output = comparison_coil;
logic.rungs.push_back(comparison_rung);
LogicNode move_input;
@@ -186,8 +189,7 @@ void testModeTransitions()
LadderRung move_rung;
move_rung.id = "poll-move-rung";
move_rung.name = "Poll MOVE";
move_rung.condition = conditionWithOutputWire(
move_input, "poll-move-output-wire");
setConditionPath(&move_rung, move_input);
move_rung.output = move_output;
logic.rungs.push_back(move_rung);
LogicNode add_input;
@@ -210,8 +212,7 @@ void testModeTransitions()
LadderRung add_rung;
add_rung.id = "poll-add-rung";
add_rung.name = "Poll ADD";
add_rung.condition = conditionWithOutputWire(
add_input, "poll-add-output-wire");
setConditionPath(&add_rung, add_input);
add_rung.output = add_output;
logic.rungs.push_back(add_rung);
project.controlLogics.push_back(logic);
@@ -276,6 +277,100 @@ void testModeTransitions()
"a completed PLC poll cycle must trigger one new local trace scan");
}

void testDisconnectedOutputBlocksOfflineAndOnlineRuntime()
{
TestProjectStorage storage;
ProjectService project_service(storage);
VirtualRegisterRepository virtual_repository;
VirtualRegisterRepository plc_repository;
ActiveRegisterRepository active_repository(virtual_repository);
OfflineSimulationService simulation_service(virtual_repository);
OnlineLogicMonitorService online_monitor_service(plc_repository);
LogicEditorService logic_editor_service(project_service);
ReadyPlcGateway gateway;
RuntimeModeService service(
project_service,
logic_editor_service,
simulation_service,
online_monitor_service);
service.configurePlc(
gateway, active_repository, virtual_repository, plc_repository);

ControlLogic logic;
logic.id = "broken-logic";
logic.name = "断路逻辑";
LadderRung rung;
rung.id = "broken-rung";
rung.name = "行 1";
for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
rung.cells.push_back({
"broken-cell-" + std::to_string(column),
LadderCellKind::Gap,
std::nullopt});
}
rung.output = LogicNode{
"broken-output",
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal},
true};
logic.rungs.push_back(rung);
LadderRung unused;
unused.id = "unused-rung";
unused.name = "行 2";
for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
unused.cells.push_back({
"unused-cell-" + std::to_string(column),
column >= 3 && column <= 5
? LadderCellKind::Wire : LadderCellKind::Gap,
std::nullopt});
}
logic.rungs.push_back(unused);
project_service.editProject().controlLogics.push_back(logic);
logic_editor_service.clearHistory();

const ModeTransitionResult offline = service.enterOfflineRunning();
require(
!offline.succeeded
&& offline.error == ModeTransitionError::ProjectNotReady
&& offline.detail.find("断路逻辑") != std::string::npos
&& offline.detail.find("第 11 列") != std::string::npos
&& offline.detail.find("第 1 列") != std::string::npos
&& service.mode() == ApplicationMode::Editing
&& simulation_service.state() == SimulationState::Stopped
&& service.lastSyntaxCheck().changed
&& service.lastSyntaxCheck().removedWireCells == 3U
&& logic_editor_service.findCell(
"broken-logic", "unused-rung", 3)->kind
== LadderCellKind::Gap
&& logic_editor_service.canUndo(),
"runtime preflight must normalize unused lines before rejecting a broken output");

require(service.connectPlc(
{"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
"the online connectivity check requires a ready PLC cache");
gateway.completeInitialRead();
const ModeTransitionResult online = service.enterOnlineRunning();
require(
!online.succeeded
&& online.error == ModeTransitionError::ProjectNotReady
&& online.detail == offline.detail
&& service.mode() == ApplicationMode::Editing
&& online_monitor_service.state()
== OnlineLogicMonitorState::Stopped
&& !service.lastSyntaxCheck().changed
&& logic_editor_service.undo().succeeded
&& logic_editor_service.findCell(
"broken-logic", "unused-rung", 3)->kind
== LadderCellKind::Wire,
"the same disconnected output validation must block online runtime");
}

} // namespace

int main()
@@ -284,6 +379,7 @@ int main()
{
// 运行模式只有这一组状态机边界测试
testModeTransitions();
testDisconnectedOutputBlocksOfflineAndOnlineRuntime();
}
catch (const std::exception &error)
{


+ 2
- 0
app/tests/runtime_mode_service_tests.pro Voir le fichier

@@ -8,12 +8,14 @@ SOURCES += \
runtime_mode_service_tests.cpp \
$$DOMAIN_ALL_SOURCES \
$$SERVICE_PROJECT_SOURCES \
$$SERVICE_LOGIC_SOURCES \
$$SERVICE_OFFLINE_SOURCES \
$$SERVICE_RUNTIME_SOURCES

HEADERS += \
$$DOMAIN_ALL_HEADERS \
$$SERVICE_PROJECT_HEADERS \
$$SERVICE_LOGIC_HEADERS \
$$SERVICE_OFFLINE_HEADERS \
$$SERVICE_RUNTIME_HEADERS \
$$TEST_SUPPORT_HEADERS

+ 714
- 3
app/tests/runtime_panel_controller_tests.cpp Voir le fichier

@@ -17,9 +17,18 @@
#include <QApplication>
#include <QCoreApplication>
#include <QEventLoop>
#include <QGraphicsLineItem>
#include <QGraphicsScene>
#include <QGraphicsSimpleTextItem>
#include <QImage>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QMouseEvent>
#include <QPainter>
#include <QWidget>

#include <algorithm>
#include <iostream>
#include <stdexcept>
#include <string>
@@ -38,7 +47,15 @@ ControlLogic makeAlwaysOnLogic()
LadderRung rung;
rung.id = "always-on-rung";
rung.name = "Always on";
rung.condition = ConditionExpression::fromWire("always-on-wire", 10);
for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
rung.cells.push_back({
"always-on-cell-" + std::to_string(column),
LadderCellKind::Wire,
std::nullopt});
}

LogicNode output;
output.id = "always-on-output";
@@ -56,11 +73,14 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing()
VirtualRegisterRepository virtual_repository;
OfflineSimulationService simulation_service(virtual_repository);
OnlineLogicMonitorService online_monitor_service(virtual_repository);
LogicEditorService logic_editor_service(project_service);
RuntimeModeService runtime_mode_service(
project_service, simulation_service, online_monitor_service);
project_service,
logic_editor_service,
simulation_service,
online_monitor_service);
HmiEditorService hmi_editor_service(project_service);
HmiRuntimeService hmi_runtime_service(virtual_repository);
LogicEditorService logic_editor_service(project_service);
HmiNavigationService hmi_navigation_service(project_service);
AlarmService alarm_service(project_service, virtual_repository);
RegisterMonitorService register_monitor_service(virtual_repository);
@@ -128,6 +148,27 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing()
require(simulation_service.traceSnapshot()
.forLogic(logic.id).rungValues.at("always-on-rung"),
"the queued trace must contain an energized rung");
QCoreApplication::processEvents(QEventLoop::AllEvents);
LogicEditorWidget *runtime_logic_view = controller.runtimeMonitorWidget()
->findChild<LogicEditorWidget *>(QStringLiteral("runtimeLogicView"));
bool found_active_runtime_output = false;
require(runtime_logic_view != nullptr,
"runtime monitor must own the ladder trace view");
for (QGraphicsItem *item : runtime_logic_view->scene()->items())
{
if (item->data(0).toString() == QStringLiteral("output")
&& item->data(1).toString()
== QStringLiteral("always-on-rung"))
{
found_active_runtime_output = item->data(3).toBool()
&& item->data(4).toBool();
}
}
require(found_active_runtime_output,
"the full executor snapshot must reach the runtime ladder exactly once");

require(simulation_service.executeOnce().succeeded,
"a second scan must queue the stale-trace regression event");

require(runtime_mode_service.enterEditing().succeeded,
"offline simulation must return to editing before queued delivery");
@@ -142,6 +183,671 @@ void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing()
"a queued offline scan must not restore the trace after returning to editing");
}

void testLogicEditorGridSelectionAndDeletion()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService logic_editor_service(project_service);
const std::string logic_id = logic_editor_service.ensureDefaultLogic().id;

LogicEditorWidget widget(logic_editor_service);
widget.setLogicId(logic_id);
require(widget.scene()->items().isEmpty(),
"an empty logic must not draw standalone power rails");
require((widget.alignment() & Qt::AlignLeft) != 0
&& (widget.alignment() & Qt::AlignTop) != 0,
"the ladder canvas must start at its top-left origin");

const std::string rung_id = logic_editor_service.addRung(logic_id).id;
require(!rung_id.empty(), "the grid regression fixture must create a row");
require(logic_editor_service.setHorizontalWireRange(
logic_id, rung_id, 4, 4, true).succeeded,
"the grid regression fixture must draw one horizontal cell");
widget.reloadLogic();
widget.resize(1200, 400);
widget.show();
QCoreApplication::processEvents(QEventLoop::AllEvents);

constexpr qreal left_bus = 60.0;
constexpr qreal cell_width = 96.0;
constexpr qreal output_width = 224.0;
constexpr qreal first_grid_top = 46.0;
constexpr qreal row_height = 78.0;
const auto clickScene = [&widget](const QPointF &position,
Qt::KeyboardModifiers modifiers = Qt::NoModifier)
{
const QPoint point = widget.mapFromScene(position);
QMouseEvent press(
QEvent::MouseButtonPress,
QPointF(point),
Qt::LeftButton,
Qt::LeftButton,
modifiers);
QApplication::sendEvent(widget.viewport(), &press);
QMouseEvent release(
QEvent::MouseButtonRelease,
QPointF(point),
Qt::LeftButton,
Qt::NoButton,
modifiers);
QApplication::sendEvent(widget.viewport(), &release);
};
const auto dragScene = [&widget](
const QPointF &from,
const QPointF &to,
Qt::KeyboardModifiers modifiers = Qt::NoModifier)
{
const QPoint from_point = widget.mapFromScene(from);
const QPoint to_point = widget.mapFromScene(to);
QMouseEvent press(
QEvent::MouseButtonPress,
QPointF(from_point),
Qt::LeftButton,
Qt::LeftButton,
modifiers);
QApplication::sendEvent(widget.viewport(), &press);
QMouseEvent move(
QEvent::MouseMove,
QPointF(to_point),
Qt::NoButton,
Qt::LeftButton,
modifiers);
QApplication::sendEvent(widget.viewport(), &move);
QMouseEvent release(
QEvent::MouseButtonRelease,
QPointF(to_point),
Qt::LeftButton,
Qt::NoButton,
modifiers);
QApplication::sendEvent(widget.viewport(), &release);
};
clickScene(QPointF(
left_bus + 4.0 * cell_width + cell_width / 2.0,
first_grid_top + row_height / 2.0));

require(widget.selectedRungId() == rung_id,
"clicking a grid cell must select its row");
QString delete_error;
QObject::connect(
&widget,
&LogicEditorWidget::editorError,
[&delete_error](const QString &message) { delete_error = message; });
const LogicEditorResult deleted = widget.deleteSelected();
require(deleted.succeeded,
"Delete on a selected horizontal cell must succeed: "
+ delete_error.toStdString());
const LadderRung *rung = logic_editor_service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->cells.size() == 10U
&& rung->cells[4].kind == LadderCellKind::Gap,
"Delete on one horizontal cell must preserve the row and clear only that cell");

widget.clearSelection();
require(widget.selectedRungId().empty(),
"clearing the selection must not expose the first row as selected");
require(!widget.addHorizontalWire().succeeded,
"the horizontal-wire command must require an explicitly selected cell");

require(logic_editor_service.setHorizontalWireRange(
logic_id, rung_id, 3, 3, true).succeeded,
"fixture must restore a wire for precise hit testing");
widget.reloadLogic();
clickScene(QPointF(
left_bus + 3.0 * cell_width,
first_grid_top + row_height / 2.0));
require(!widget.deleteSelected().succeeded,
"Delete on a column boundary must not delete an adjacent cell");
require(logic_editor_service.findRung(logic_id, rung_id)->cells[3].kind
== LadderCellKind::Wire,
"a boundary selection must preserve the adjacent horizontal wire");

clickScene(QPointF(
left_bus + 10.0 * cell_width + output_width / 2.0,
first_grid_top + row_height / 2.0));
require(!widget.deleteSelected().succeeded
&& logic_editor_service.findLogic(logic_id)->rungs.size() == 1U,
"Delete on an empty output slot must never delete the whole row");

clickScene(QPointF(
left_bus + 2.0 * cell_width + cell_width / 2.0,
first_grid_top + row_height / 2.0));
require(widget.addHorizontalWire().succeeded
&& logic_editor_service.findRung(logic_id, rung_id)->cells[2].kind
== LadderCellKind::Wire,
"the horizontal-wire command must act on an explicitly selected cell");

const LogicEditorResult first_node = logic_editor_service.setConditionAtColumn(
logic_id,
rung_id,
0,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen},
true);
const LogicEditorResult second_node = logic_editor_service.setConditionAtColumn(
logic_id,
rung_id,
1,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 1},
ContactMode::NormallyOpen},
true);
require(first_node.succeeded && second_node.succeeded,
"fixture must create adjacent conditions for multi-selection");
widget.reloadLogic();
dragScene(
QPointF(left_bus + 5.0, first_grid_top + 5.0),
QPointF(
left_bus + 2.0 * cell_width - 5.0,
first_grid_top + row_height - 5.0));
require(widget.selectedNodeIds().size() == 2U,
"mouse drag must select multiple conditions in the same row");
widget.clearSelection();
clickScene(QPointF(
left_bus + cell_width / 2.0,
first_grid_top + row_height / 2.0));
dragScene(
QPointF(left_bus + cell_width + 5.0, first_grid_top + 5.0),
QPointF(
left_bus + 2.0 * cell_width - 5.0,
first_grid_top + row_height - 5.0),
Qt::ControlModifier);
require(widget.selectedNodeIds().size() == 2U,
"Ctrl-drag must append objects to the existing selection");
require(widget.addParallelBranch(ContactNodeConfig{
RegisterAddress{RegisterArea::M, 2},
ContactMode::NormallyOpen}).succeeded
&& logic_editor_service.findLogic(logic_id)->rungs.size() == 2U,
"parallel insertion must use the explicitly selected condition range");
const LogicEditorResult output = logic_editor_service.setOutput(
logic_id,
rung_id,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 3}, CoilMode::Normal},
true);
require(output.succeeded,
"syntax-location fixture must create an output instruction");
widget.reloadLogic();
widget.focusSyntaxLocation(
rung_id, ProjectLimits::kMaximumLadderColumns);
require(
widget.selectedRungId() == rung_id
&& widget.selectedNodeId() == output.id,
"a syntax error at column 11 must focus the output slot");
widget.focusSyntaxLocation(rung_id, 1);
require(
widget.selectedRungId() == rung_id
&& widget.selectedNodeId() == first_node.id,
"a syntax error in the condition area must focus its one-based column");
}

void testLadderLayoutAndDragDeletion()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService editor(project_service);
const std::string logic_id = editor.ensureDefaultLogic().id;
const std::string upper = editor.addRung(logic_id).id;
const std::string lower = editor.addRung(logic_id).id;
require(
editor.setConditionAtColumn(
logic_id,
upper,
0,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 10},
ContactMode::NormallyClosed},
true).succeeded
&& editor.setHorizontalWireRange(
logic_id, upper, 1, 1, true).succeeded
&& editor.setOutput(
logic_id,
upper,
MoveNodeConfig{
WordOperand{
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, 10},
0},
RegisterAddress{RegisterArea::D, 20}},
true).succeeded
&& editor.setHorizontalWireRange(
logic_id, lower, 0, 1, true).succeeded
&& editor.setVerticalConnection(
logic_id, upper, lower, 2, true).succeeded
&& editor.updateRungComment(
logic_id, upper, "主网络注释").succeeded
&& editor.updateRungComment(
logic_id, lower, "支路注释不应重复").succeeded,
"layout fixture must create a connected two-row network");
editor.clearHistory();

LogicEditorWidget widget(editor);
widget.setLogicId(logic_id);
widget.resize(1320, 360);
widget.show();
QCoreApplication::processEvents(QEventLoop::AllEvents);

bool found_head_comment = false;
bool found_branch_comment = false;
for (QGraphicsItem *item : widget.scene()->items())
{
auto *text = dynamic_cast<QGraphicsSimpleTextItem *>(item);
if (text == nullptr)
{
continue;
}
found_head_comment = found_head_comment
|| text->text() == QStringLiteral("主网络注释");
found_branch_comment = found_branch_comment
|| text->text() == QStringLiteral("支路注释不应重复");
}
require(found_head_comment && !found_branch_comment,
"only the network-head comment must be rendered");

constexpr qreal left_bus = 60.0;
constexpr qreal cell_width = 96.0;
constexpr qreal right_bus = left_bus + 10.0 * cell_width + 224.0;
constexpr qreal first_grid_top = 46.0;
constexpr qreal second_grid_bottom = first_grid_top + 2.0 * 78.0;
QImage grid_image(1320, 360, QImage::Format_ARGB32_Premultiplied);
grid_image.fill(Qt::transparent);
{
QPainter painter(&grid_image);
widget.scene()->render(
&painter,
QRectF(0.0, 0.0, 1320.0, 360.0),
QRectF(0.0, 0.0, 1320.0, 360.0),
Qt::IgnoreAspectRatio);
}
const auto background_at = [&grid_image, first_grid_top](qreal x)
{
return grid_image.pixelColor(
qRound(x), qRound(first_grid_top + 68.0));
};
const QColor node_background = background_at(
left_bus + cell_width - 10.0);
const QColor wire_background = background_at(
left_bus + 2.0 * cell_width - 10.0);
const QColor gap_background = background_at(
left_bus + 3.0 * cell_width - 10.0);
const QColor output_background = background_at(right_bus - 10.0);
require(
node_background == QColor(QStringLiteral("#ffffff"))
&& node_background == wire_background
&& wire_background == gap_background
&& gap_background == output_background,
"node, wire, gap, and output cells must share one grid background");

bool found_left_rail = false;
bool found_right_rail = false;
for (QGraphicsItem *item : widget.scene()->items())
{
auto *line_item = dynamic_cast<QGraphicsLineItem *>(item);
if (line_item == nullptr || line_item->data(0).isValid())
{
continue;
}
const QLineF line = line_item->line();
const bool exact_span = qFuzzyCompare(
line.y1() + 1.0, first_grid_top + 1.0)
&& qFuzzyCompare(
line.y2() + 1.0, second_grid_bottom + 1.0);
found_left_rail = found_left_rail
|| (exact_span && qFuzzyCompare(
line.x1() + 1.0, left_bus + 1.0));
found_right_rail = found_right_rail
|| (exact_span && qFuzzyCompare(
line.x1() + 1.0, right_bus + 1.0));
}
require(found_left_rail && found_right_rail,
"both rails must share the exact first-to-last row span");

const QPoint from = widget.mapFromScene(
QPointF(left_bus + 4.0, first_grid_top + 4.0));
const QPoint to = widget.mapFromScene(
QPointF(right_bus - 4.0, second_grid_bottom - 4.0));
QMouseEvent press(
QEvent::MouseButtonPress,
QPointF(from),
Qt::LeftButton,
Qt::LeftButton,
Qt::NoModifier);
QApplication::sendEvent(widget.viewport(), &press);
QMouseEvent move(
QEvent::MouseMove,
QPointF(to),
Qt::NoButton,
Qt::LeftButton,
Qt::NoModifier);
QApplication::sendEvent(widget.viewport(), &move);
QMouseEvent release(
QEvent::MouseButtonRelease,
QPointF(to),
Qt::LeftButton,
Qt::NoButton,
Qt::NoModifier);
QApplication::sendEvent(widget.viewport(), &release);
require(widget.selectedNodeIds().size() == 2U,
"drag selection must include the condition and output instruction");
const QList<QGraphicsItem *> selected_scene_items = widget.scene()->items();
require(
std::any_of(
selected_scene_items.cbegin(),
selected_scene_items.cend(),
[](QGraphicsItem *item) { return item->zValue() == 100.0; }),
"selected objects must be painted by the highest selection layer");

require(widget.deleteSelected().succeeded,
"Delete must submit the complete drag selection once");
const LadderRung *upper_rung = editor.findRung(logic_id, upper);
const LadderRung *lower_rung = editor.findRung(logic_id, lower);
require(
upper_rung->cells[0].kind == LadderCellKind::Gap
&& upper_rung->cells[1].kind == LadderCellKind::Gap
&& !upper_rung->output.has_value()
&& lower_rung->cells[0].kind == LadderCellKind::Gap
&& lower_rung->cells[1].kind == LadderCellKind::Gap
&& editor.findLogic(logic_id)->verticalConnections.empty(),
"drag deletion must clear cells, output, and vertical connection together");
require(editor.undo().succeeded,
"one undo must restore the complete drag deletion");
upper_rung = editor.findRung(logic_id, upper);
require(
upper_rung->cells[0].kind == LadderCellKind::Node
&& upper_rung->cells[1].kind == LadderCellKind::Wire
&& upper_rung->output.has_value()
&& editor.findLogic(logic_id)->verticalConnections.size() == 1U,
"one undo must restore every object removed by the drag selection");
}

void testCursorAdvanceAndInlineCommandInput()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService editor(project_service);
const std::string logic_id = editor.ensureDefaultLogic().id;
LogicEditorWidget widget(editor);
widget.setLogicId(logic_id);
widget.resize(1320, 440);
widget.show();
QCoreApplication::processEvents(QEventLoop::AllEvents);

for (int column = 0;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
require(widget.addHorizontalWire().succeeded,
"repeated toolbar wire input must advance through all ten cells");
}
const ControlLogic *logic = editor.findLogic(logic_id);
require(logic != nullptr && logic->rungs.size() == 1U,
"the first wire on empty logic must atomically create one row");
for (const LadderCell &cell : logic->rungs.front().cells)
{
require(cell.kind == LadderCellKind::Wire,
"ten repeated wire actions must fill ten distinct cells");
}

require(widget.setOutput(
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal},
true).succeeded,
"the output action must succeed after the tenth cell");
logic = editor.findLogic(logic_id);
require(logic->rungs.size() == 2U
&& logic->rungs.front().output.has_value(),
"the output action must append the next empty row atomically");
require(widget.addHorizontalWire().succeeded
&& editor.findLogic(logic_id)->rungs[1].cells[0].kind
== LadderCellKind::Wire,
"the toolbar cursor must continue at the appended row first cell");

constexpr qreal left_bus = 60.0;
constexpr qreal cell_width = 96.0;
constexpr qreal output_width = 224.0;
constexpr qreal second_row_center = 197.0;
const auto double_click = [&widget](const QPointF &scene_point)
{
const QPoint point = widget.mapFromScene(scene_point);
QMouseEvent event(
QEvent::MouseButtonDblClick,
QPointF(point),
Qt::LeftButton,
Qt::LeftButton,
Qt::NoModifier);
QApplication::sendEvent(widget.viewport(), &event);
QCoreApplication::processEvents(QEventLoop::AllEvents);
};
const auto press_enter = [](QLineEdit *input)
{
QKeyEvent event(
QEvent::KeyPress,
Qt::Key_Return,
Qt::NoModifier);
QApplication::sendEvent(input, &event);
QCoreApplication::processEvents(QEventLoop::AllEvents);
};

double_click(QPointF(
left_bus + 1.5 * cell_width,
second_row_center));
QLineEdit *input = widget.findChild<QLineEdit *>(
QStringLiteral("logicCommandInput"));
require(input != nullptr && input->isVisible()
&& input->completer() != nullptr,
"double-clicking a cell must open the inline command editor with completion");
const int first_input_left = input->geometry().left();
input->setText(QStringLiteral("LD M4"));
press_enter(input);
const LadderRung &second = editor.findLogic(logic_id)->rungs[1];
require(second.cells[1].node.has_value()
&& input->isVisible()
&& input->geometry().left() > first_input_left,
"a committed inline condition must move the editor one cell right");

double_click(QPointF(
left_bus + ProjectLimits::kMaximumConditionColumns * cell_width
+ output_width / 2.0,
second_row_center));
input->setText(QStringLiteral("OUT M61"));
press_enter(input);
logic = editor.findLogic(logic_id);
require(logic->rungs.size() == 3U
&& logic->rungs[1].output.has_value()
&& input->isVisible(),
"an inline output must append a row and keep continuous input active");
}

void testSegmentLevelTraceProjection()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService editor(project_service);
const std::string logic_id = editor.ensureDefaultLogic().id;
const std::string upper = editor.addRung(logic_id).id;
const std::string lower = editor.addRung(logic_id).id;
const LogicEditorResult condition = editor.setConditionAtColumn(
logic_id,
upper,
0,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0},
ContactMode::NormallyOpen},
true);
const LogicEditorResult output = editor.setOutput(
logic_id,
upper,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal},
true);
require(condition.succeeded && output.succeeded
&& editor.setVerticalConnection(
logic_id, upper, lower, 0, true).succeeded,
"the segment trace fixture must create a contact, output and vertical edge");
const ControlLogic *logic = editor.findLogic(logic_id);
const std::string cell_id = logic->rungs.front().cells.front().id;
const std::string vertical_id = logic->verticalConnections.front().id;

LogicTraceSnapshot trace;
LogicTraceValues &values = trace.logicValues[logic_id];
values.cellInputPowerValues[cell_id] = true;
values.cellPowerValues[cell_id] = false;
values.rungValues[upper] = false;
values.nodeValues[output.id] = false;
LogicEditorWidget widget(editor);
widget.setLogicId(logic_id);
widget.resize(1320, 360);
widget.show();
widget.setRuntimeTrace(trace);
QCoreApplication::processEvents(QEventLoop::AllEvents);

bool found_split_contact = false;
bool found_inactive_output = false;
bool found_inactive_vertical = false;
for (QGraphicsItem *item : widget.scene()->items())
{
const QString type = item->data(0).toString();
if (type == QStringLiteral("cell")
&& item->data(1).toString().toStdString() == upper
&& item->data(2).toInt() == 0)
{
found_split_contact = item->data(6).toBool()
&& !item->data(7).toBool();
}
else if (type == QStringLiteral("output")
&& item->data(1).toString().toStdString() == upper)
{
found_inactive_output = !item->data(3).toBool()
&& !item->data(4).toBool();
}
else if (type == QStringLiteral("vertical")
&& item->data(1).toString().toStdString() == vertical_id)
{
found_inactive_vertical = !item->data(5).toBool();
}
}
require(found_split_contact && found_inactive_output
&& found_inactive_vertical,
"trace projection must keep left/right contact power and inactive verticals separate");

values.cellPowerValues[cell_id] = true;
values.rungValues[upper] = true;
values.nodeValues[output.id] = true;
values.verticalConnectionValues[vertical_id] = true;
widget.setRuntimeTrace(trace);
bool found_active_output = false;
bool found_active_vertical = false;
for (QGraphicsItem *item : widget.scene()->items())
{
const QString type = item->data(0).toString();
if (type == QStringLiteral("output")
&& item->data(1).toString().toStdString() == upper)
{
found_active_output = item->data(3).toBool()
&& item->data(4).toBool();
}
else if (type == QStringLiteral("vertical")
&& item->data(1).toString().toStdString() == vertical_id)
{
found_active_vertical = item->data(5).toBool();
}
}
require(found_active_output && found_active_vertical,
"explicitly energized output and vertical segments must project as active");
}

void testLogicClipboardUsesExplicitObjectAndRowSelection()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService editor(project_service);
const std::string logic_id = editor.ensureDefaultLogic().id;
const std::string source = editor.addRung(logic_id).id;
const std::string target = editor.addRung(logic_id).id;
require(
editor.setHorizontalWireRange(
logic_id, source, 0, 0, true).succeeded,
"clipboard fixture must create one source wire");
editor.clearHistory();

LogicEditorWidget widget(editor);
widget.setLogicId(logic_id);
widget.resize(1320, 360);
widget.show();
QCoreApplication::processEvents(QEventLoop::AllEvents);
const auto find_item = [&widget](
const QString &type,
const std::string &rung_id,
int column) -> QGraphicsItem *
{
const QList<QGraphicsItem *> items = widget.scene()->items();
const auto found = std::find_if(
items.cbegin(), items.cend(),
[&type, &rung_id, column](QGraphicsItem *item)
{
return item->data(0).toString() == type
&& item->data(1).toString().toStdString() == rung_id
&& (column < 0 || item->data(2).toInt() == column);
});
return found == items.cend() ? nullptr : *found;
};
const auto click_item = [&widget](QGraphicsItem *item)
{
require(item != nullptr, "clipboard test target item must exist");
const QPoint point = widget.mapFromScene(
item->sceneBoundingRect().center());
QMouseEvent press(
QEvent::MouseButtonPress,
QPointF(point),
Qt::LeftButton,
Qt::LeftButton,
Qt::NoModifier);
QApplication::sendEvent(widget.viewport(), &press);
QMouseEvent release(
QEvent::MouseButtonRelease,
QPointF(point),
Qt::LeftButton,
Qt::NoButton,
Qt::NoModifier);
QApplication::sendEvent(widget.viewport(), &release);
};

click_item(find_item(QStringLiteral("cell"), source, 0));
const LogicClipboardCopyResult wire_copy = widget.copySelection();
require(
widget.hasCopyableSelection()
&& wire_copy.copy.succeeded
&& wire_copy.fragment.mode == LogicClipboardMode::GridObjects
&& wire_copy.fragment.cells.size() == 1U
&& wire_copy.fragment.cells.front().kind == LadderCellKind::Wire
&& wire_copy.fragment.rows.empty(),
"clicking one wire must copy one grid object instead of its whole row");

click_item(find_item(QStringLiteral("cell"), target, 0));
const LogicClipboardPasteResult pasted = widget.pasteClipboard(
wire_copy.fragment);
require(
pasted.edit.succeeded
&& editor.findLogic(logic_id)->rungs.size() == 2U
&& editor.findCell(logic_id, target, 0)->kind
== LadderCellKind::Wire,
"widget paste must place one wire without creating or copying a row");
const LogicClipboardCopyResult pasted_selection = widget.copySelection();
require(
pasted_selection.copy.succeeded
&& pasted_selection.fragment.cells.size() == 1U
&& pasted_selection.fragment.cells.front().kind
== LadderCellKind::Wire,
"a successful paste must select the newly pasted wire");

click_item(find_item(QStringLiteral("rowHeader"), source, -1));
const LogicClipboardCopyResult row_copy = widget.copySelection();
require(
row_copy.copy.succeeded
&& row_copy.fragment.mode == LogicClipboardMode::WholeRows
&& row_copy.fragment.rows.size() == 1U,
"only clicking the left row header may create a whole-row clipboard");
}

} // namespace

int main(int argc, char *argv[])
@@ -151,6 +857,11 @@ int main(int argc, char *argv[])
try
{
testQueuedOfflineTraceIsIgnoredAfterReturningToEditing();
testLogicEditorGridSelectionAndDeletion();
testLadderLayoutAndDragDeletion();
testCursorAdvanceAndInlineCommandInput();
testSegmentLevelTraceProjection();
testLogicClipboardUsesExplicitObjectAndRowSelection();
}
catch (const std::exception &error)
{


Chargement…
Annuler
Enregistrer