Explorar el Código

feat: 支持边沿定时器与软元件注释模型

main
suyu hace 1 mes
padre
commit
66a0a2a472
Se han modificado 10 ficheros con 901 adiciones y 13 borrados
  1. +173
    -5
      app/src/domain/control_logic_model.cpp
  2. +62
    -2
      app/src/domain/control_logic_model.h
  3. +59
    -0
      app/src/domain/project_model.cpp
  4. +11
    -0
      app/src/domain/project_model.h
  5. +196
    -3
      app/src/infrastructure/json_project_storage.cpp
  6. +102
    -0
      app/src/services/register_comment_service.cpp
  7. +37
    -0
      app/src/services/register_comment_service.h
  8. +107
    -2
      app/tests/domain_tests.cpp
  9. +152
    -1
      app/tests/project_management_tests.cpp
  10. +2
    -0
      app/tests/project_management_tests.pro

+ 173
- 5
app/src/domain/control_logic_model.cpp Ver fichero

@@ -1,6 +1,8 @@
#include "control_logic_model.h"

#include <algorithm>
#include <map>
#include <type_traits>
#include <utility>

namespace {
@@ -29,6 +31,21 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error)
return true;
}

bool validateConfig(const EdgeContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::M)
{
setError(error, "边沿触点必须使用有效的 M 区地址");
return false;
}
if (config.mode != EdgeMode::Rising && config.mode != EdgeMode::Falling)
{
setError(error, "边沿触点使用了不支持的模式");
return false;
}
return true;
}

bool validateConfig(const CoilNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::M)
@@ -66,6 +83,38 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error)
return true;
}

bool validateConfig(const TimerContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid())
{
setError(error, "定时器触点必须使用有效的 T 地址");
return false;
}
if (config.mode != ContactMode::NormallyOpen
&& config.mode != ContactMode::NormallyClosed)
{
setError(error, "定时器触点使用了不支持的模式");
return false;
}
return true;
}

bool validateConfig(const TonNodeConfig &config, std::string *error)
{
if (!config.address.isValid())
{
setError(error, "TON 必须使用有效的 T 地址");
return false;
}
if (config.presetMs < TonNodeConfig::kMinimumPresetMs
|| config.presetMs > TonNodeConfig::kMaximumPresetMs)
{
setError(error, "TON 预设时间必须在 1~86400000 ms 范围内");
return false;
}
return true;
}

template<typename TItem>
bool hasDuplicateId(const std::vector<TItem> &items)
{
@@ -134,6 +183,55 @@ void normalizeExpression(ConditionExpression *expression)

} // namespace

TimerAddress::TimerAddress(int index)
: index_(index)
{
}

int TimerAddress::index() const
{
return index_;
}

bool TimerAddress::isValid() const
{
return index_ >= kMinimumIndex && index_ <= kMaximumIndex;
}

std::string TimerAddress::toString() const
{
return isValid() ? "T" + std::to_string(index_) : "InvalidTimerAddress";
}

bool TimerAddress::operator==(const TimerAddress &other) const
{
return index_ == other.index_;
}

bool TimerAddress::operator!=(const TimerAddress &other) const
{
return !(*this == other);
}

std::optional<RegisterAddress> registerAddressForLogicNode(
const LogicNodeConfig &config)
{
return std::visit(
[](const auto &value) -> std::optional<RegisterAddress>
{
using Config = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>
|| std::is_same_v<Config, EdgeContactNodeConfig>
|| std::is_same_v<Config, CoilNodeConfig>
|| std::is_same_v<Config, CompareNodeConfig>)
{
return value.address;
}
return std::nullopt;
},
config);
}

bool LogicNode::validate(std::string *error) const
{
if (id.empty())
@@ -153,12 +251,14 @@ bool LogicNode::isConfigured() const

bool LogicNode::isCondition() const
{
return !std::holds_alternative<CoilNodeConfig>(config);
return !std::holds_alternative<CoilNodeConfig>(config)
&& !std::holds_alternative<TonNodeConfig>(config);
}

bool LogicNode::isOutput() const
{
return std::holds_alternative<CoilNodeConfig>(config);
return std::holds_alternative<CoilNodeConfig>(config)
|| std::holds_alternative<TonNodeConfig>(config);
}

ConditionExpression ConditionExpression::fromNode(LogicNode logic_node)
@@ -343,7 +443,7 @@ bool LadderRung::validateForRunning(std::string *error) const
}
if (!condition.has_value() || !output.has_value())
{
setError(error, "未完成的梯形图网络必须同时包含条件和输出线圈");
setError(error, "未完成的梯形图网络必须同时包含条件和输出节点");
return false;
}
if (!condition->validateForRunning(error))
@@ -352,7 +452,7 @@ bool LadderRung::validateForRunning(std::string *error) const
}
if (!output->isConfigured())
{
setError(error, "输出线圈 " + output->id + " 尚未配置");
setError(error, "输出节点 " + output->id + " 尚未配置");
return false;
}
return true;
@@ -383,7 +483,7 @@ bool LadderRung::validateStructure(std::string *error) const
{
if (!output->validate(error) || !output->isOutput())
{
setError(error, "梯形图网络输出必须是线圈节点");
setError(error, "梯形图网络输出必须是线圈或 TON 指令");
return false;
}
node_ids.push_back(output->id);
@@ -479,3 +579,71 @@ bool ControlLogic::validateForRunning(std::string *error) const
}
return true;
}

