Sfoglia il codice sorgente

feat: 完善梯形图网格连续插入

main
suyu 1 mese fa
parent
commit
f33976b5da
7 ha cambiato i file con 1008 aggiunte e 26 eliminazioni
  1. +178
    -1
      app/src/services/logic_editor_service.cpp
  2. +11
    -0
      app/src/services/logic_editor_service.h
  3. +227
    -18
      app/src/ui/logic_editor_widget.cpp
  4. +5
    -0
      app/src/ui/logic_editor_widget.h
  5. +7
    -7
      app/src/ui/main_window.ui
  6. +339
    -0
      app/tests/logic_editor_service_tests.cpp
  7. +241
    -0
      app/tests/main_window_tests.cpp

+ 178
- 1
app/src/services/logic_editor_service.cpp Vedi File

@@ -930,6 +930,99 @@ LogicEditorResult LogicEditorService::appendCondition(
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::insertConditionAtColumn(
const std::string &logic_id,
const std::string &rung_id,
int column,
const LogicNodeConfig &config)
{
const ControlLogic *logic = findLogic(logic_id);
const LadderRung *existing_rung = findRung(logic_id, rung_id);
if (logic == nullptr || existing_rung == nullptr)
{
return failure(
logic == nullptr ? LogicEditorError::LogicNotFound
: LogicEditorError::RungNotFound,
logic == nullptr ? "未找到控制逻辑" : "未找到梯形图网络");
}
if (!isConditionConfig(config))
{
return failure(LogicEditorError::InvalidNode, "梯形图条件不能使用输出节点");
}
if (column < 0 || column >= ProjectLimits::kMaximumConditionColumns)
{
return failure(LogicEditorError::InvalidOperation, "条件插入列必须位于第 1~10 列");
}

const int occupied_columns = existing_rung->condition.has_value()
? expressionColumns(*existing_rung->condition) : 0;
if (column < occupied_columns)
{
return failure(LogicEditorError::InvalidOperation, "所选网格已被条件或横线占用");
}

const int leading_wire_columns = column - occupied_columns;
const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config));
ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config));
HistoryState before = captureState();
Project &project = project_service_.editProject();
LadderRung *rung = findEditableRung(project, logic_id, rung_id);

if (leading_wire_columns > 0)
{
ConditionExpression wire = ConditionExpression::fromWire(
makeUniqueWireId(*logic), leading_wire_columns);
if (!rung->condition.has_value())
{
rung->condition = makeContainer(
makeUniqueExpressionId(*logic),
ConditionExpressionKind::Series,
std::move(wire),
std::move(leaf));
}
else if (rung->condition->kind == ConditionExpressionKind::Series)
{
rung->condition->children.push_back(std::move(wire));
rung->condition->children.push_back(std::move(leaf));
}
else
{
ConditionExpression series;
series.id = makeUniqueExpressionId(*logic);
series.kind = ConditionExpressionKind::Series;
series.children.push_back(std::move(*rung->condition));
series.children.push_back(std::move(wire));
series.children.push_back(std::move(leaf));
rung->condition = std::move(series);
}
}
else if (!rung->condition.has_value())
{
rung->condition = std::move(leaf);
}
else if (rung->condition->kind == ConditionExpressionKind::Series)
{
rung->condition->children.push_back(std::move(leaf));
}
else
{
rung->condition = makeContainer(
makeUniqueExpressionId(*logic),
ConditionExpressionKind::Series,
std::move(*rung->condition),
std::move(leaf));
}

