Browse Source

feat: 扩展多页面多逻辑工程模型

main
suyu 1 month ago
parent
commit
d2dd72752c
7 changed files with 298 additions and 8 deletions
  1. +47
    -0
      app/src/domain/hmi_model.cpp
  2. +8
    -1
      app/src/domain/hmi_model.h
  3. +70
    -1
      app/src/domain/project_model.cpp
  4. +2
    -0
      app/src/domain/project_model.h
  5. +29
    -0
      app/src/infrastructure/json_project_storage.cpp
  6. +66
    -0
      app/tests/domain_tests.cpp
  7. +76
    -6
      app/tests/project_management_tests.cpp

+ 47
- 0
app/src/domain/hmi_model.cpp View File

@@ -25,10 +25,35 @@ bool requiresWordBinding(HmiControlType type)
return type == HmiControlType::NumericDisplay || type == HmiControlType::NumericInput;
}

bool supportsRegisterBinding(HmiControlType type)
{
return requiresBitBinding(type) || requiresWordBinding(type);
}

bool isSupportedControlType(HmiControlType type)
{
switch (type)
{
case HmiControlType::Button:
case HmiControlType::Indicator:
case HmiControlType::NumericDisplay:
case HmiControlType::NumericInput:
case HmiControlType::Label:
case HmiControlType::PageJump:
return true;
}
return false;
}

} // namespace

bool HmiControl::validate(std::string *error) const
{
if (!isSupportedControlType(type))
{
setError(error, "不支持的 HMI 控件类型");
return false;
}
if (id.empty())
{
setError(error, "HMI 控件 ID 不能为空");
@@ -68,6 +93,24 @@ bool HmiControl::validate(std::string *error) const
setError(error, "HMI 控件绑定了无效地址");
return false;
}
if (!supportsRegisterBinding(type) && binding.has_value())
{
setError(error, "文本和页面跳转控件不能绑定寄存器");
return false;
}
if (type == HmiControlType::PageJump)
{
if (!pageJump.has_value())
{
setError(error, "页面跳转控件缺少跳转配置");
return false;
}
}
else if (pageJump.has_value())
{
setError(error, "非页面跳转控件不能包含跳转配置");
return false;
}
return true;
}

@@ -77,6 +120,10 @@ bool HmiControl::isConfigured() const
{
return true;
}
if (type == HmiControlType::PageJump)
{
return pageJump.has_value() && !pageJump->targetPageId.empty();
}
if (!binding.has_value() || !binding->isValid())
{
return false;


+ 8
- 1
app/src/domain/hmi_model.h View File

@@ -41,7 +41,8 @@ enum class HmiControlType
Indicator, // 指示灯
NumericDisplay, // 数值显示
NumericInput, // 数值输入
Label // 标签
Label, // 标签
PageJump // 页面跳转
};