bool validateTimerReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error)
{
std::map<int, std::string> timer_outputs;
for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.output.has_value())
{
continue;
}
const auto *ton = std::get_if<TonNodeConfig>(&rung.output->config);
if (ton == nullptr)
{
continue;
}
const auto inserted = timer_outputs.emplace(
ton->address.index(), rung.output->id);
if (!inserted.second)
{
setError(
error,
"定时器 " + ton->address.toString()
+ " 只能由一个已启用 TON 指令驱动");
return false;
}
}
}

for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.condition.has_value())
{
continue;
}
std::vector<const LogicNode *> nodes;
collectConditionNodes(*rung.condition, &nodes);
for (const LogicNode *node : nodes)
{
const auto *contact = std::get_if<TimerContactNodeConfig>(
&node->config);
if (contact != nullptr
&& timer_outputs.count(contact->address.index()) == 0U)
{
setError(
error,
"定时器触点 " + contact->address.toString()
+ " 没有对应的已启用 TON 指令");
return false;
}
}
}
}
return true;
}

+ 62
- 2
app/src/domain/control_logic_model.h Ver fichero

@@ -21,6 +21,12 @@ enum class CoilMode
Reset
};

enum class EdgeMode
{
Rising,
Falling
};

enum class ComparisonOperator
{
Equal,
@@ -37,6 +43,12 @@ struct ContactNodeConfig
ContactMode mode = ContactMode::NormallyOpen;
};

struct EdgeContactNodeConfig
{
RegisterAddress address{RegisterArea::M, 0};
EdgeMode mode = EdgeMode::Rising;
};

struct CoilNodeConfig
{
RegisterAddress address{RegisterArea::M, 0};
@@ -50,7 +62,50 @@ struct CompareNodeConfig
std::int16_t value = 0;
};

using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>;
class TimerAddress
{
public:
static constexpr int kMinimumIndex = 0;
static constexpr int kMaximumIndex = 4000;

explicit TimerAddress(int index = 0);

int index() const;
bool isValid() const;
std::string toString() const;

bool operator==(const TimerAddress &other) const;
bool operator!=(const TimerAddress &other) const;

private:
int index_ = 0;
};

struct TimerContactNodeConfig
{
TimerAddress address;
ContactMode mode = ContactMode::NormallyOpen;
};

struct TonNodeConfig
{
static constexpr int kMinimumPresetMs = 1;
static constexpr int kMaximumPresetMs = 86400000;

TimerAddress address;
int presetMs = 1000;
};

using LogicNodeConfig = std::variant<
ContactNodeConfig,
EdgeContactNodeConfig,
TimerContactNodeConfig,
CoilNodeConfig,
CompareNodeConfig,
TonNodeConfig>;

std::optional<RegisterAddress> registerAddressForLogicNode(
const LogicNodeConfig &config);

struct LogicNode
{
@@ -99,11 +154,12 @@ void collectConditionNodes(
void collectConditionExpressionIds(
const ConditionExpression &expression, std::vector<std::string> *ids);

// 一个网络包含一棵结构化条件表达式,输出线圈固定在最右侧
// 一个网络包含一棵结构化条件表达式,输出指令固定在最右侧
struct LadderRung
{
std::string id;
std::string name;
std::string comment;
std::optional<ConditionExpression> condition;
std::optional<LogicNode> output;

@@ -123,3 +179,7 @@ struct ControlLogic
bool validateStructure(std::string *error = nullptr) const;
bool validateForRunning(std::string *error = nullptr) const;
};

bool validateTimerReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error = nullptr);

+ 59
- 0
app/src/domain/project_model.cpp Ver fichero

@@ -1,6 +1,7 @@
#include "project_model.h"

#include <algorithm>
#include <cctype>

namespace {

@@ -53,8 +54,43 @@ bool containsDuplicateName(const std::vector<TItem> &items)
return false;
}

bool isBlank(const std::string &value)
{
return value.empty()
|| std::all_of(
value.cbegin(), value.cend(),
[](unsigned char character) { return std::isspace(character) != 0; });
}

} // namespace

bool RegisterComment::validate(std::string *error) const
{
if (!address.isValid())
{
setError(error, "软元件注释必须使用有效的 M/D 地址");
return false;
}
if (isBlank(text))
{
setError(error, "软元件注释内容不能为空");
return false;
}
return true;
}

const RegisterComment *Project::findRegisterComment(
const RegisterAddress &address) const
{
const auto comment = std::find_if(
registerComments.cbegin(), registerComments.cend(),
[&address](const RegisterComment &candidate)
{
return candidate.address == address;
});
return comment == registerComments.cend() ? nullptr : &*comment;
}