std::string validation_error;
if (!rung->validate(&validation_error))
{
project.controlLogics = std::move(before.logics);
return failure(LogicEditorError::InvalidOperation, validation_error);
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::appendWire(
const std::string &logic_id,
const std::string &rung_id,
@@ -1014,7 +1107,25 @@ LogicEditorResult LogicEditorService::insertConditionAfter(
{
return child.id == target_node_id;
});
parent->children.insert(target_iterator + 1, std::move(leaf));
const auto insertion_position = target_iterator + 1;
if (insertion_position != parent->children.end()
&& insertion_position->kind == ConditionExpressionKind::Wire)
{
// 横线代表已占用的网格位置,连续插入时先消费一格而不是扩展网络
if (insertion_position->wire->columnSpan == 1)
{
*insertion_position = std::move(leaf);
}
else
{
--insertion_position->wire->columnSpan;
parent->children.insert(insertion_position, std::move(leaf));
}
}
else
{
parent->children.insert(insertion_position, std::move(leaf));
}
}
else
{
@@ -1132,6 +1243,72 @@ LogicEditorResult LogicEditorService::replaceWireWithCondition(
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::replaceWireColumnWithCondition(
const std::string &logic_id,
const std::string &rung_id,
const std::string &wire_expression_id,
int column_offset,
const LogicNodeConfig &config)
{
const ControlLogic *logic = findLogic(logic_id);
const ConditionExpression *wire = findExpression(
logic_id, rung_id, wire_expression_id);
if (logic == nullptr || wire == nullptr
|| wire->kind != ConditionExpressionKind::Wire)
{
return failure(LogicEditorError::ExpressionNotFound, "未找到要替换的横线");
}
if (!isConditionConfig(config))
{
return failure(LogicEditorError::InvalidNode, "横线只能替换为条件节点");
}
if (column_offset < 0 || column_offset >= wire->wire->columnSpan)
{
return failure(LogicEditorError::InvalidOperation, "横线网格偏移超出有效范围");
}
if (wire->wire->columnSpan == 1)
{
return replaceWireWithCondition(
logic_id, rung_id, wire_expression_id, config);
}

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

HistoryState before = captureState();
Project &project = project_service_.editProject();
LadderRung *rung = findEditableRung(project, logic_id, rung_id);
ConditionExpression *editable = findConditionExpression(
*rung->condition, wire_expression_id);
*editable = std::move(replacement);
normalizeConditionExpression(&rung->condition);
std::string validation_error;
if (!rung->validate(&validation_error))
{
project.controlLogics = std::move(before.logics);
return failure(LogicEditorError::InvalidNode, validation_error);
}
recordHistory(std::move(before));
return {true, LogicEditorError::None, {}, node_id};
}

LogicEditorResult LogicEditorService::addParallelBranch(
const std::string &logic_id,
const std::string &rung_id,


+ 11
- 0
app/src/services/logic_editor_service.h Vedi File

@@ -68,6 +68,11 @@ public:
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config);
LogicEditorResult insertConditionAtColumn(
const std::string &logic_id,
const std::string &rung_id,
int column,
const LogicNodeConfig &config);
LogicEditorResult appendWire(
const std::string &logic_id,
const std::string &rung_id,
@@ -87,6 +92,12 @@ public:
const std::string &rung_id,
const std::string &wire_expression_id,
const LogicNodeConfig &config);
LogicEditorResult replaceWireColumnWithCondition(
const std::string &logic_id,
const std::string &rung_id,
const std::string &wire_expression_id,
int column_offset,
const LogicNodeConfig &config);
LogicEditorResult addParallelBranch(
const std::string &logic_id,
const std::string &rung_id,


+ 227
- 18
app/src/ui/logic_editor_widget.cpp Vedi File

@@ -586,6 +586,113 @@ private:
bool active_ = false;
};

class LogicEditorWidget::WireCellItem final : public QGraphicsItem
{
public:
WireCellItem(
const std::string &expression_id,
const std::string &rung_id,
int column_offset,
const QPointF &center)
: expression_id_(expression_id),
rung_id_(rung_id),
column_offset_(column_offset)
{
setPos(center);
setFlag(ItemIsSelectable, true);
setZValue(2.2);
setToolTip(LogicEditorWidget::tr("横线网格:第 %1 格,可用触点原位替换")
.arg(column_offset + 1));
}

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

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

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

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

class LogicEditorWidget::EmptySlotItem final : public QGraphicsItem
{
public:
EmptySlotItem(
const std::string &rung_id,
int column,
const QPointF &center,
bool show_label)
: rung_id_(rung_id), column_(column), show_label_(show_label)
{
setPos(center);
setFlag(ItemIsSelectable, true);
setZValue(2.0);
setToolTip(LogicEditorWidget::tr("空条件网格:第 %1 列,点击后可插入触点")
.arg(column + 1));
}

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

void paint(
QPainter *painter,
const QStyleOptionGraphicsItem *option,
QWidget *) override
{
const bool selected = (option->state & QStyle::State_Selected) != 0;
if (selected)
{
painter->fillRect(boundingRect().adjusted(2, 2, -2, -2), kSelectionColor);
painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine));
painter->drawRect(boundingRect().adjusted(3, 3, -3, -3));
}
painter->setPen(ladderPen(false));
painter->drawLine(
QPointF(-kCellWidth / 2.0, 0), QPointF(kCellWidth / 2.0, 0));
if (show_label_)
{
painter->setPen(kPlaceholderColor);
painter->drawText(
QRectF(-kCellWidth / 2.0, -20, kCellWidth, 40),
Qt::AlignCenter,
LogicEditorWidget::tr("添加条件"));
}
}

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

private:
std::string rung_id_;
int column_ = 0;
bool show_label_ = false;
};

class LogicEditorWidget::VerticalConnectorItem final : public QGraphicsItem
{
public:
@@ -752,6 +859,17 @@ RenderResult renderExpression(
center,
expression.wire->columnSpan,
active));
for (int column = 0; column < expression.wire->columnSpan; ++column)
{
scene.addItem(new LogicEditorWidget::WireCellItem(
expression.id,
rung_id,
column,
QPointF(
top_left.x() + (static_cast<qreal>(column) + 0.5)
* kCellWidth,
center.y())));
}
return {
QPointF(top_left.x(), center.y()),
QPointF(top_left.x() + width, center.y())};
@@ -953,16 +1071,7 @@ void LogicEditorWidget::reloadLogic()
current_rung_id_ = logic->rungs.front().id;
}

int condition_columns = 1;
for (const LadderRung &rung : logic->rungs)
{
if (rung.condition.has_value())
{
condition_columns = std::max(
condition_columns,
measureExpression(*rung.condition).columns);
}
}
const int condition_columns = ProjectLimits::kMaximumConditionColumns;
const int grid_columns = std::max(kMinimumLogicColumns, condition_columns + 1);
const qreal left_rail_x = kSceneMargin + kRailInset;
const qreal right_rail_x = left_rail_x + static_cast<qreal>(grid_columns) * kCellWidth;
@@ -974,6 +1083,7 @@ void LogicEditorWidget::reloadLogic()
{
const ExpressionMetrics metrics = rung.condition.has_value()
? measureExpression(*rung.condition) : ExpressionMetrics{};
const int occupied_columns = rung.condition.has_value() ? metrics.columns : 0;
const int grid_rows = std::max(1, metrics.rows);
const qreal grid_top = top + kRungHeaderHeight;
const qreal rung_height = kRungHeaderHeight
@@ -1011,12 +1121,18 @@ void LogicEditorWidget::reloadLogic()
fault_node_id_);
expression_output = rendered.output;
}
else
for (int column = occupied_columns;
column < ProjectLimits::kMaximumConditionColumns;
++column)
{
addPlaceholder(
*scene_,
QRectF(left_rail_x, grid_top, kCellWidth, kCellHeight),
rung.output.has_value() ? tr("无条件") : tr("添加条件"));
scene_->addItem(new EmptySlotItem(
rung.id,
column,
QPointF(
left_rail_x + (static_cast<qreal>(column) + 0.5)
* kCellWidth,
main_y),
column == 0 && !rung.output.has_value()));
}