/**
@@ -55,6 +56,11 @@ enum class HmiButtonOperation
MomentaryOn // 按下时写入 1,释放时写入 0
};

struct HmiPageJumpConfig
{
std::string targetPageId;
};

/**
* @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置
*
@@ -69,6 +75,7 @@ struct HmiControl
std::optional<RegisterAddress> binding;
std::map<std::string, std::string> properties;
HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn;
std::optional<HmiPageJumpConfig> pageJump;

/**
* @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定


+ 70
- 1
app/src/domain/project_model.cpp View File

@@ -33,6 +33,26 @@ bool containsDuplicateId(const std::vector<TItem> &items)
return false;
}

template <typename TItem>
bool containsDuplicateName(const std::vector<TItem> &items)
{
for (auto current = items.cbegin(); current != items.cend(); ++current)
{
const auto duplicate = std::find_if(
current + 1,
items.cend(),
[&current](const TItem &item)
{
return item.name == current->name;
});
if (duplicate != items.cend())
{
return true;
}
}
return false;
}

} // namespace

bool Project::validate(std::string *error) const
@@ -47,11 +67,40 @@ bool Project::validate(std::string *error) const
setError(error, "工程内的 HMI 页面 ID 必须唯一");
return false;
}
if (containsDuplicateName(hmiPages))
{
setError(error, "工程内的 HMI 页面名称必须唯一");
return false;
}
if (hmiPages.empty())
{
if (!initialHmiPageId.empty())
{
setError(error, "空工程不能设置初始 HMI 页面");
return false;
}
}
else
{
const auto initial_page = std::find_if(
hmiPages.cbegin(), hmiPages.cend(),
[this](const HmiPage &page) { return page.id == initialHmiPageId; });
if (initial_page == hmiPages.cend())
{
setError(error, "工程初始 HMI 页面不存在");
return false;
}
}
if (containsDuplicateId(controlLogics))
{
setError(error, "工程内的控制逻辑 ID 必须唯一");
return false;
}
if (containsDuplicateName(controlLogics))
{
setError(error, "工程内的控制逻辑名称必须唯一");
return false;
}
for (const HmiPage &page : hmiPages)
{
// 工程聚合校验会向下委托页面和控件的完整规则
@@ -59,6 +108,26 @@ bool Project::validate(std::string *error) const
{
return false;
}
for (const HmiControl &control : page.controls)
{
if (control.type != HmiControlType::PageJump
|| !control.pageJump.has_value()
|| control.pageJump->targetPageId.empty())
{
continue;
}
const auto target = std::find_if(
hmiPages.cbegin(), hmiPages.cend(),
[&control](const HmiPage &candidate)
{
return candidate.id == control.pageJump->targetPageId;
});
if (target == hmiPages.cend())
{
setError(error, "页面跳转控件 " + control.id + " 的目标页面不存在");
return false;
}
}
}
for (const ControlLogic &logic : controlLogics)
{
@@ -85,7 +154,7 @@ bool Project::validateForRunning(std::string *error) const
}
for (const ControlLogic &logic : controlLogics)
{
if (!logic.validateForRunning(error))
if (logic.enabled && !logic.validateForRunning(error))
{
return false;
}


+ 2
- 0
app/src/domain/project_model.h View File

@@ -24,6 +24,8 @@ struct Project
ProjectMetadata metadata;
// 工程包含的 HMI 页面集合
std::vector<HmiPage> hmiPages;
// 进入运行态时显示的初始 HMI 页面
std::string initialHmiPageId;
// 工程包含的控制逻辑集合
std::vector<ControlLogic> controlLogics;



+ 29
- 0
app/src/infrastructure/json_project_storage.cpp View File

@@ -278,6 +278,10 @@ QString hmiControlTypeName(HmiControlType type)
{
return QStringLiteral("label");
}
case HmiControlType::PageJump:
{
return QStringLiteral("pageJump");
}
default:
{
return {};
@@ -309,6 +313,10 @@ bool parseHmiControlType(
{
*type = HmiControlType::Label;
}
else if (value == "pageJump")
{
*type = HmiControlType::PageJump;
}
else
{
return state->fail(
@@ -476,6 +484,14 @@ QJsonObject serializeHmiControl(const HmiControl &control)
QStringLiteral("buttonOperation"),
hmiButtonOperationName(control.buttonOperation));
}
if (control.type == HmiControlType::PageJump)
{
object.insert(
QStringLiteral("targetPageId"),
fromUtf8(control.pageJump.has_value()
? control.pageJump->targetPageId
: std::string{}));
}
return object;
}

@@ -520,6 +536,15 @@ bool parseHmiControl(
return false;
}
}
if (control->type == HmiControlType::PageJump)
{
std::string target_page_id;
if (!readString(object, "targetPageId", context, &target_page_id, state))
{
return false;
}
control->pageJump = HmiPageJumpConfig{std::move(target_page_id)};
}

// binding 允许为 null,其余非空值必须是合法的寄存器地址对象
if (binding.isNull())
@@ -1144,6 +1169,8 @@ QJsonObject serializeProject(const Project &project)
object.insert(QStringLiteral("id"), fromUtf8(project.metadata.id));
object.insert(QStringLiteral("name"), fromUtf8(project.metadata.name));
object.insert(QStringLiteral("hmiPages"), pages);
object.insert(QStringLiteral("initialHmiPageId"),
fromUtf8(project.initialHmiPageId));
object.insert(QStringLiteral("controlLogics"), logics);
return object;
}
@@ -1174,6 +1201,8 @@ bool parseProject(
if (!readString(object, "id", "project", &project->metadata.id, state)
|| !readString(object, "name", "project", &project->metadata.name, state)
|| !readArray(object, "hmiPages", "project", &pages, state)
|| !readString(object, "initialHmiPageId", "project",
&project->initialHmiPageId, state)
|| !readArray(object, "controlLogics", "project", &logics, state))
{
return false;


+ 66
- 0
app/tests/domain_tests.cpp View File

@@ -115,10 +115,75 @@ Project makeValidProject()
Project project;
project.metadata = {"sample-project", "Sample project", "1.0"};
project.hmiPages.push_back(page);
project.initialHmiPageId = page.id;
project.controlLogics.push_back(logic);
return project;
}

void testMultiPageAndLogicDomainRules()
{
Project project = makeValidProject();
HmiPage settings;
settings.id = "settings-page";
settings.name = "Settings";
project.hmiPages.push_back(settings);

HmiControl label;
label.id = "title";
label.type = HmiControlType::Label;
label.text = "Machine";
project.hmiPages.front().controls.push_back(label);
require(project.validate(), "an unbound label must be a valid static control");

project.hmiPages.front().controls.back().binding =
RegisterAddress{RegisterArea::M, 10};
require(!project.validate(), "labels must reject register bindings");

project = makeValidProject();
project.hmiPages.push_back(settings);
HmiControl jump;
jump.id = "settings-jump";
jump.type = HmiControlType::PageJump;
jump.text = "Settings";
jump.pageJump = HmiPageJumpConfig{settings.id};
project.hmiPages.front().controls.push_back(jump);
require(project.validate() && project.validateForRunning(),
"a page jump must resolve its target by stable page id");

project.hmiPages.front().controls.back().pageJump->targetPageId = "missing";
require(!project.validate(), "a page jump must reject a missing target page");

project = makeValidProject();
project.initialHmiPageId = "missing";
require(!project.validate(), "the initial HMI page id must resolve to a page");

project = makeValidProject();
HmiPage duplicate_name = settings;
duplicate_name.name = project.hmiPages.front().name;
project.hmiPages.push_back(duplicate_name);
require(!project.validate(), "HMI page names must be unique");

project = makeValidProject();
ControlLogic disabled_draft;
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});
project.controlLogics.push_back(disabled_draft);
require(project.validateForRunning(),
"a disabled draft logic must not block offline running");

project.controlLogics.back().name = project.controlLogics.front().name;
require(!project.validate(), "control logic names must be unique");

project = makeValidProject();
project.hmiPages.front().controls.front().type =
static_cast<HmiControlType>(99);
project.hmiPages.front().controls.front().binding.reset();
require(!project.validate(), "unknown HMI control types must be rejected");
}

void testLogicNodeConfigurationBoundaries()
{
// 触点只能绑定 M 区,数值比较只能绑定 D 区
@@ -302,6 +367,7 @@ int main()
testLogicNodeConfigurationBoundaries();
testLadderLogicBoundaries();
testModelsValidateBindingsAndIdentifiers();
testMultiPageAndLogicDomainRules();
testRuntimeStateBoundaries();
}
catch (const std::exception &error)


+ 76
- 6
app/tests/project_management_tests.cpp View File

@@ -3,6 +3,9 @@
#include "services/project_service.h"

#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryDir>

#include <exception>
@@ -56,6 +59,19 @@ Project makeExampleProject()
target_input.binding = RegisterAddress{RegisterArea::D, 3};
target_input.properties.emplace("minimum", "-100");

HmiControl title_label;
title_label.id = "title-label";
title_label.type = HmiControlType::Label;
title_label.bounds = {10, 145, 180, 32};
title_label.text = "Production line";

HmiControl settings_jump;
settings_jump.id = "settings-jump";
settings_jump.type = HmiControlType::PageJump;
settings_jump.bounds = {210, 145, 120, 40};
settings_jump.text = "Settings";
settings_jump.pageJump = HmiPageJumpConfig{"settings-page"};

HmiPage page;
page.id = "main-page";
page.name = "Main";
@@ -63,6 +79,12 @@ Project makeExampleProject()
page.controls.push_back(running_indicator);
page.controls.push_back(temperature_display);
page.controls.push_back(target_input);
page.controls.push_back(title_label);
page.controls.push_back(settings_jump);

HmiPage settings_page;
settings_page.id = "settings-page";
settings_page.name = "Settings";

LogicNode contact;
contact.id = "start-contact";
@@ -115,7 +137,16 @@ Project makeExampleProject()
Project project;
project.metadata = {"example-project", "Example project", "1.0"};
project.hmiPages.push_back(page);
project.hmiPages.push_back(settings_page);
project.initialHmiPageId = page.id;
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});
project.controlLogics.push_back(draft_logic);
return project;
}

@@ -172,6 +203,8 @@ void testExampleProjectRoundTrip()
const QString first_path = directory.filePath("example.json");
const QString second_path = directory.filePath("example-copy.json");
const QString invalid_operation_path = directory.filePath("invalid-operation.json");
const QString missing_initial_path = directory.filePath("missing-initial.json");
const QString missing_target_path = directory.filePath("missing-target.json");
require(service.saveAs(first_path.toStdString()).succeeded,
"example project save must succeed");
const QByteArray saved_json = readBytes(first_path);
@@ -183,8 +216,10 @@ void testExampleProjectRoundTrip()
require(!saved_json.contains("\"dataPoints\""),
"saved project must not contain the removed data point model");
require(saved_json.contains("\"formatVersion\": \"1.0\"")
&& saved_json.contains("\"buttonOperation\": \"setOn\""),
"version 1.0 projects must persist the button operation");
&& saved_json.contains("\"buttonOperation\": \"setOn\"")
&& saved_json.contains("\"initialHmiPageId\": \"main-page\"")
&& saved_json.contains("\"targetPageId\": \"settings-page\""),
"version 1.0 projects must persist the current multi-page schema");
require(!saved_json.contains("\"stages\"")
&& !saved_json.contains("\"branches\""),
"current project format must not contain the removed stage model");
@@ -196,9 +231,11 @@ void testExampleProjectRoundTrip()

const Project &project = service.project();
require(project.metadata.id == "example-project", "project id must survive round trip");
require(project.hmiPages.size() == 1, "HMI page count must survive round trip");
require(project.hmiPages.front().controls.size() == 4,
"all basic HMI controls must survive round trip");
require(project.hmiPages.size() == 2, "HMI page count must survive round trip");
require(project.initialHmiPageId == "main-page",
"the initial HMI page id must survive round trip");
require(project.hmiPages.front().controls.size() == 6,
"register, Label and PageJump controls must survive round trip");
require(project.hmiPages.front().controls.front().binding->area()
== RegisterArea::M,
"HMI M binding must survive round trip");
@@ -213,7 +250,11 @@ void testExampleProjectRoundTrip()
"numeric display D binding must survive round trip");
require(project.hmiPages.front().controls.at(3).bounds.x == 150,
"numeric input bounds must survive round trip");
require(project.controlLogics.size() == 1,
require(project.hmiPages.front().controls.at(4).type == HmiControlType::Label
&& project.hmiPages.front().controls.at(5).pageJump->targetPageId
== "settings-page",
"Label and PageJump typed data 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");
@@ -244,6 +285,35 @@ void testExampleProjectRoundTrip()
writeText(invalid_operation_path, invalid_operation);
require(!service.load(invalid_operation_path.toStdString()).succeeded,
"unsupported HMI button operations must be rejected");

QJsonObject missing_initial = QJsonDocument::fromJson(saved_json).object();
missing_initial.remove(QStringLiteral("initialHmiPageId"));
writeText(
missing_initial_path,
QJsonDocument(missing_initial).toJson(QJsonDocument::Compact));
ProjectOperationResult missing_result = service.load(
missing_initial_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require initialHmiPageId 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();
QJsonArray controls = main_page.value(QStringLiteral("controls")).toArray();
QJsonObject jump = controls.at(5).toObject();
jump.remove(QStringLiteral("targetPageId"));
controls.replace(5, jump);
main_page.insert(QStringLiteral("controls"), controls);
pages.replace(0, main_page);
missing_target.insert(QStringLiteral("hmiPages"), pages);
writeText(
missing_target_path,
QJsonDocument(missing_target).toJson(QJsonDocument::Compact));
missing_result = service.load(missing_target_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require PageJump targetPageId");
}

void testInvalidFiles()


Loading…
Cancel
Save