bool Project::validate(std::string *error) const
{
if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty())
@@ -113,6 +149,25 @@ bool Project::validate(std::string *error) const
return false;
}
}
for (auto current = registerComments.cbegin(); current != registerComments.cend(); ++current)
{
if (!current->validate(error))
{
return false;
}
const auto duplicate = std::find_if(
current + 1,
registerComments.cend(),
[&current](const RegisterComment &candidate)
{
return candidate.address == current->address;
});
if (duplicate != registerComments.cend())
{
setError(error, "同一 M/D 地址只能保存一条软元件注释");
return false;
}
}
for (const HmiPage &page : hmiPages)
{
// 工程聚合校验会向下委托页面和控件的完整规则
@@ -171,5 +226,9 @@ bool Project::validateForRunning(std::string *error) const
return false;
}
}
if (!validateTimerReferencesForRunning(controlLogics, error))
{
return false;
}
return true;
}

+ 11
- 0
app/src/domain/project_model.h Ver fichero

@@ -7,6 +7,14 @@
#include <string>
#include <vector>

struct RegisterComment
{
RegisterAddress address{RegisterArea::M, 0};
std::string text;

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

// 描述工程的基本身份和文件格式信息
struct ProjectMetadata
{
@@ -29,9 +37,12 @@ struct Project
std::string initialHmiPageId;
// 工程统一管理的报警定义
std::vector<AlarmDefinition> alarmDefinitions;
// 工程级 M/D 地址注释,不包含当前值和引用位置
std::vector<RegisterComment> registerComments;
// 工程包含的控制逻辑集合
std::vector<ControlLogic> controlLogics;

const RegisterComment *findRegisterComment(const RegisterAddress &address) const;
// 校验工程配置并通过 error 返回失败原因
bool validate(std::string *error = nullptr) const;
bool validateForRunning(std::string *error = nullptr) const;


+ 196
- 3
app/src/infrastructure/json_project_storage.cpp Ver fichero

@@ -253,6 +253,35 @@ bool parseAddress(
return true;
}

QJsonObject serializeTimerAddress(const TimerAddress &address)
{
QJsonObject object;
object.insert(QStringLiteral("index"), address.index());
return object;
}

bool parseTimerAddress(
const QJsonObject &object,
const std::string &context,
TimerAddress *address,
ParseState *state)
{
int index = 0;
if (!readInt(
object,
"index",
context,
TimerAddress::kMinimumIndex,
TimerAddress::kMaximumIndex,
&index,
state))
{
return false;
}
*address = TimerAddress{index};
return true;
}

// 将 HMI 控件类型枚举转换为工程文件中的稳定字符串
QString hmiControlTypeName(HmiControlType type)
{
@@ -478,6 +507,29 @@ bool parseAlarmDefinition(
return true;
}

QJsonObject serializeRegisterComment(const RegisterComment &comment)
{
QJsonObject object;
object.insert(QStringLiteral("address"), serializeAddress(comment.address));
object.insert(QStringLiteral("text"), fromUtf8(comment.text));
return object;
}

bool parseRegisterComment(
const QJsonObject &object,
const std::string &context,
RegisterComment *comment,
ParseState *state)
{
QJsonObject address;
if (!readObject(object, "address", context, &address, state)
|| !readString(object, "text", context, &comment->text, state))
{
return false;
}
return parseAddress(address, context + ".address", &comment->address, state);
}

// 将 HMI 控件矩形区域序列化为 JSON 对象
QJsonObject serializeBounds(const HmiRect &bounds)
{
@@ -758,6 +810,33 @@ bool parseContactMode(
return true;
}

QString edgeModeName(EdgeMode mode)
{
return mode == EdgeMode::Rising
? QStringLiteral("rising")
: QStringLiteral("falling");
}

bool parseEdgeMode(
const std::string &value, EdgeMode *mode, ParseState *state)
{
if (value == "rising")
{
*mode = EdgeMode::Rising;
}
else if (value == "falling")
{
*mode = EdgeMode::Falling;
}
else
{
return state->fail(
ProjectStorageError::InvalidField,
"不支持的边沿模式:" + value);
}
return true;
}

// 将线圈模式枚举转换为工程文件中的稳定字符串
QString coilModeName(CoilMode mode)
{
@@ -891,6 +970,24 @@ QJsonObject serializeNodeConfig(const ContactNodeConfig &config)
return object;
}

QJsonObject serializeNodeConfig(const EdgeContactNodeConfig &config)
{
QJsonObject object;
object.insert(QStringLiteral("type"), QStringLiteral("edgeContact"));
object.insert(QStringLiteral("address"), serializeAddress(config.address));
object.insert(QStringLiteral("mode"), edgeModeName(config.mode));
return object;
}

QJsonObject serializeNodeConfig(const TimerContactNodeConfig &config)
{
QJsonObject object;
object.insert(QStringLiteral("type"), QStringLiteral("timerContact"));
object.insert(QStringLiteral("timer"), serializeTimerAddress(config.address));
object.insert(QStringLiteral("mode"), contactModeName(config.mode));
return object;
}

// 将线圈节点配置序列化,并写入用于反序列化分派的类型标记
QJsonObject serializeNodeConfig(const CoilNodeConfig &config)
{
@@ -912,6 +1009,15 @@ QJsonObject serializeNodeConfig(const CompareNodeConfig &config)
return object;
}

QJsonObject serializeNodeConfig(const TonNodeConfig &config)
{
QJsonObject object;
object.insert(QStringLiteral("type"), QStringLiteral("ton"));
object.insert(QStringLiteral("timer"), serializeTimerAddress(config.address));
object.insert(QStringLiteral("presetMs"), config.presetMs);
return object;
}

// 将逻辑节点序列化,并根据 variant 中的实际配置类型选择对应重载
QJsonObject serializeLogicNode(const LogicNode &node)
{
@@ -938,13 +1044,56 @@ bool parseNodeConfig(
ParseState *state)
{
std::string type;
QJsonObject address_object;
if (!readString(object, "type", context, &type, state)
|| !readObject(object, "address", context, &address_object, state))
if (!readString(object, "type", context, &type, state))
{
return false;
}

if (type == "timerContact" || type == "ton")
{
QJsonObject timer_object;
if (!readObject(object, "timer", context, &timer_object, state))
{
return false;
}
TimerAddress address;
if (!parseTimerAddress(timer_object, context + ".timer", &address, state))
{
return false;
}
if (type == "timerContact")
{
std::string mode_text;
ContactMode mode = ContactMode::NormallyOpen;
if (!readString(object, "mode", context, &mode_text, state)
|| !parseContactMode(mode_text, &mode, state))
{
return false;
}
*config = TimerContactNodeConfig{address, mode};
return true;
}
int preset_ms = 0;
if (!readInt(
object,
"presetMs",
context,
TonNodeConfig::kMinimumPresetMs,
TonNodeConfig::kMaximumPresetMs,
&preset_ms,
state))
{
return false;
}
*config = TonNodeConfig{address, preset_ms};
return true;
}

QJsonObject address_object;
if (!readObject(object, "address", context, &address_object, state))
{
return false;
}
RegisterAddress address{RegisterArea::M, 0};
if (!parseAddress(address_object, context + ".address", &address, state))
{
@@ -964,6 +1113,18 @@ bool parseNodeConfig(
*config = ContactNodeConfig{address, mode};
return true;
}
if (type == "edgeContact")
{
std::string mode_text;
EdgeMode mode = EdgeMode::Rising;
if (!readString(object, "mode", context, &mode_text, state)
|| !parseEdgeMode(mode_text, &mode, state))
{
return false;
}
*config = EdgeContactNodeConfig{address, mode};
return true;
}
if (type == "coil")
{
std::string mode_text;
@@ -1127,6 +1288,7 @@ 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()
@@ -1149,6 +1311,7 @@ bool parseLadderRung(
QJsonValue output;
if (!readString(object, "id", context, &rung->id, state)
|| !readString(object, "name", context, &rung->name, state)
|| !readString(object, "comment", context, &rung->comment, state)
|| !readValue(object, "output", context, &output, state))
{
return false;
@@ -1270,6 +1433,12 @@ QJsonObject serializeProject(const Project &project)
alarms.append(serializeAlarmDefinition(definition));
}

QJsonArray register_comments;
for (const RegisterComment &comment : project.registerComments)
{
register_comments.append(serializeRegisterComment(comment));
}

QJsonObject object;
object.insert(QStringLiteral("formatVersion"), fromUtf8(project.metadata.formatVersion));
object.insert(QStringLiteral("id"), fromUtf8(project.metadata.id));
@@ -1278,6 +1447,7 @@ QJsonObject serializeProject(const Project &project)
object.insert(QStringLiteral("initialHmiPageId"),
fromUtf8(project.initialHmiPageId));
object.insert(QStringLiteral("alarmDefinitions"), alarms);
object.insert(QStringLiteral("registerComments"), register_comments);
object.insert(QStringLiteral("controlLogics"), logics);
return object;
}
@@ -1288,6 +1458,7 @@ bool parseProject(
{
QJsonArray pages;
QJsonArray alarms;
QJsonArray register_comments;
QJsonArray logics;
if (!readString(
object,
@@ -1312,6 +1483,7 @@ bool parseProject(
|| !readString(object, "initialHmiPageId", "project",
&project->initialHmiPageId, state)
|| !readArray(object, "alarmDefinitions", "project", &alarms, state)
|| !readArray(object, "registerComments", "project", &register_comments, state)
|| !readArray(object, "controlLogics", "project", &logics, state))
{
return false;
@@ -1360,6 +1532,27 @@ bool parseProject(
project->alarmDefinitions.push_back(std::move(definition));
}

project->registerComments.reserve(static_cast<std::size_t>(register_comments.size()));
for (int index = 0; index < register_comments.size(); ++index)
{
if (!register_comments.at(index).isObject())
{
return state->fail(
ProjectStorageError::InvalidField,
"project.registerComments 的元素必须是对象");
}
RegisterComment comment;
if (!parseRegisterComment(
register_comments.at(index).toObject(),
"project.registerComments[" + std::to_string(index) + ']',
&comment,
state))
{
return false;
}
project->registerComments.push_back(std::move(comment));
}

project->controlLogics.reserve(static_cast<std::size_t>(logics.size()));
for (int index = 0; index < logics.size(); ++index)
{


+ 102
- 0
app/src/services/register_comment_service.cpp Ver fichero

@@ -0,0 +1,102 @@
#include "register_comment_service.h"

#include "project_service.h"

#include <algorithm>
#include <cctype>
#include <utility>

namespace {

std::string trim(const std::string &value)
{
const auto first = std::find_if_not(
value.cbegin(), value.cend(),
[](unsigned char character) { return std::isspace(character) != 0; });
const auto last = std::find_if_not(
value.crbegin(), value.crend(),
[](unsigned char character) { return std::isspace(character) != 0; }).base();
return first >= last ? std::string{} : std::string(first, last);
}

RegisterCommentResult failure(
RegisterCommentError error, const std::string &message)
{
return {false, error, message};
}

} // namespace

RegisterCommentService::RegisterCommentService(ProjectService &project_service)
: project_service_(project_service)
{
}

const std::vector<RegisterComment> &RegisterCommentService::comments() const
{
return project_service_.project().registerComments;
}

const RegisterComment *RegisterCommentService::findComment(
const RegisterAddress &address) const
{
return project_service_.project().findRegisterComment(address);
}

RegisterCommentResult RegisterCommentService::setComment(
const RegisterAddress &address, const std::string &text)
{
const std::string normalized = trim(text);
RegisterComment candidate{address, normalized};
std::string error;
if (!candidate.validate(&error))
{
return failure(RegisterCommentError::InvalidComment, error);
}

Project &project = project_service_.editProject();
auto existing = std::find_if(
project.registerComments.begin(), project.registerComments.end(),
[&address](const RegisterComment &comment)
{
return comment.address == address;
});
if (existing == project.registerComments.end())
{
project.registerComments.push_back(std::move(candidate));
}
else
{
existing->text = normalized;
}
std::sort(
project.registerComments.begin(), project.registerComments.end(),
[](const RegisterComment &left, const RegisterComment &right)
{
if (left.address.area() != right.address.area())
{
return left.address.area() == RegisterArea::M;
}
return left.address.index() < right.address.index();
});
return {true, RegisterCommentError::None, {}};
}

RegisterCommentResult RegisterCommentService::removeComment(
const RegisterAddress &address)
{
if (findComment(address) == nullptr)
{
return failure(RegisterCommentError::NotFound, "未找到软元件注释");
}
Project &project = project_service_.editProject();
project.registerComments.erase(
std::remove_if(
project.registerComments.begin(), project.registerComments.end(),
[&address](const RegisterComment &comment)
{
return comment.address == address;
}),
project.registerComments.end());
return {true, RegisterCommentError::None, {}};
}

+ 37
- 0
app/src/services/register_comment_service.h Ver fichero

@@ -0,0 +1,37 @@
#pragma once

#include "domain/project_model.h"

#include <string>
#include <vector>

class ProjectService;

enum class RegisterCommentError
{
None,
InvalidComment,
NotFound
};

struct RegisterCommentResult
{
bool succeeded = false;
RegisterCommentError error = RegisterCommentError::None;
std::string message;
};

class RegisterCommentService
{
public:
explicit RegisterCommentService(ProjectService &project_service);

const std::vector<RegisterComment> &comments() const;
const RegisterComment *findComment(const RegisterAddress &address) const;
RegisterCommentResult setComment(
const RegisterAddress &address, const std::string &text);
RegisterCommentResult removeComment(const RegisterAddress &address);

private:
ProjectService &project_service_;
};

+ 107
- 2
app/tests/domain_tests.cpp Ver fichero

@@ -169,7 +169,7 @@ void testMultiPageAndLogicDomainRules()
disabled_draft.name = "Draft logic";
disabled_draft.enabled = false;
disabled_draft.rungs.push_back(
{"rung-1", "Draft network", std::nullopt, std::nullopt});
{"rung-1", "Draft network", {}, std::nullopt, std::nullopt});
project.controlLogics.push_back(disabled_draft);
require(project.validateForRunning(),
"a disabled draft logic must not block offline running");
@@ -208,6 +208,108 @@ void testLogicNodeConfigurationBoundaries()
require(comparison.validate(), "comparison node bound to D address must be valid");
}

void testTimerAndCommentBoundaries()
{
LogicNode edge;
edge.id = "edge";
edge.config = EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, 0}, EdgeMode::Rising};
require(edge.validate(), "a valid rising edge contact must pass validation");

LogicNode timer_contact;
timer_contact.id = "timer-contact";
timer_contact.config = TimerContactNodeConfig{
TimerAddress{4000}, ContactMode::NormallyOpen};
require(timer_contact.validate(), "T4000 must be a valid timer contact");
timer_contact.config = TimerContactNodeConfig{
TimerAddress{-1}, ContactMode::NormallyOpen};
require(!timer_contact.validate(), "a negative T address must be rejected");

LogicNode ton;
ton.id = "ton";
ton.config = TonNodeConfig{TimerAddress{0}, TonNodeConfig::kMinimumPresetMs};
require(ton.validate(), "the minimum TON preset must be valid");
ton.config = TonNodeConfig{TimerAddress{0}, 0};
require(!ton.validate(), "a zero TON preset must be rejected");
ton.config = TonNodeConfig{
TimerAddress{0}, TonNodeConfig::kMaximumPresetMs + 1};
require(!ton.validate(), "an oversized TON preset must be rejected");

RegisterComment comment{RegisterAddress{RegisterArea::M, 0}, "启动按钮"};
require(comment.validate(), "a nonblank register comment must be valid");
comment.text = " \t";
require(!comment.validate(), "a blank register comment must be rejected");

Project project = makeValidProject();
project.registerComments = {
{RegisterAddress{RegisterArea::M, 0}, "启动按钮"},
{RegisterAddress{RegisterArea::M, 0}, "重复地址"}};
require(!project.validate(), "duplicate register comments must be rejected");
}

LadderRung makeTimerRung(
const std::string &rung_id,
const std::string &condition_id,
const std::string &output_id,
int timer_index,
int preset_ms)
{
LogicNode condition;
condition.id = condition_id;
condition.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, timer_index}, ContactMode::NormallyOpen};
LogicNode output;
output.id = output_id;
output.config = TonNodeConfig{TimerAddress{timer_index}, preset_ms};
LadderRung rung;
rung.id = rung_id;
rung.name = rung_id;
rung.condition = ConditionExpression::fromNode(condition);
rung.output = output;
return rung;
}

void testTimerReferencesForRunning()
{
ControlLogic valid;
valid.id = "timer-valid";
valid.name = "Timer valid";
valid.rungs.push_back(makeTimerRung("rung-0", "input-0", "ton-0", 0, 100));
require(validateTimerReferencesForRunning({valid}),
"a timer contact-free TON network must pass timer reference validation");

ControlLogic duplicate = valid;
duplicate.id = "timer-duplicate";
duplicate.name = "Timer duplicate";
duplicate.rungs.front().output->id = "ton-duplicate";
require(!validateTimerReferencesForRunning({valid, duplicate}),
"the same T must not have two enabled TON drivers");

ControlLogic missing;
missing.id = "timer-missing";
missing.name = "Timer missing";
LogicNode contact;
contact.id = "missing-contact";
contact.config = TimerContactNodeConfig{
TimerAddress{7}, ContactMode::NormallyOpen};
LogicNode output;
output.id = "missing-coil";
output.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal};
LadderRung missing_rung;
missing_rung.id = "missing-rung";
missing_rung.name = "Missing rung";
missing_rung.condition = ConditionExpression::fromNode(contact);
missing_rung.output = output;
missing.rungs.push_back(missing_rung);
require(!validateTimerReferencesForRunning({missing}),
"a T contact without an enabled TON driver must be rejected");

missing.enabled = false;
require(validateTimerReferencesForRunning({missing}),
"disabled timer drafts must not block runtime timer validation");
}

void testLadderLogicBoundaries()
{
LogicNode stop;
@@ -269,7 +371,8 @@ void testLadderLogicBoundaries()
require(!logic.validateForRunning(),
"conditions without an output must block runtime validation");

LadderRung empty_rung{"rung-empty", "Empty network", std::nullopt, std::nullopt};
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;
@@ -365,6 +468,8 @@ int main()
testRegisterAddressParsing();
testRegisterRepositorySeparatesAreas();
testLogicNodeConfigurationBoundaries();
testTimerAndCommentBoundaries();
testTimerReferencesForRunning();
testLadderLogicBoundaries();
testModelsValidateBindingsAndIdentifiers();
testMultiPageAndLogicDomainRules();


+ 152
- 1
app/tests/project_management_tests.cpp Ver fichero

@@ -1,5 +1,6 @@
#include "domain/project_storage.h"
#include "infrastructure/json_project_storage.h"
#include "services/register_comment_service.h"
#include "services/project_service.h"

#include <QFile>
@@ -125,6 +126,7 @@ Project makeExampleProject()
LadderRung rung;
rung.id = "rung-1";
rung.name = "Network 1";
rung.comment = "启动条件与温度检查";
ConditionExpression parallel;
parallel.id = "parallel-start";
parallel.kind = ConditionExpressionKind::Parallel;
@@ -141,6 +143,47 @@ Project makeExampleProject()
rung.output = coil;
logic.rungs.push_back(rung);

LogicNode rising_edge;
rising_edge.id = "rising-edge";
rising_edge.config = EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, 4}, EdgeMode::Rising};
LogicNode ton_output;
ton_output.id = "ton-output";
ton_output.config = TonNodeConfig{TimerAddress{7}, 2500};
LadderRung ton_rung;
ton_rung.id = "ton-rung";
ton_rung.name = "TON network";
ton_rung.comment = "延时启动";
ton_rung.condition = ConditionExpression::fromNode(rising_edge);
ton_rung.output = ton_output;
logic.rungs.push_back(ton_rung);

LogicNode falling_edge;
falling_edge.id = "falling-edge";
falling_edge.config = EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, 5}, EdgeMode::Falling};
LogicNode timer_contact;
timer_contact.id = "timer-contact";
timer_contact.config = TimerContactNodeConfig{
TimerAddress{7}, ContactMode::NormallyOpen};
LogicNode timer_coil;
timer_coil.id = "timer-coil";
timer_coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 6}, CoilMode::Normal};
LadderRung timer_rung;
timer_rung.id = "timer-rung";
timer_rung.name = "Timer feedback";
timer_rung.comment = "定时器完成反馈";
ConditionExpression timer_series;
timer_series.id = "timer-series";
timer_series.kind = ConditionExpressionKind::Series;
timer_series.children = {
ConditionExpression::fromNode(falling_edge),
ConditionExpression::fromNode(timer_contact)};
timer_rung.condition = std::move(timer_series);
timer_rung.output = timer_coil;
logic.rungs.push_back(timer_rung);

Project project;
project.metadata = {"example-project", "Example project", "1.0"};
project.hmiPages.push_back(page);
@@ -158,13 +201,16 @@ Project makeExampleProject()
AlarmCondition::DHigh,
80,
"Temperature high"});
project.registerComments = {
{RegisterAddress{RegisterArea::M, 0}, "启动按钮"},
{RegisterAddress{RegisterArea::D, 2}, "当前温度"}};
project.controlLogics.push_back(logic);
ControlLogic draft_logic;
draft_logic.id = "draft-logic";
draft_logic.name = "Draft logic";
draft_logic.enabled = false;
draft_logic.rungs.push_back(
{"rung-1", "Draft network", std::nullopt, std::nullopt});
{"rung-1", "Draft network", {}, std::nullopt, std::nullopt});
project.controlLogics.push_back(draft_logic);
return project;
}
@@ -227,6 +273,10 @@ void testExampleProjectRoundTrip()
const QString missing_initial_path = directory.filePath("missing-initial.json");
const QString missing_target_path = directory.filePath("missing-target.json");
const QString missing_alarms_path = directory.filePath("missing-alarms.json");
const QString missing_register_comments_path = directory.filePath(
"missing-register-comments.json");
const QString missing_rung_comment_path = directory.filePath(
"missing-rung-comment.json");
require(service.saveAs(first_path.toStdString()).succeeded,
"example project save must succeed");
const QByteArray saved_json = readBytes(first_path);
@@ -242,6 +292,12 @@ void testExampleProjectRoundTrip()
&& saved_json.contains("\"initialHmiPageId\": \"main-page\"")
&& saved_json.contains("\"targetPageId\": \"settings-page\"")
&& saved_json.contains("\"alarmDefinitions\"")
&& saved_json.contains("\"registerComments\"")
&& saved_json.contains("\"text\": \"启动按钮\"")
&& saved_json.contains("\"type\": \"edgeContact\"")
&& saved_json.contains("\"type\": \"timerContact\"")
&& saved_json.contains("\"type\": \"ton\"")
&& saved_json.contains("\"comment\": \"启动条件与温度检查\"")
&& saved_json.contains("\"type\": \"alarmList\""),
"version 1.0 projects must persist pages and alarm definitions");
require(!saved_json.contains("\"stages\"")
@@ -286,11 +342,18 @@ void testExampleProjectRoundTrip()
== AlarmCondition::MOn
&& project.alarmDefinitions.at(1).threshold == 80,
"M and D alarm definitions must survive round trip");
require(project.registerComments.size() == 2
&& project.registerComments.front().address.area() == RegisterArea::M
&& project.registerComments.front().text == "启动按钮"
&& project.registerComments.at(1).address.area() == RegisterArea::D,
"M/D register comments must survive round trip");
require(project.controlLogics.size() == 2,
"control logic count must survive round trip");
require(!project.controlLogics.front().enabled,
"control logic enabled state must survive round trip");
const LadderRung &rung = project.controlLogics.front().rungs.front();
require(rung.comment == "启动条件与温度检查",
"rung comments must survive round trip");
require(rung.condition.has_value()
&& rung.condition->kind == ConditionExpressionKind::Series,
"series root expression must survive round trip");
@@ -301,6 +364,32 @@ void testExampleProjectRoundTrip()
require(rung.output.has_value(),
"ladder output must survive round trip");