const bool rung_active = runtime_trace_enabled_ && traceValue(
@@ -1178,6 +1294,11 @@ std::vector<std::string> LogicEditorWidget::selectedExpressionIds() const
{
positioned_ids.emplace_back(wire->scenePos(), wire->expressionId());
}
else if (const WireCellItem *cell =
dynamic_cast<const WireCellItem *>(item))
{
positioned_ids.emplace_back(cell->scenePos(), cell->expressionId());
}
}
std::sort(
positioned_ids.begin(), positioned_ids.end(),
@@ -1193,7 +1314,10 @@ std::vector<std::string> LogicEditorWidget::selectedExpressionIds() const
ids.reserve(positioned_ids.size());
for (const auto &positioned_id : positioned_ids)
{
ids.push_back(positioned_id.second);
if (std::find(ids.cbegin(), ids.cend(), positioned_id.second) == ids.cend())
{
ids.push_back(positioned_id.second);
}
}
return ids;
}
@@ -1205,12 +1329,49 @@ std::vector<std::string> LogicEditorWidget::selectedWireIds() const
{
if (const WireItem *wire = dynamic_cast<const WireItem *>(item))
{
ids.push_back(wire->expressionId());
if (std::find(ids.cbegin(), ids.cend(), wire->expressionId()) == ids.cend())
{
ids.push_back(wire->expressionId());
}
}
else if (const WireCellItem *cell =
dynamic_cast<const WireCellItem *>(item))
{
if (std::find(ids.cbegin(), ids.cend(), cell->expressionId()) == ids.cend())
{
ids.push_back(cell->expressionId());
}
}
}
return ids;
}

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

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

std::vector<std::string> LogicEditorWidget::selectedBranchIds() const
{
std::vector<std::string> ids;
@@ -1250,6 +1411,15 @@ std::string LogicEditorWidget::selectedRungId() const
}
rung_id = wire->rungId();
}
else if (const WireCellItem *cell =
dynamic_cast<const WireCellItem *>(item))
{
if (!rung_id.empty() && rung_id != cell->rungId())
{
return {};
}
rung_id = cell->rungId();
}
else if (const VerticalConnectorItem *connector =
dynamic_cast<const VerticalConnectorItem *>(item))
{
@@ -1266,6 +1436,15 @@ std::string LogicEditorWidget::selectedRungId() const
rung_id = rung->rungId();
}
}
else if (const EmptySlotItem *slot =
dynamic_cast<const EmptySlotItem *>(item))
{
if (!rung_id.empty() && rung_id != slot->rungId())
{
return {};
}
rung_id = slot->rungId();
}
}
return rung_id;
}
@@ -1288,11 +1467,41 @@ LogicEditorResult LogicEditorWidget::addRung()

LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config)
{
const std::vector<int> selected_empty_columns = selectedEmptySlotColumns();
const std::vector<std::pair<std::string, int>> selected_wire_cells =
selectedWireCells();
const std::vector<std::string> selected_expressions = selectedExpressionIds();
const std::vector<std::string> selected_wires = selectedWireIds();
const std::vector<std::string> selected_ids = selectedNodeIds();
LogicEditorResult result;
if (selected_expressions.size() > 1U || selected_wires.size() > 1U)
if (selected_empty_columns.size() > 1U || selected_wire_cells.size() > 1U
|| (!selected_empty_columns.empty()
&& (!selected_wire_cells.empty()
|| !selected_expressions.empty() || !selected_ids.empty()))
|| (!selected_wire_cells.empty()
&& (!selected_ids.empty() || selected_expressions.size() > 1U)))
{
result = {false, LogicEditorError::InvalidOperation,
"插入条件时只能选择一个网格或条件对象", {}};
}
else if (selected_empty_columns.size() == 1U)
{
result = editor_service_.insertConditionAtColumn(
logic_id_,
currentRungId(),
selected_empty_columns.front(),
config);
}
else if (selected_wire_cells.size() == 1U)
{
result = editor_service_.replaceWireColumnWithCondition(
logic_id_,
currentRungId(),
selected_wire_cells.front().first,
selected_wire_cells.front().second,
config);
}
else if (selected_expressions.size() > 1U || selected_wires.size() > 1U)
{
result = {false, LogicEditorError::InvalidOperation,
"串联插入或替换横线时只能选择一个条件对象", {}};


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

@@ -7,6 +7,7 @@
#include <QGraphicsView>

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

class QGraphicsScene;
@@ -19,8 +20,10 @@ class LogicEditorWidget final : public QGraphicsView
public:
class NodeItem;
class WireItem;
class WireCellItem;
class VerticalConnectorItem;
class RungItem;
class EmptySlotItem;

explicit LogicEditorWidget(
LogicEditorService &editor_service,
@@ -66,6 +69,8 @@ private:
std::vector<std::string> selectedExpressionIds() const;
std::vector<std::string> selectedWireIds() const;
std::vector<std::string> selectedBranchIds() const;
std::vector<int> selectedEmptySlotColumns() const;
std::vector<std::pair<std::string, int>> selectedWireCells() const;

LogicEditorService &editor_service_;
QGraphicsScene *scene_ = nullptr;


+ 7
- 7
app/src/ui/main_window.ui Vedi File

@@ -1188,7 +1188,7 @@
<string>常开</string>
</property>
<property name="toolTip">
<string>向当前网络添加常开触点</string>
<string>在选中的空网格或横线处添加常开触点;选中条件时在其后插入</string>
</property>
</action>
<action name="addNormallyClosedAction">
@@ -1196,7 +1196,7 @@
<string>常闭</string>
</property>
<property name="toolTip">
<string>向当前网络添加常闭触点</string>
<string>在选中的空网格或横线处添加常闭触点;选中条件时在其后插入</string>
</property>
</action>
<action name="addRisingEdgeAction">
@@ -1204,7 +1204,7 @@
<string>上升沿</string>
</property>
<property name="toolTip">
<string>添加一个扫描周期的上升沿触点</string>
<string>在选中的空网格或横线处添加一个扫描周期的上升沿触点</string>
</property>
</action>
<action name="addFallingEdgeAction">
@@ -1212,7 +1212,7 @@
<string>下降沿</string>
</property>
<property name="toolTip">
<string>添加一个扫描周期的下降沿触点</string>
<string>在选中的空网格或横线处添加一个扫描周期的下降沿触点</string>
</property>
</action>
<action name="addTimerContactAction">
@@ -1220,12 +1220,12 @@
<string>T 触点</string>
</property>
<property name="toolTip">
<string>添加离线仿真定时器触点</string>
<string>在选中的空网格或横线处添加离线仿真定时器触点</string>
</property>
</action>
<action name="addCounterContactAction">
<property name="text"><string>C 触点</string></property>
<property name="toolTip"><string>添加离线仿真计数器完成触点</string></property>
<property name="toolTip"><string>在选中的空网格或横线处添加离线仿真计数器完成触点</string></property>
</action>
<action name="addNormalCoilAction">
<property name="text">
@@ -1284,7 +1284,7 @@
<string>D 比较</string>
</property>
<property name="toolTip">
<string>向当前网络添加 D 值与常量比较</string>
<string>在选中的空网格或横线处添加 D 值与常量比较条件</string>
</property>
</action>
<action name="editRungCommentAction">


+ 339
- 0
app/tests/logic_editor_service_tests.cpp Vedi File

@@ -2,6 +2,7 @@
#include "services/logic_editor_service.h"
#include "services/project_service.h"

#include <algorithm>
#include <iostream>
#include <stdexcept>

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

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

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

void testStructuredEditingAndNormalization()
{
TestProjectStorage storage;
@@ -295,6 +334,303 @@ void testUnconditionalOutputEditing()
"an editor-created output-only network must be runnable as unconditional");
}

void testColumnTargetedConditionInsertion()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string first_rung_id = service.firstRungId(logic_id);

require(service.insertConditionAtColumn(
logic_id, first_rung_id, 4, contact(4)).succeeded,
"an empty network must accept a condition at the selected fifth column");
const LadderRung *rung = service.findRung(logic_id, first_rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.size() == 2U
&& rung->condition->children.front().kind
== ConditionExpressionKind::Wire
&& rung->condition->children.front().wire->columnSpan == 4
&& rung->condition->children.back().kind
== ConditionExpressionKind::Node
&& rung->validate()
&& conditionColumns(*rung->condition) == 5,
"column insertion must preserve the requested horizontal position");

require(service.insertConditionAtColumn(
logic_id, first_rung_id, 2, contact(2)).error
== LogicEditorError::InvalidOperation,
"inserting into an already occupied column must be rejected atomically");
require(service.insertConditionAtColumn(
logic_id, first_rung_id, 10, contact(10)).error
== LogicEditorError::InvalidOperation,
"the eleventh condition column must be rejected");
require(service.insertConditionAtColumn(
logic_id, first_rung_id, -1, contact(10)).error
== LogicEditorError::InvalidOperation,
"a negative grid column must be rejected");
require(service.insertConditionAtColumn(
logic_id,
first_rung_id,
5,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
.error == LogicEditorError::InvalidNode,
"a condition grid slot must reject output instructions");

const LogicEditorResult second_rung = service.addRung(logic_id);
require(second_rung.succeeded,
"column insertion test must create a second empty network");
require(service.setOutput(
logic_id,
second_rung.id,
CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
true)
.succeeded,
"an output-only network must be configurable before adding a condition");
require(service.insertConditionAtColumn(
logic_id, second_rung.id, 0, contact(0)).succeeded,
"a selected first grid slot must work on an output-only network");
const LadderRung *output_rung = service.findRung(logic_id, second_rung.id);
require(output_rung != nullptr && output_rung->condition.has_value()
&& output_rung->condition->kind == ConditionExpressionKind::Node
&& output_rung->output.has_value()
&& output_rung->validate(),
"condition insertion must keep the independent output slot intact");

const LogicEditorResult last_column_rung = service.addRung(logic_id);
require(last_column_rung.succeeded
&& service.insertConditionAtColumn(
logic_id, last_column_rung.id, 9, contact(9)).succeeded,
"the tenth condition column must remain a valid insertion target");
const LadderRung *full_width = service.findRung(
logic_id, last_column_rung.id);
require(full_width != nullptr && full_width->condition.has_value()
&& full_width->condition->kind == ConditionExpressionKind::Series
&& full_width->condition->children.front().wire->columnSpan == 9
&& conditionColumns(*full_width->condition) == 10,
"last-column insertion must fill exactly the ten-column condition area");
require(!service.appendCondition(
logic_id, last_column_rung.id, contact(10)).succeeded
&& conditionColumns(*service.findRung(
logic_id, last_column_rung.id)->condition) == 10,
"a full-width grid must reject another condition without partial changes");
}

void testWireColumnReplacement()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4);
require(wire.succeeded,
"wire-column replacement test must create a four-column wire");

require(service.replaceWireColumnWithCondition(
logic_id, rung_id, wire.id, 1,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 1},
ContactMode::NormallyClosed})
.succeeded,
"a selected wire cell must be replaceable without removing adjacent cells");
const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.size() == 3U
&& rung->condition->children.front().wire->columnSpan == 1
&& rung->condition->children.at(1).kind
== ConditionExpressionKind::Node
&& std::get<ContactNodeConfig>(
rung->condition->children.at(1).node->config).mode
== ContactMode::NormallyClosed
&& rung->condition->children.back().wire->columnSpan == 2
&& conditionColumns(*rung->condition) == 4,
"wire-cell replacement must split the wire around the new contact");