const LadderRung &ton_rung = project.controlLogics.front().rungs.at(1);
require(ton_rung.output.has_value()
&& std::holds_alternative<TonNodeConfig>(ton_rung.output->config)
&& std::get<TonNodeConfig>(ton_rung.output->config).address
== TimerAddress{7}
&& std::get<TonNodeConfig>(ton_rung.output->config).presetMs == 2500,
"TON timer address and preset must survive round trip");
require(std::holds_alternative<EdgeContactNodeConfig>(
ton_rung.condition->node->config)
&& std::get<EdgeContactNodeConfig>(ton_rung.condition->node->config).mode
== EdgeMode::Rising,
"rising edge configuration must survive round trip");
const LadderRung &timer_rung = project.controlLogics.front().rungs.at(2);
require(timer_rung.comment == "定时器完成反馈"
&& std::holds_alternative<TimerContactNodeConfig>(
timer_rung.condition->children.at(1).node->config)
&& std::get<TimerContactNodeConfig>(
timer_rung.condition->children.at(1).node->config).address
== TimerAddress{7}
&& std::holds_alternative<EdgeContactNodeConfig>(
timer_rung.condition->children.front().node->config)
&& std::get<EdgeContactNodeConfig>(
timer_rung.condition->children.front().node->config).mode
== EdgeMode::Falling,
"falling edge and T contact configurations must survive round trip");

const auto &compare = std::get<CompareNodeConfig>(
rung.condition->children.at(1).node->config);
require(compare.address.index() == 2 && compare.value == 100,
@@ -339,6 +428,35 @@ void testExampleProjectRoundTrip()
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require alarmDefinitions without migration defaults");

QJsonObject missing_register_comments = QJsonDocument::fromJson(saved_json).object();
missing_register_comments.remove(QStringLiteral("registerComments"));
writeText(
missing_register_comments_path,
QJsonDocument(missing_register_comments).toJson(QJsonDocument::Compact));
missing_result = service.load(missing_register_comments_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require registerComments without migration defaults");

QJsonObject missing_rung_comment = QJsonDocument::fromJson(saved_json).object();
QJsonArray missing_comment_logics = missing_rung_comment.value(
QStringLiteral("controlLogics")).toArray();
QJsonObject first_logic = missing_comment_logics.at(0).toObject();
QJsonArray first_rungs = first_logic.value(QStringLiteral("rungs")).toArray();
QJsonObject first_rung = first_rungs.at(0).toObject();
first_rung.remove(QStringLiteral("comment"));
first_rungs.replace(0, first_rung);
first_logic.insert(QStringLiteral("rungs"), first_rungs);
missing_comment_logics.replace(0, first_logic);
missing_rung_comment.insert(QStringLiteral("controlLogics"), missing_comment_logics);
writeText(
missing_rung_comment_path,
QJsonDocument(missing_rung_comment).toJson(QJsonDocument::Compact));
missing_result = service.load(missing_rung_comment_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require rung comments without migration defaults");

QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object();
QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray();
QJsonObject main_page = pages.at(0).toObject();
@@ -422,6 +540,38 @@ void testServiceStateAndSaveErrors()
"failed save as must keep the previous current path");
}

void testRegisterCommentService()
{
JsonProjectStorage storage;
ProjectService project_service(storage);
RegisterCommentService service(project_service);

require(service.setComment(
RegisterAddress{RegisterArea::D, 3}, " 目标温度 ").succeeded,
"register comment service must create a trimmed D comment");
require(service.setComment(
RegisterAddress{RegisterArea::M, 2}, "启动信号").succeeded,
"register comment service must create an M comment");
require(service.comments().size() == 2U
&& service.comments().front().address.area() == RegisterArea::M
&& service.comments().front().address.index() == 2
&& service.comments().back().text == "目标温度",
"register comments must be sorted by area and address");
require(service.setComment(
RegisterAddress{RegisterArea::M, 2}, "新的启动信号").succeeded
&& service.comments().size() == 2U
&& service.findComment(RegisterAddress{RegisterArea::M, 2})->text
== "新的启动信号",
"setting an existing address must update instead of duplicating it");
require(!service.setComment(RegisterAddress{RegisterArea::M, 4}, " \t").succeeded,
"blank register comments must be rejected by the service");
require(service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded
&& service.findComment(RegisterAddress{RegisterArea::M, 2}) == nullptr,
"register comments must be removable");
require(!service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded,
"removing a missing register comment must fail explicitly");
}

} // namespace

int main()
@@ -431,6 +581,7 @@ int main()
// 工程服务和 JSON 存储在同一测试进程中验证完整闭环
testEmptyProjectRoundTrip();
testExampleProjectRoundTrip();
testRegisterCommentService();
testInvalidFiles();
testServiceStateAndSaveErrors();
}


+ 2
- 0
app/tests/project_management_tests.pro Ver fichero

@@ -18,6 +18,7 @@ SOURCES += \
../src/domain/project_model.cpp \
../src/domain/runtime_state.cpp \
../src/services/project_service.cpp \
../src/services/register_comment_service.cpp \
../src/infrastructure/json_project_storage.cpp

HEADERS += \
@@ -30,4 +31,5 @@ HEADERS += \
../src/domain/runtime_state.h \
../src/domain/project_storage.h \
../src/services/project_service.h \
../src/services/register_comment_service.h \
../src/infrastructure/json_project_storage.h

Cargando…
Cancelar
Guardar