const std::vector<ControlLogic> before_invalid =
project_service.project().controlLogics;
require(service.replaceWireColumnWithCondition(
logic_id,
rung_id,
rung->condition->children.front().id,
1,
contact(2))
.error == LogicEditorError::InvalidOperation,
"an out-of-range wire-cell offset must be rejected");
require(project_service.project().controlLogics.size() == before_invalid.size()
&& conditionColumns(*service.findRung(
logic_id, rung_id)->condition) == 4,
"a rejected wire-cell replacement must leave the network width unchanged");

const LogicEditorResult first_cell_rung = service.addRung(logic_id);
const LogicEditorResult first_cell_wire = service.appendWire(
logic_id, first_cell_rung.id, 4);
require(first_cell_rung.succeeded && first_cell_wire.succeeded
&& service.replaceWireColumnWithCondition(
logic_id,
first_cell_rung.id,
first_cell_wire.id,
0,
contact(10))
.succeeded,
"the first cell of a multi-column wire must be replaceable");
const LadderRung *first_cell = service.findRung(logic_id, first_cell_rung.id);
require(first_cell != nullptr && first_cell->condition.has_value()
&& first_cell->condition->kind == ConditionExpressionKind::Series
&& first_cell->condition->children.size() == 2U
&& first_cell->condition->children.front().kind
== ConditionExpressionKind::Node
&& first_cell->condition->children.back().kind
== ConditionExpressionKind::Wire
&& first_cell->condition->children.back().wire->columnSpan == 3,
"first-cell replacement must preserve the trailing wire cells");
require(service.undo().succeeded,
"wire-cell replacement must be one undoable edit");
const LadderRung *undone = service.findRung(logic_id, first_cell_rung.id);
require(undone != nullptr && undone->condition.has_value()
&& undone->condition->kind == ConditionExpressionKind::Wire
&& undone->condition->wire->columnSpan == 4,
"undo must restore the original unsplit wire");
require(service.redo().succeeded,
"wire-cell replacement must be redoable");
const LadderRung *redone = service.findRung(logic_id, first_cell_rung.id);
require(redone != nullptr && redone->condition.has_value()
&& redone->condition->kind == ConditionExpressionKind::Series
&& conditionColumns(*redone->condition) == 4,
"redo must restore the split wire without changing its width");
const std::string redone_trailing_wire_id =
redone->condition->children.back().id;

const LogicEditorResult last_cell_rung = service.addRung(logic_id);
const LogicEditorResult last_cell_wire = service.appendWire(
logic_id, last_cell_rung.id, 4);
require(last_cell_rung.succeeded && last_cell_wire.succeeded
&& service.replaceWireColumnWithCondition(
logic_id,
last_cell_rung.id,
last_cell_wire.id,
3,
contact(11))
.succeeded,
"the last cell of a multi-column wire must be replaceable");
const LadderRung *last_cell = service.findRung(logic_id, last_cell_rung.id);
require(last_cell != nullptr && last_cell->condition.has_value()
&& last_cell->condition->kind == ConditionExpressionKind::Series
&& last_cell->condition->children.size() == 2U
&& last_cell->condition->children.front().kind
== ConditionExpressionKind::Wire
&& last_cell->condition->children.front().wire->columnSpan == 3
&& last_cell->condition->children.back().kind
== ConditionExpressionKind::Node,
"last-cell replacement must preserve the leading wire cells");

const LogicEditorResult single_cell_rung = service.addRung(logic_id);
const LogicEditorResult single_cell_wire = service.appendWire(
logic_id, single_cell_rung.id, 1);
require(single_cell_rung.succeeded && single_cell_wire.succeeded
&& service.replaceWireColumnWithCondition(
logic_id,
single_cell_rung.id,
single_cell_wire.id,
0,
contact(12))
.succeeded,
"a one-column wire must use the same grid-cell replacement API");
const LadderRung *single_cell = service.findRung(
logic_id, single_cell_rung.id);
require(single_cell != nullptr && single_cell->condition.has_value()
&& single_cell->condition->kind == ConditionExpressionKind::Node,
"one-column wire replacement must normalize directly to a condition node");

require(service.replaceWireColumnWithCondition(
logic_id,
first_cell_rung.id,
redone_trailing_wire_id,
0,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 13}, CoilMode::Set})
.error == LogicEditorError::InvalidNode,
"wire grid cells must reject output instructions");
require(service.replaceWireColumnWithCondition(
logic_id,
first_cell_rung.id,
"missing-wire",
0,
contact(13))
.error == LogicEditorError::ExpressionNotFound,
"wire grid replacement must reject an unknown wire without mutation");
}

void testSequentialConditionInsertionConsumesFollowingWire()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10);
require(wire.succeeded,
"sequential wire replacement must start with a full-width wire");

LogicEditorResult inserted = service.replaceWireColumnWithCondition(
logic_id, rung_id, wire.id, 0, contact(0));
require(inserted.succeeded,
"the first condition must replace the first full-wire cell");
std::string selected_node_id = inserted.id;
for (int address = 1; address < 10; ++address)
{
inserted = service.insertConditionAfter(
logic_id, rung_id, selected_node_id, contact(address));
require(inserted.succeeded,
"continuous condition insertion must consume the following wire cell");
selected_node_id = inserted.id;

const LadderRung *rung = service.findRung(logic_id, rung_id);
std::vector<const LogicNode *> nodes;
collectConditionNodes(*rung->condition, &nodes);
require(rung != nullptr && rung->condition.has_value()
&& conditionColumns(*rung->condition) == 10
&& nodes.size() == static_cast<std::size_t>(address + 1)
&& wireColumns(*rung->condition) == 9 - address,
"each continuous insertion must preserve width while consuming one wire cell");
}

const LadderRung *full = service.findRung(logic_id, rung_id);
require(full != nullptr && full->condition.has_value()
&& conditionColumns(*full->condition) == 10
&& wireColumns(*full->condition) == 0,
"ten continuous insertions must replace the entire wire without expanding it");
require(service.insertConditionAfter(
logic_id, rung_id, selected_node_id, contact(10))
.error == LogicEditorError::InvalidOperation,
"the eleventh condition must still be rejected after all wire cells are consumed");
require(conditionColumns(*service.findRung(
logic_id, rung_id)->condition) == 10,
"a rejected eleventh insertion must leave the full network unchanged");

require(service.undo().succeeded,
"a failed eleventh insertion must not displace the last successful undo step");
const LadderRung *undone = service.findRung(logic_id, rung_id);
std::vector<const LogicNode *> undone_nodes;
collectConditionNodes(*undone->condition, &undone_nodes);
require(undone_nodes.size() == 9U
&& wireColumns(*undone->condition) == 1
&& conditionColumns(*undone->condition) == 10,
"undo must restore nine contacts followed by one wire cell");
require(service.redo().succeeded,
"the final wire-consuming insertion must be redoable");
const LadderRung *redone = service.findRung(logic_id, rung_id);
std::vector<const LogicNode *> redone_nodes;
collectConditionNodes(*redone->condition, &redone_nodes);
require(redone_nodes.size() == 10U
&& wireColumns(*redone->condition) == 0
&& conditionColumns(*redone->condition) == 10,
"redo must restore all ten contacts without wire cells");
}

void testLogicLifecycleAndOrdering()
{
TestProjectStorage storage;
@@ -472,6 +808,9 @@ int main()
testBatchDeleteAllNodesInParallelBranch();
testConditionColumnLimit();
testUnconditionalOutputEditing();
testColumnTargetedConditionInsertion();
testWireColumnReplacement();
testSequentialConditionInsertionConsumesFollowingWire();
testLogicLifecycleAndOrdering();
testEdgeTimerNodesAndRungComments();
testHistoryAndAtomicBatchDelete();


+ 241
- 0
app/tests/main_window_tests.cpp Vedi File

@@ -1,5 +1,6 @@
#include "domain/active_register_repository.h"
#include "domain/project_storage.h"
#include "domain/project_limits.h"
#include "domain/register_repository.h"
#include "services/alarm_editor_service.h"
#include "services/alarm_service.h"
@@ -661,6 +662,183 @@ void testRuntimeAlarmListInteraction()
"AlarmList must become visible again after returning to editing");
}

void testLogicEmptyGridSlotInsertion()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
LogicEditorWidget editor(service);
editor.setLogicId(logic_id);
editor.resize(1400, 360);
editor.show();
QApplication::processEvents();

auto insertionGridItems = [&editor]
{
QList<QGraphicsItem *> items;
for (QGraphicsItem *item : editor.scene()->items())
{
if (item->toolTip().startsWith(QStringLiteral("空条件网格"))
|| item->toolTip().startsWith(QStringLiteral("横线网格")))
{
items.push_back(item);
}
}
std::sort(
items.begin(), items.end(),
[](const QGraphicsItem *left, const QGraphicsItem *right)
{
return left->scenePos().x() < right->scenePos().x();
});
return items;
};

QList<QGraphicsItem *> empty_items = insertionGridItems();
require(empty_items.size() == ProjectLimits::kMaximumConditionColumns
&& !service.findRung(logic_id, rung_id)->condition.has_value(),
"a new network must expose ten selectable grid slots without persisting placeholders");

const QPoint fifth_slot = editor.mapFromScene(empty_items.at(4)->scenePos());
QTest::mouseClick(
editor.viewport(), Qt::LeftButton, Qt::NoModifier, fifth_slot);
require(editor.scene()->selectedItems().size() == 1,
"clicking an empty grid cell must select one insertion position");
require(editor.addCondition(ContactNodeConfig{
RegisterAddress{RegisterArea::M, 4},
ContactMode::NormallyOpen})
.succeeded,
"the toolbar condition path must insert at the selected fifth column");

const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.front().kind
== ConditionExpressionKind::Wire
&& rung->condition->children.front().wire->columnSpan == 4,
"a fifth-column click must become four wire columns followed by the contact");

empty_items = insertionGridItems();
require(empty_items.size() == 9,
"every unoccupied column must stay clickable after the first insertion");
const QPoint second_slot = editor.mapFromScene(empty_items.at(1)->scenePos());
QTest::mouseClick(
editor.viewport(), Qt::LeftButton, Qt::NoModifier, second_slot);
require(editor.addCondition(ContactNodeConfig{
RegisterAddress{RegisterArea::M, 1},
ContactMode::NormallyClosed})
.succeeded,
"a grid cell inside a multi-column wire must accept a contact");
rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.size() == 4U
&& rung->condition->children.front().kind
== ConditionExpressionKind::Wire
&& rung->condition->children.front().wire->columnSpan == 1
&& rung->condition->children.at(2).kind
== ConditionExpressionKind::Wire
&& rung->condition->children.at(2).wire->columnSpan == 2
&& std::get<ContactNodeConfig>(
rung->condition->children.at(1).node->config).mode
== ContactMode::NormallyClosed,
"wire splitting must preserve both surrounding gaps and contact order");

empty_items = insertionGridItems();
require(empty_items.size() == 8,
"two occupied columns must leave eight selectable grid positions");
const QPoint eighth_slot = editor.mapFromScene(empty_items.at(5)->scenePos());
QTest::mouseClick(
editor.viewport(), Qt::LeftButton, Qt::NoModifier, eighth_slot);
require(editor.addCondition(ContactNodeConfig{
RegisterAddress{RegisterArea::M, 7},
ContactMode::NormallyOpen})
.succeeded,
"a trailing empty grid cell must remain a valid insertion target");

require(service.undo().succeeded,
"a grid-targeted insertion must remain one undoable edit");
editor.reloadLogic();
require(insertionGridItems().size() == 8,
"undo must restore the prior condition layout and empty grid slots");

const LogicEditorResult added_rung = service.addRung(logic_id);
require(added_rung.succeeded,
"the grid-slot UI test must create another network");
editor.reloadLogic();
require(insertionGridItems().size() == 18
&& !service.findRung(logic_id, added_rung.id)->condition.has_value(),
"every newly added network must expose its own ten empty insertion slots");
}

void testLogicContinuousInsertionConsumesFullWire()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
LogicEditorWidget editor(service);
editor.setLogicId(logic_id);
editor.resize(1400, 360);
editor.show();
QApplication::processEvents();

for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
{
require(editor.addHorizontalWire().succeeded,
"the UI fixture must fill all ten condition columns with wires");
}

QList<QGraphicsItem *> wire_cells;
for (QGraphicsItem *item : editor.scene()->items())
{
if (item->toolTip().startsWith(QStringLiteral("横线网格")))
{
wire_cells.push_back(item);
}
}
std::sort(
wire_cells.begin(), wire_cells.end(),
[](const QGraphicsItem *left, const QGraphicsItem *right)
{
return left->scenePos().x() < right->scenePos().x();
});
require(wire_cells.size() == ProjectLimits::kMaximumConditionColumns,
"a full wire network must expose ten replaceable wire cells");
QTest::mouseClick(
editor.viewport(),
Qt::LeftButton,
Qt::NoModifier,
editor.mapFromScene(wire_cells.front()->scenePos()));

for (int address = 0; address < ProjectLimits::kMaximumConditionColumns; ++address)
{
require(editor.addCondition(logicContact(address)).succeeded,
"repeated toolbar conditions must consume the next wire cell");
int remaining_wire_cells = 0;
for (QGraphicsItem *item : editor.scene()->items())
{
remaining_wire_cells += item->toolTip().startsWith(
QStringLiteral("横线网格")) ? 1 : 0;
}
require(remaining_wire_cells
== ProjectLimits::kMaximumConditionColumns - address - 1,
"each toolbar condition must consume exactly one visible wire cell");
}

const LadderRung *rung = service.findRung(logic_id, rung_id);
std::vector<const LogicNode *> nodes;
collectConditionNodes(*rung->condition, &nodes);
require(rung != nullptr && rung->condition.has_value()
&& nodes.size() == static_cast<std::size_t>(
ProjectLimits::kMaximumConditionColumns),
"ten repeated toolbar conditions must replace the full wire with ten contacts");
require(!editor.addCondition(logicContact(10)).succeeded,
"the toolbar must reject only the eleventh condition after all wires are consumed");
}

void testWindowTitleTracksUnsavedProjectChanges()
{
TestProjectStorage storage;
@@ -769,6 +947,10 @@ void testModeActionsControlEditingAvailability()
window, "addNormallyOpenAction");
QAction *add_normal_coil_action = requiredChild<QAction>(
window, "addNormalCoilAction");
QAction *add_set_coil_action = requiredChild<QAction>(
window, "addSetCoilAction");
QAction *add_reset_coil_action = requiredChild<QAction>(
window, "addResetCoilAction");
QAction *parallel_insert_action = requiredChild<QAction>(
window, "parallelInsertAction");
QAction *insert_horizontal_wire_action = requiredChild<QAction>(
@@ -1080,6 +1262,41 @@ void testModeActionsControlEditingAvailability()
require(restored_nodes.size() == 1U,
"logic redo must restore the original condition node");
const std::string restored_node_id = restored_nodes.front()->id;

QGraphicsItem *fifth_column_slot = nullptr;
for (QGraphicsItem *item : logic_editor->scene()->items())
{
if (item->toolTip().startsWith(QStringLiteral("空条件网格"))
&& item->toolTip().contains(QStringLiteral("第 5 列")))
{
fifth_column_slot = item;
break;
}
}
require(fifth_column_slot != nullptr,
"the main-window ladder must expose the fifth condition-grid slot");
QTest::mouseClick(
logic_editor->viewport(),
Qt::LeftButton,
Qt::NoModifier,
logic_editor->mapFromScene(fifth_column_slot->scenePos()));
add_normally_open_action->trigger();
const LadderRung &action_inserted_rung = logic_editor_service.findLogic(
logic_editor_service.firstLogicId())->rungs.front();
require(action_inserted_rung.condition.has_value()
&& action_inserted_rung.condition->kind
== ConditionExpressionKind::Series
&& action_inserted_rung.condition->children.size() == 3U
&& action_inserted_rung.condition->children.at(1).kind
== ConditionExpressionKind::Wire
&& action_inserted_rung.condition->children.at(1).wire->columnSpan == 3
&& action_inserted_rung.condition->children.at(2).kind
== ConditionExpressionKind::Node,
"the real contact QAction must insert at the clicked fifth grid column");
undo_action->trigger();
require(logic_editor_service.findLogic(logic_editor_service.firstLogicId())
->rungs.front().condition->kind == ConditionExpressionKind::Node,
"undo after a grid-targeted QAction must restore the prior network");
logic_editor->selectNode(restored_node_id);

insert_horizontal_wire_action->trigger();
@@ -1140,6 +1357,28 @@ void testModeActionsControlEditingAvailability()
"parallel branch action must create a parallel expression");
require(logic->rungs.front().output.has_value(),
"coil action must set the fixed ladder output");
const std::string output_node_id = logic->rungs.front().output->id;
add_set_coil_action->trigger();
logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId());
require(logic->rungs.front().output.has_value()
&& logic->rungs.front().output->id == output_node_id
&& std::get<CoilNodeConfig>(
logic->rungs.front().output->config).mode == CoilMode::Set,
"the set-coil action must switch the existing fixed output in place");
add_reset_coil_action->trigger();
logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId());
require(logic->rungs.front().output.has_value()
&& logic->rungs.front().output->id == output_node_id
&& std::get<CoilNodeConfig>(
logic->rungs.front().output->config).mode == CoilMode::Reset,
"the reset-coil action must reuse the same fixed output slot");
add_normal_coil_action->trigger();
logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId());
require(logic->rungs.front().output.has_value()
&& logic->rungs.front().output->id == output_node_id
&& std::get<CoilNodeConfig>(
logic->rungs.front().output->config).mode == CoilMode::Normal,
"switching back to a normal coil must preserve the output node identity");
offline_action->trigger();
require(mode_service.mode() == ApplicationMode::Editing,
"unconfigured ladder nodes must block offline running");
@@ -1898,6 +2137,8 @@ int main(int argc, char *argv[])
testRuntimeProgressBarRendering();
testRuntimePageJumpDoesNotRequireRegisterWritePermission();
testRuntimeAlarmListInteraction();
testLogicEmptyGridSlotInsertion();
testLogicContinuousInsertionConsumesFullWire();
testWindowTitleTracksUnsavedProjectChanges();
testModeActionsControlEditingAvailability();
testIndependentHmiRuntimeWindowLifecycle();


Caricamento…
Annulla
Salva