From 6594ef1183d3807eab28ab6a4121b745a463322a Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Tue, 25 Aug 2026 10:18:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/integrated_platform.pro | 3 + app/src/domain/control_logic_model.cpp | 30 +- app/src/domain/control_logic_model.h | 9 + app/src/domain/hmi_model.cpp | 22 +- app/src/domain/hmi_model.h | 6 + app/src/domain/project_limits.h | 32 ++ app/src/domain/project_model.cpp | 47 +- app/src/domain/project_model.h | 7 + .../application_settings_loader.cpp | 509 ++++++++++++++++++ .../application_settings_loader.h | 13 + .../infrastructure/json_project_storage.cpp | 35 +- app/src/infrastructure/json_project_storage.h | 6 + app/src/main.cpp | 16 +- app/src/services/alarm_editor_service.cpp | 8 +- app/src/services/application_settings.h | 30 ++ app/src/services/hmi_editor_service.cpp | 39 +- app/src/services/hmi_editor_service.h | 5 +- app/src/services/logic_editor_service.cpp | 41 +- app/src/services/project_service.cpp | 17 +- app/src/services/project_service.h | 5 + app/src/services/runtime_mode_service.cpp | 3 +- app/src/ui/main_window.cpp | 32 +- app/src/ui/main_window.h | 5 + app/src/ui/property_panel_controller.cpp | 6 +- app/src/ui/property_panel_controller.h | 3 + app/tests/alarm_service_tests.cpp | 17 + app/tests/application_settings_tests.cpp | 252 +++++++++ app/tests/application_settings_tests.pro | 17 + app/tests/domain_tests.cpp | 50 ++ app/tests/hmi_editor_service_tests.cpp | 25 + app/tests/logic_editor_service_tests.cpp | 19 + app/tests/project_management_tests.cpp | 26 + app/tests/tests.pro | 2 + 33 files changed, 1272 insertions(+), 65 deletions(-) create mode 100644 app/src/infrastructure/application_settings_loader.cpp create mode 100644 app/src/infrastructure/application_settings_loader.h create mode 100644 app/src/services/application_settings.h create mode 100644 app/tests/application_settings_tests.cpp create mode 100644 app/tests/application_settings_tests.pro diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index 39053e0..32b4702 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -55,6 +55,7 @@ SOURCES += \ src/infrastructure/plc_communication_error_classifier.cpp \ src/infrastructure/plc_communication_service.cpp \ src/infrastructure/plc_discovery_service.cpp \ + src/infrastructure/application_settings_loader.cpp \ src/infrastructure/json_project_storage.cpp \ src/ui/hmi_editor_widget.cpp \ src/ui/logic_editor_widget.cpp @@ -100,10 +101,12 @@ HEADERS += \ src/services/register_monitor_service.h \ src/services/register_comment_service.h \ src/services/plc_communication_gateway.h \ + src/services/application_settings.h \ src/infrastructure/plc_register_repository.h \ src/infrastructure/plc_communication_error_classifier.h \ src/infrastructure/plc_communication_service.h \ src/infrastructure/plc_discovery_service.h \ + src/infrastructure/application_settings_loader.h \ src/infrastructure/json_project_storage.h \ src/ui/hmi_editor_widget.h \ src/ui/logic_editor_widget.h diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index e755630..27cca5d 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -820,7 +820,13 @@ bool LadderRung::validateStructure(std::string *error) const bool ControlLogic::validate(std::string *error) const { - if (!validateStructure(error)) + return validate(defaultProjectLimitSettings(), error); +} + +bool ControlLogic::validate( + const ProjectLimitSettings &limits, std::string *error) const +{ + if (!validateStructure(limits, error)) { return false; } @@ -835,6 +841,12 @@ bool ControlLogic::validate(std::string *error) const } bool ControlLogic::validateStructure(std::string *error) const +{ + return validateStructure(defaultProjectLimitSettings(), error); +} + +bool ControlLogic::validateStructure( + const ProjectLimitSettings &limits, std::string *error) const { if (id.empty() || name.empty()) { @@ -851,9 +863,13 @@ bool ControlLogic::validateStructure(std::string *error) const setError(error, "控制逻辑名称不能超过 256 个 UTF-8 字节"); return false; } - if (rungs.size() > ProjectLimits::kMaximumRungsPerLogic) + if (rungs.size() > limits.maximumRungsPerLogic) { - setError(error, "单组控制逻辑最多包含 256 个网络"); + setError( + error, + "控制逻辑“" + name + "”的网络数量为 " + + std::to_string(rungs.size()) + ",当前配置上限为 " + + std::to_string(limits.maximumRungsPerLogic)); return false; } if (hasDuplicateId(rungs)) @@ -902,7 +918,13 @@ bool ControlLogic::validateStructure(std::string *error) const bool ControlLogic::validateForRunning(std::string *error) const { - if (!validateStructure(error)) + return validateForRunning(defaultProjectLimitSettings(), error); +} + +bool ControlLogic::validateForRunning( + const ProjectLimitSettings &limits, std::string *error) const +{ + if (!validateStructure(limits, error)) { return false; } diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index 784393e..be4e5e8 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -257,7 +257,16 @@ struct ControlLogic // 校验可保存结构 bool validate(std::string *error = nullptr) const; + bool validate( + const ProjectLimitSettings &limits, + std::string *error = nullptr) const; bool validateStructure(std::string *error = nullptr) const; + bool validateStructure( + const ProjectLimitSettings &limits, + std::string *error = nullptr) const; // 校验运行所需资源和网络配置 bool validateForRunning(std::string *error = nullptr) const; + bool validateForRunning( + const ProjectLimitSettings &limits, + std::string *error = nullptr) const; }; diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index 9cf9af6..96af83b 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -220,6 +220,12 @@ bool HmiControl::isConfigured() const } bool HmiPage::validate(std::string *error) const +{ + return validate(defaultProjectLimitSettings(), error); +} + +bool HmiPage::validate( + const ProjectLimitSettings &limits, std::string *error) const { if (id.empty() || name.empty()) { @@ -246,9 +252,13 @@ bool HmiPage::validate(std::string *error) const "HMI 页面宽度必须在 320~1600、高度必须在 200~800 范围内"); return false; } - if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage) + if (controls.size() > limits.maximumHmiControlsPerPage) { - setError(error, "单个 HMI 页面最多包含 128 个控件"); + setError( + error, + "HMI 页面“" + name + "”的控件数量为 " + + std::to_string(controls.size()) + ",当前配置上限为 " + + std::to_string(limits.maximumHmiControlsPerPage)); return false; } @@ -280,7 +290,13 @@ bool HmiPage::validate(std::string *error) const bool HmiPage::validateForRunning(std::string *error) const { - if (!validate(error)) + return validateForRunning(defaultProjectLimitSettings(), error); +} + +bool HmiPage::validateForRunning( + const ProjectLimitSettings &limits, std::string *error) const +{ + if (!validate(limits, error)) { return false; } diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h index 4ef9c73..4f4a7a0 100644 --- a/app/src/domain/hmi_model.h +++ b/app/src/domain/hmi_model.h @@ -120,5 +120,11 @@ struct HmiPage * @return 页面及其全部控件有效时返回 true */ bool validate(std::string *error = nullptr) const; + bool validate( + const ProjectLimitSettings &limits, + std::string *error = nullptr) const; bool validateForRunning(std::string *error = nullptr) const; + bool validateForRunning( + const ProjectLimitSettings &limits, + std::string *error = nullptr) const; }; diff --git a/app/src/domain/project_limits.h b/app/src/domain/project_limits.h index 1bfb683..3bfd323 100644 --- a/app/src/domain/project_limits.h +++ b/app/src/domain/project_limits.h @@ -66,3 +66,35 @@ constexpr std::size_t kMaximumPendingWrites = 1U; // 同时只允许等待 1 个 constexpr int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条 } + +// 用户可调的工程数量上限;默认值等于代码定义的绝对硬上限 +struct ProjectLimitSettings +{ + std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; + std::size_t maximumHmiControlsPerPage = + ProjectLimits::kMaximumHmiControlsPerPage; + std::size_t maximumAlarmDefinitions = + ProjectLimits::kMaximumAlarmDefinitions; + std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics; + std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic; + int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages; +}; + +// 新建 HMI 页面使用的默认尺寸;页面结构安全范围仍由上方硬限制控制 +struct HmiDefaultSettings +{ + int pageWidth = ProjectLimits::kDefaultHmiPageWidth; + int pageHeight = ProjectLimits::kDefaultHmiPageHeight; +}; + +inline const ProjectLimitSettings &defaultProjectLimitSettings() +{ + static const ProjectLimitSettings settings; + return settings; +} + +inline const HmiDefaultSettings &defaultHmiSettings() +{ + static const HmiDefaultSettings settings; + return settings; +} diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index 1aaaed7..4dccc6e 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -110,6 +110,12 @@ const RegisterComment *Project::findRegisterComment( } bool Project::validate(std::string *error) const +{ + return validate(defaultProjectLimitSettings(), error); +} + +bool Project::validate( + const ProjectLimitSettings &limits, std::string *error) const { // 这里是工程级总闸门:先检查数量和唯一性,再向下委托页面、报警和逻辑 if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) @@ -133,14 +139,23 @@ bool Project::validate(std::string *error) const setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节"); return false; } - if (hmiPages.size() > ProjectLimits::kMaximumHmiPages) + if (hmiPages.size() > limits.maximumHmiPages) { - setError(error, "单个工程最多包含 32 个 HMI 页面"); + setError( + error, + "工程 HMI 页面数量为 " + std::to_string(hmiPages.size()) + + ",当前配置上限为 " + + std::to_string(limits.maximumHmiPages)); return false; } - if (alarmDefinitions.size() > ProjectLimits::kMaximumAlarmDefinitions) + if (alarmDefinitions.size() > limits.maximumAlarmDefinitions) { - setError(error, "单个工程最多包含 256 条报警定义"); + setError( + error, + "工程报警定义数量为 " + + std::to_string(alarmDefinitions.size()) + + ",当前配置上限为 " + + std::to_string(limits.maximumAlarmDefinitions)); return false; } if (registerComments.size() > ProjectLimits::kMaximumRegisterComments) @@ -148,9 +163,13 @@ bool Project::validate(std::string *error) const setError(error, "单个工程最多包含 8002 条软元件注释"); return false; } - if (controlLogics.size() > ProjectLimits::kMaximumControlLogics) + if (controlLogics.size() > limits.maximumControlLogics) { - setError(error, "单个工程最多包含 32 组控制逻辑"); + setError( + error, + "工程控制逻辑数量为 " + std::to_string(controlLogics.size()) + + ",当前配置上限为 " + + std::to_string(limits.maximumControlLogics)); return false; } std::size_t total_hmi_controls = 0U; @@ -246,7 +265,7 @@ bool Project::validate(std::string *error) const for (const HmiPage &page : hmiPages) { // 工程聚合校验会向下委托页面和控件的完整规则 - if (!page.validate(error)) + if (!page.validate(limits, error)) { return false; } @@ -273,7 +292,7 @@ bool Project::validate(std::string *error) const } for (const ControlLogic &logic : controlLogics) { - if (!logic.validate(error)) + if (!logic.validate(limits, error)) { return false; } @@ -334,22 +353,28 @@ bool Project::validate(std::string *error) const } bool Project::validateForRunning(std::string *error) const +{ + return validateForRunning(defaultProjectLimitSettings(), error); +} + +bool Project::validateForRunning( + const ProjectLimitSettings &limits, std::string *error) const { // 运行校验建立在可保存校验之上,再额外拒绝未配置的运行对象 - if (!validate(error)) + if (!validate(limits, error)) { return false; } for (const HmiPage &page : hmiPages) { - if (!page.validateForRunning(error)) + if (!page.validateForRunning(limits, error)) { return false; } } for (const ControlLogic &logic : controlLogics) { - if (logic.enabled && !logic.validateForRunning(error)) + if (logic.enabled && !logic.validateForRunning(limits, error)) { return false; } diff --git a/app/src/domain/project_model.h b/app/src/domain/project_model.h index 7f5abab..0facb3c 100644 --- a/app/src/domain/project_model.h +++ b/app/src/domain/project_model.h @@ -3,6 +3,7 @@ #include "alarm_model.h" #include "control_logic_model.h" #include "hmi_model.h" +#include "project_limits.h" #include #include @@ -48,6 +49,12 @@ struct Project const RegisterComment *findRegisterComment(const RegisterAddress &address) const; // 编辑态校验:允许尚未绑定完成的草稿 bool validate(std::string *error = nullptr) const; + bool validate( + const ProjectLimitSettings &limits, + std::string *error = nullptr) const; // 运行态校验:额外要求所有运行所需对象都已配置 bool validateForRunning(std::string *error = nullptr) const; + bool validateForRunning( + const ProjectLimitSettings &limits, + std::string *error = nullptr) const; }; diff --git a/app/src/infrastructure/application_settings_loader.cpp b/app/src/infrastructure/application_settings_loader.cpp new file mode 100644 index 0000000..2e63cc7 --- /dev/null +++ b/app/src/infrastructure/application_settings_loader.cpp @@ -0,0 +1,509 @@ +#include "application_settings_loader.h" + +#include "domain/project_limits.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +constexpr int kCurrentConfigurationVersion = 1; + +struct ParsedEntry +{ + QString value; + int line = 0; +}; + +std::string toUtf8(const QString &value) +{ + const QByteArray bytes = value.toUtf8(); + return std::string(bytes.constData(), static_cast(bytes.size())); +} + +QString defaultFileContents() +{ + return QStringLiteral( + "[Config]\n" + "Version=1\n" + "\n" + "[ProjectLimits]\n" + "MaxHmiPages=32\n" + "MaxHmiControlsPerPage=128\n" + "MaxAlarmDefinitions=256\n" + "MaxControlLogics=32\n" + "MaxRungsPerLogic=256\n" + "MaxOutputMessages=1000\n" + "\n" + "[HmiDefaults]\n" + "DefaultPageWidth=800\n" + "DefaultPageHeight=400\n" + "\n" + "[PlcDefaults]\n" + "PortName=COM3\n" + "ServerAddress=1\n" + "BaudRate=9600\n" + "DataBits=8\n" + "Parity=2\n" + "StopBits=1\n"); +} + +ApplicationSettingsLoadResult failureResult( + const QString &file_path, const QString &reason) +{ + ApplicationSettingsLoadResult result; + result.warningRequired = true; + result.warningMessage = toUtf8( + QStringLiteral("应用配置无法使用,本次已全部采用代码默认值。\n%1") + .arg(reason)); + result.messages.push_back(toUtf8( + QStringLiteral("应用配置错误:%1(%2)") + .arg(reason, QDir::toNativeSeparators(file_path)))); + result.messages.push_back( + "应用配置:本次运行全部使用代码默认值"); + return result; +} + +bool containsValue(std::initializer_list values, int value) +{ + for (int candidate : values) + { + if (candidate == value) + { + return true; + } + } + return false; +} + +bool readInteger( + const QMap &entries, + const QString &path, + qint64 minimum, + qint64 maximum, + qint64 default_value, + qint64 *value, + QStringList *notices, + QStringList *errors, + bool required = false) +{ + const auto entry = entries.constFind(path); + if (entry == entries.cend()) + { + if (required) + { + errors->append(QStringLiteral("缺少必填字段 %1").arg(path)); + } + else + { + notices->append( + QStringLiteral("配置字段 %1 缺失,使用代码默认值 %2") + .arg(path) + .arg(default_value)); + } + *value = default_value; + return !required; + } + + bool converted = false; + const qint64 parsed = entry->value.toLongLong(&converted, 10); + if (!converted || parsed < minimum || parsed > maximum) + { + errors->append( + QStringLiteral("第 %1 行字段 %2 的值“%3”无效,允许范围为 %4~%5") + .arg(entry->line) + .arg(path, entry->value) + .arg(minimum) + .arg(maximum)); + return false; + } + *value = parsed; + return true; +} + +void addUnsupportedValueError( + const QMap &entries, + const QString &path, + const QString &allowed, + QStringList *errors) +{ + const ParsedEntry &entry = entries[path]; + errors->append( + QStringLiteral("第 %1 行字段 %2 的值“%3”无效,只允许 %4") + .arg(entry.line) + .arg(path, entry.value, allowed)); +} + +} // namespace + +QString ApplicationSettingsLoader::defaultFilePath() +{ + return QDir(QCoreApplication::applicationDirPath()).filePath( + QStringLiteral("config/application.ini")); +} + +ApplicationSettingsLoadResult ApplicationSettingsLoader::load( + const QString &file_path) +{ + if (!QFileInfo::exists(file_path)) + { + const QString directory = QFileInfo(file_path).absolutePath(); + if (!QDir().mkpath(directory)) + { + return failureResult( + file_path, + QStringLiteral("无法创建配置目录 %1,可能没有写入权限") + .arg(QDir::toNativeSeparators(directory))); + } + + QSaveFile file(file_path); + if (!file.open(QIODevice::WriteOnly)) + { + return failureResult(file_path, file.errorString()); + } + const QByteArray data = defaultFileContents().toUtf8(); + if (file.write(data) != data.size()) + { + const QString reason = file.errorString(); + file.cancelWriting(); + return failureResult(file_path, reason); + } + if (!file.commit()) + { + return failureResult(file_path, file.errorString()); + } + + ApplicationSettingsLoadResult result; + result.messages.push_back(toUtf8( + QStringLiteral("应用配置:已创建默认配置文件 %1") + .arg(QDir::toNativeSeparators(file_path)))); + result.messages.push_back( + "应用配置:首次运行使用代码默认值,修改后请重启程序"); + return result; + } + + QFile file(file_path); + if (!file.open(QIODevice::ReadOnly)) + { + return failureResult(file_path, file.errorString()); + } + const QByteArray data = file.readAll(); + if (file.error() != QFileDevice::NoError) + { + return failureResult(file_path, file.errorString()); + } + + QTextCodec::ConverterState converter_state; + const QString text = QTextCodec::codecForName("UTF-8")->toUnicode( + data.constData(), data.size(), &converter_state); + if (converter_state.invalidChars > 0) + { + return failureResult(file_path, QStringLiteral("配置文件不是有效的 UTF-8 文本")); + } + + const QSet known_fields{ + QStringLiteral("Config.Version"), + QStringLiteral("ProjectLimits.MaxHmiPages"), + QStringLiteral("ProjectLimits.MaxHmiControlsPerPage"), + QStringLiteral("ProjectLimits.MaxAlarmDefinitions"), + QStringLiteral("ProjectLimits.MaxControlLogics"), + QStringLiteral("ProjectLimits.MaxRungsPerLogic"), + QStringLiteral("ProjectLimits.MaxOutputMessages"), + QStringLiteral("HmiDefaults.DefaultPageWidth"), + QStringLiteral("HmiDefaults.DefaultPageHeight"), + QStringLiteral("PlcDefaults.PortName"), + QStringLiteral("PlcDefaults.ServerAddress"), + QStringLiteral("PlcDefaults.BaudRate"), + QStringLiteral("PlcDefaults.DataBits"), + QStringLiteral("PlcDefaults.Parity"), + QStringLiteral("PlcDefaults.StopBits")}; + + QMap entries; + QStringList notices; + QStringList errors; + QString current_section; + QString normalized_text = text; + if (!normalized_text.isEmpty() + && normalized_text.front() == QChar::ByteOrderMark) + { + normalized_text.remove(0, 1); + } + const QStringList lines = normalized_text.split(QLatin1Char('\n')); + for (int index = 0; index < lines.size(); ++index) + { + QString line = lines.at(index); + if (line.endsWith(QLatin1Char('\r'))) + { + line.chop(1); + } + const QString trimmed = line.trimmed(); + const int line_number = index + 1; + if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char(';')) + || trimmed.startsWith(QLatin1Char('#'))) + { + continue; + } + if (trimmed.startsWith(QLatin1Char('['))) + { + if (!trimmed.endsWith(QLatin1Char(']')) || trimmed.size() < 3) + { + errors.append( + QStringLiteral("第 %1 行分区格式无效:%2") + .arg(line_number) + .arg(trimmed)); + current_section.clear(); + continue; + } + current_section = trimmed.mid(1, trimmed.size() - 2).trimmed(); + if (current_section.isEmpty()) + { + errors.append(QStringLiteral("第 %1 行分区名称不能为空").arg(line_number)); + } + continue; + } + + const int separator = trimmed.indexOf(QLatin1Char('=')); + if (separator <= 0 || current_section.isEmpty()) + { + errors.append( + QStringLiteral("第 %1 行字段格式无效:%2") + .arg(line_number) + .arg(trimmed)); + continue; + } + const QString key = trimmed.left(separator).trimmed(); + const QString value = trimmed.mid(separator + 1).trimmed(); + if (key.isEmpty()) + { + errors.append(QStringLiteral("第 %1 行字段名称不能为空").arg(line_number)); + continue; + } + const QString path = current_section + QLatin1Char('.') + key; + if (entries.contains(path)) + { + errors.append( + QStringLiteral("第 %1 行字段 %2 重复,首次出现在第 %3 行") + .arg(line_number) + .arg(path) + .arg(entries.value(path).line)); + continue; + } + entries.insert(path, {value, line_number}); + if (!known_fields.contains(path)) + { + notices.append( + QStringLiteral("第 %1 行未知字段 %2 已忽略") + .arg(line_number) + .arg(path)); + } + } + + ApplicationSettings candidate; + qint64 parsed = 0; + if (readInteger( + entries, QStringLiteral("Config.Version"), + kCurrentConfigurationVersion, kCurrentConfigurationVersion, + kCurrentConfigurationVersion, &parsed, ¬ices, &errors, true)) + { + candidate.version = static_cast(parsed); + } + + if (readInteger( + entries, QStringLiteral("ProjectLimits.MaxHmiPages"), + 1, ProjectLimits::kMaximumHmiPages, + ProjectLimits::kMaximumHmiPages, &parsed, ¬ices, &errors)) + { + candidate.projectLimits.maximumHmiPages = static_cast(parsed); + } + if (readInteger( + entries, QStringLiteral("ProjectLimits.MaxHmiControlsPerPage"), + 1, ProjectLimits::kMaximumHmiControlsPerPage, + ProjectLimits::kMaximumHmiControlsPerPage, + &parsed, ¬ices, &errors)) + { + candidate.projectLimits.maximumHmiControlsPerPage = + static_cast(parsed); + } + if (readInteger( + entries, QStringLiteral("ProjectLimits.MaxAlarmDefinitions"), + 0, ProjectLimits::kMaximumAlarmDefinitions, + ProjectLimits::kMaximumAlarmDefinitions, + &parsed, ¬ices, &errors)) + { + candidate.projectLimits.maximumAlarmDefinitions = + static_cast(parsed); + } + if (readInteger( + entries, QStringLiteral("ProjectLimits.MaxControlLogics"), + 1, ProjectLimits::kMaximumControlLogics, + ProjectLimits::kMaximumControlLogics, + &parsed, ¬ices, &errors)) + { + candidate.projectLimits.maximumControlLogics = + static_cast(parsed); + } + if (readInteger( + entries, QStringLiteral("ProjectLimits.MaxRungsPerLogic"), + 0, ProjectLimits::kMaximumRungsPerLogic, + ProjectLimits::kMaximumRungsPerLogic, + &parsed, ¬ices, &errors)) + { + candidate.projectLimits.maximumRungsPerLogic = + static_cast(parsed); + } + if (readInteger( + entries, QStringLiteral("ProjectLimits.MaxOutputMessages"), + 1, ProjectLimits::kMaximumOutputMessages, + ProjectLimits::kMaximumOutputMessages, + &parsed, ¬ices, &errors)) + { + candidate.projectLimits.maximumOutputMessages = static_cast(parsed); + } + + if (readInteger( + entries, QStringLiteral("HmiDefaults.DefaultPageWidth"), + ProjectLimits::kMinimumHmiPageWidth, + ProjectLimits::kMaximumHmiPageWidth, + ProjectLimits::kDefaultHmiPageWidth, + &parsed, ¬ices, &errors)) + { + candidate.hmiDefaults.pageWidth = static_cast(parsed); + } + if (readInteger( + entries, QStringLiteral("HmiDefaults.DefaultPageHeight"), + ProjectLimits::kMinimumHmiPageHeight, + ProjectLimits::kMaximumHmiPageHeight, + ProjectLimits::kDefaultHmiPageHeight, + &parsed, ¬ices, &errors)) + { + candidate.hmiDefaults.pageHeight = static_cast(parsed); + } + + const QString port_path = QStringLiteral("PlcDefaults.PortName"); + const auto port_entry = entries.constFind(port_path); + if (port_entry == entries.cend()) + { + notices.append( + QStringLiteral("配置字段 %1 缺失,使用代码默认值 COM3") + .arg(port_path)); + } + else + { + const QByteArray port_bytes = port_entry->value.toUtf8(); + if (port_entry->value.trimmed().isEmpty() + || port_bytes.size() > static_cast(ProjectLimits::kMaximumIdBytes)) + { + errors.append( + QStringLiteral("第 %1 行字段 %2 的值“%3”无效,端口名称不能为空且不能超过 128 个 UTF-8 字节") + .arg(port_entry->line) + .arg(port_path, port_entry->value)); + } + else + { + candidate.plcDefaults.portName = toUtf8(port_entry->value.trimmed()); + } + } + + if (readInteger( + entries, QStringLiteral("PlcDefaults.ServerAddress"), + ProjectLimits::kMinimumPlcServerAddress, + ProjectLimits::kMaximumPlcServerAddress, + candidate.plcDefaults.serverAddress, + &parsed, ¬ices, &errors)) + { + candidate.plcDefaults.serverAddress = static_cast(parsed); + } + + int baud_rate = candidate.plcDefaults.baudRate; + if (readInteger( + entries, QStringLiteral("PlcDefaults.BaudRate"), + 9600, 115200, candidate.plcDefaults.baudRate, + &parsed, ¬ices, &errors)) + { + baud_rate = static_cast(parsed); + if (!containsValue({9600, 19200, 38400, 57600, 115200}, baud_rate)) + { + addUnsupportedValueError( + entries, QStringLiteral("PlcDefaults.BaudRate"), + QStringLiteral("9600、19200、38400、57600 或 115200"), &errors); + } + else + { + candidate.plcDefaults.baudRate = baud_rate; + } + } + + if (readInteger( + entries, QStringLiteral("PlcDefaults.DataBits"), + 7, 8, candidate.plcDefaults.dataBits, + &parsed, ¬ices, &errors)) + { + candidate.plcDefaults.dataBits = static_cast(parsed); + } + + int parity = candidate.plcDefaults.parity; + if (readInteger( + entries, QStringLiteral("PlcDefaults.Parity"), + 0, 3, candidate.plcDefaults.parity, + &parsed, ¬ices, &errors)) + { + parity = static_cast(parsed); + if (!containsValue({0, 2, 3}, parity)) + { + addUnsupportedValueError( + entries, QStringLiteral("PlcDefaults.Parity"), + QStringLiteral("0、2 或 3"), &errors); + } + else + { + candidate.plcDefaults.parity = parity; + } + } + + if (readInteger( + entries, QStringLiteral("PlcDefaults.StopBits"), + 1, 2, candidate.plcDefaults.stopBits, + &parsed, ¬ices, &errors)) + { + candidate.plcDefaults.stopBits = static_cast(parsed); + } + + if (!errors.isEmpty()) + { + ApplicationSettingsLoadResult result; + result.warningRequired = true; + result.warningMessage = + "应用配置存在严重错误,本次已全部采用代码默认值。详细原因请查看底部输出面板。"; + for (const QString &error : errors) + { + result.messages.push_back(toUtf8(QStringLiteral("应用配置错误:") + error)); + } + for (const QString ¬ice : notices) + { + result.messages.push_back(toUtf8(QStringLiteral("应用配置提示:") + notice)); + } + result.messages.push_back( + "应用配置:整份配置不生效,本次运行全部使用代码默认值"); + return result; + } + + ApplicationSettingsLoadResult result; + result.settings = candidate; + result.messages.push_back(toUtf8( + QStringLiteral("应用配置:已加载 %1,修改后需重启程序") + .arg(QDir::toNativeSeparators(file_path)))); + for (const QString ¬ice : notices) + { + result.messages.push_back(toUtf8(QStringLiteral("应用配置提示:") + notice)); + } + return result; +} diff --git a/app/src/infrastructure/application_settings_loader.h b/app/src/infrastructure/application_settings_loader.h new file mode 100644 index 0000000..af8d155 --- /dev/null +++ b/app/src/infrastructure/application_settings_loader.h @@ -0,0 +1,13 @@ +#pragma once + +#include "services/application_settings.h" + +#include + +// 从程序目录下的严格 UTF-8 INI 文件加载一次应用配置 +class ApplicationSettingsLoader final +{ +public: + static QString defaultFilePath(); + static ApplicationSettingsLoadResult load(const QString &file_path); +}; diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 175624f..991cdda 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -212,7 +212,9 @@ bool readArray( { return state->fail( ProjectStorageError::InvalidField, - fieldPath(context, field) + " 的元素数量超过上限"); + fieldPath(context, field) + " 的元素数量为 " + + std::to_string(value->size()) + ",当前上限为 " + + std::to_string(maximum_count)); } return true; } @@ -771,6 +773,7 @@ QJsonObject serializeHmiPage(const HmiPage &page) bool parseHmiPage( const QJsonObject &object, const std::string &context, + const ProjectLimitSettings &limits, HmiPage *page, ParseState *state) { @@ -789,7 +792,7 @@ bool parseHmiPage( &page->height, state) || !readArray( object, "controls", context, &controls, state, - static_cast(ProjectLimits::kMaximumHmiControlsPerPage))) + static_cast(limits.maximumHmiControlsPerPage))) { return false; } @@ -1528,6 +1531,7 @@ QJsonObject serializeControlLogic(const ControlLogic &logic) bool parseControlLogic( const QJsonObject &object, const std::string &context, + const ProjectLimitSettings &limits, ControlLogic *logic, ParseState *state) { @@ -1539,7 +1543,7 @@ bool parseControlLogic( || !readBool(object, "enabled", context, &logic->enabled, state) || !readArray( object, "rungs", context, &rungs, state, - static_cast(ProjectLimits::kMaximumRungsPerLogic))) + static_cast(limits.maximumRungsPerLogic))) { return false; } @@ -1608,7 +1612,10 @@ QJsonObject serializeProject(const Project &project) // 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本 bool parseProject( - const QJsonObject &object, Project *project, ParseState *state) + const QJsonObject &object, + const ProjectLimitSettings &limits, + Project *project, + ParseState *state) { QJsonArray pages; QJsonArray alarms; @@ -1638,20 +1645,20 @@ bool parseProject( || !readString(object, "name", "project", &project->metadata.name, state) || !readArray( object, "hmiPages", "project", &pages, state, - static_cast(ProjectLimits::kMaximumHmiPages)) + static_cast(limits.maximumHmiPages)) || !readString( object, "initialHmiPageId", "project", &project->initialHmiPageId, state, ProjectLimits::kMaximumIdBytes) || !readArray( object, "alarmDefinitions", "project", &alarms, state, - static_cast(ProjectLimits::kMaximumAlarmDefinitions)) + static_cast(limits.maximumAlarmDefinitions)) || !readArray( object, "registerComments", "project", ®ister_comments, state, static_cast(ProjectLimits::kMaximumRegisterComments)) || !readArray( object, "controlLogics", "project", &logics, state, - static_cast(ProjectLimits::kMaximumControlLogics))) + static_cast(limits.maximumControlLogics))) { return false; } @@ -1670,6 +1677,7 @@ bool parseProject( if (!parseHmiPage( pages.at(index).toObject(), "project.hmiPages[" + std::to_string(index) + ']', + limits, &page, state)) { @@ -1733,6 +1741,7 @@ bool parseProject( if (!parseControlLogic( logics.at(index).toObject(), "project.controlLogics[" + std::to_string(index) + ']', + limits, &logic, state)) { @@ -1752,13 +1761,19 @@ ProjectSaveResult saveFailure( } // namespace +JsonProjectStorage::JsonProjectStorage( + const ProjectLimitSettings &project_limits) + : project_limits_(project_limits) +{ +} + // 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件 ProjectSaveResult JsonProjectStorage::save( const Project &project, const std::string &file_path) { // 写文件前先执行领域校验,防止持久化内部关系不合法的工程 std::string validation_error; - if (!project.validate(&validation_error)) + if (!project.validate(project_limits_, &validation_error)) { return {false, ProjectStorageError::InvalidProject, validation_error}; } @@ -1856,13 +1871,13 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) // 先在局部对象中完成结构解析,失败时不会暴露半成品工程 Project project; ParseState state; - if (!parseProject(document.object(), &project, &state)) + if (!parseProject(document.object(), project_limits_, &project, &state)) { return {false, {}, state.error, state.message}; } // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 std::string validation_error; - if (!project.validate(&validation_error)) + if (!project.validate(project_limits_, &validation_error)) { return {false, {}, diff --git a/app/src/infrastructure/json_project_storage.h b/app/src/infrastructure/json_project_storage.h index de53af8..927d8f3 100644 --- a/app/src/infrastructure/json_project_storage.h +++ b/app/src/infrastructure/json_project_storage.h @@ -6,7 +6,13 @@ class JsonProjectStorage final : public ProjectStorage { public: + explicit JsonProjectStorage( + const ProjectLimitSettings &project_limits = + defaultProjectLimitSettings()); ProjectSaveResult save( const Project &project, const std::string &file_path) override; ProjectLoadResult load(const std::string &file_path) override; + +private: + const ProjectLimitSettings &project_limits_; }; diff --git a/app/src/main.cpp b/app/src/main.cpp index 80d8f5d..fdf37c5 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -9,6 +9,7 @@ #include #include "infrastructure/json_project_storage.h" +#include "infrastructure/application_settings_loader.h" #include "infrastructure/plc_communication_service.h" #include "infrastructure/plc_discovery_service.h" #include "infrastructure/plc_register_repository.h" @@ -39,12 +40,18 @@ int main(int argc, char *argv[]) // 设置组织名称,用来区分这个程序保存的 Qt 配置 application.setOrganizationName(QStringLiteral("QtProXinJe")); + const ApplicationSettingsLoadResult application_settings = + ApplicationSettingsLoader::load( + ApplicationSettingsLoader::defaultFilePath()); + // 负责把工程保存到 JSON 文件,也负责从 JSON 文件读取工程 - JsonProjectStorage project_storage; + JsonProjectStorage project_storage(application_settings.settings.projectLimits); // 统一管理新建、打开、保存和校验工程,实际读写文件交给上面的对象 - ProjectService project_service(project_storage); + ProjectService project_service( + project_storage, application_settings.settings.projectLimits); // 处理 HMI 页面和控件的添加、修改、删除、撤销与重做 - HmiEditorService hmi_editor_service(project_service); + HmiEditorService hmi_editor_service( + project_service, application_settings.settings.hmiDefaults); // 处理运行画面在不同 HMI 页面之间的跳转 HmiNavigationService hmi_navigation_service(project_service); // 处理梯形图网络和节点的编辑、校验、撤销与重做 @@ -95,7 +102,8 @@ int main(int argc, char *argv[]) hmi_navigation_service, register_comment_service, register_monitor_service, - plc_discovery_service); + plc_discovery_service, + application_settings); // 把主窗口显示到屏幕上 main_window.show(); diff --git a/app/src/services/alarm_editor_service.cpp b/app/src/services/alarm_editor_service.cpp index 479a0ed..fa41af8 100644 --- a/app/src/services/alarm_editor_service.cpp +++ b/app/src/services/alarm_editor_service.cpp @@ -37,9 +37,13 @@ const AlarmDefinition *AlarmEditorService::findDefinition( AlarmEditorResult AlarmEditorService::addDefinition( const AlarmDefinition &definition) { - if (definitions().size() >= ProjectLimits::kMaximumAlarmDefinitions) + const std::size_t maximum = + project_service_.projectLimits().maximumAlarmDefinitions; + if (definitions().size() >= maximum) { - return failure(AlarmEditorError::InvalidDefinition, "单个工程最多包含 256 条报警定义"); + return failure( + AlarmEditorError::InvalidDefinition, + "当前配置最多允许 " + std::to_string(maximum) + " 条报警定义"); } AlarmDefinition candidate = definition; candidate.id = makeUniqueId(); diff --git a/app/src/services/application_settings.h b/app/src/services/application_settings.h new file mode 100644 index 0000000..aaab719 --- /dev/null +++ b/app/src/services/application_settings.h @@ -0,0 +1,30 @@ +#pragma once + +#include "domain/project_limits.h" +#include "plc_communication_gateway.h" + +#include +#include + +// 应用启动后保持只读的用户配置;工程文件不会保存这些机器级偏好 +struct ApplicationSettings +{ + int version = 1; + ProjectLimitSettings projectLimits; + HmiDefaultSettings hmiDefaults; + PlcSerialConfiguration plcDefaults = [] + { + PlcSerialConfiguration configuration; + configuration.portName = "COM3"; + return configuration; + }(); +}; + +// 配置加载结果同时携带主界面启动后需要显示的诊断信息 +struct ApplicationSettingsLoadResult +{ + ApplicationSettings settings; + std::vector messages; + bool warningRequired = false; + std::string warningMessage; +}; diff --git a/app/src/services/hmi_editor_service.cpp b/app/src/services/hmi_editor_service.cpp index 3b20200..d3aba9f 100644 --- a/app/src/services/hmi_editor_service.cpp +++ b/app/src/services/hmi_editor_service.cpp @@ -63,8 +63,11 @@ std::size_t totalControlCount(const Project &project) } // namespace -HmiEditorService::HmiEditorService(ProjectService &project_service) - : project_service_(project_service) +HmiEditorService::HmiEditorService( + ProjectService &project_service, + const HmiDefaultSettings &hmi_defaults) + : project_service_(project_service), + hmi_defaults_(hmi_defaults) { } @@ -199,6 +202,8 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() HmiPage page; page.id = "page-1"; page.name = "主操作页面"; + page.width = hmi_defaults_.pageWidth; + page.height = hmi_defaults_.pageHeight; Project &project = project_service_.editProject(); project.hmiPages.push_back(std::move(page)); @@ -214,9 +219,13 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); } const Project ¤t = project_service_.project(); - if (current.hmiPages.size() >= ProjectLimits::kMaximumHmiPages) + const ProjectLimitSettings &limits = project_service_.projectLimits(); + if (current.hmiPages.size() >= limits.maximumHmiPages) { - return failure(HmiEditorError::InvalidPage, "单个工程最多包含 32 个 HMI 页面"); + return failure( + HmiEditorError::InvalidPage, + "当前配置最多允许 " + + std::to_string(limits.maximumHmiPages) + " 个 HMI 页面"); } if (name.size() > ProjectLimits::kMaximumTextBytes) { @@ -233,6 +242,8 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) HmiPage page; page.id = makeUniquePageId(current); page.name = name; + page.width = hmi_defaults_.pageWidth; + page.height = hmi_defaults_.pageHeight; HistoryState before = captureState(); Project &project = project_service_.editProject(); project.hmiPages.push_back(std::move(page)); @@ -262,7 +273,7 @@ HmiEditorResult HmiEditorService::resizePage( candidate.width = width; candidate.height = height; std::string error; - if (!candidate.validate(&error)) + if (!candidate.validate(project_service_.projectLimits(), &error)) { return failure(HmiEditorError::InvalidPage, error); } @@ -426,9 +437,13 @@ HmiEditorResult HmiEditorService::addControl( { return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); } - if (page->controls.size() >= ProjectLimits::kMaximumHmiControlsPerPage) + const ProjectLimitSettings &limits = project_service_.projectLimits(); + if (page->controls.size() >= limits.maximumHmiControlsPerPage) { - return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 128 个控件"); + return failure( + HmiEditorError::InvalidControl, + "当前配置下单个 HMI 页面最多允许 " + + std::to_string(limits.maximumHmiControlsPerPage) + " 个控件"); } if (totalControlCount(project_service_.project()) >= ProjectLimits::kMaximumHmiControlsPerProject) @@ -624,11 +639,15 @@ HmiEditorResult HmiEditorService::pasteControls( { return failure(HmiEditorError::InvalidOperation, "请先复制 HMI 控件"); } - if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage + const ProjectLimitSettings &limits = project_service_.projectLimits(); + if (controls.size() > limits.maximumHmiControlsPerPage || existing_page->controls.size() + controls.size() - > ProjectLimits::kMaximumHmiControlsPerPage) + > limits.maximumHmiControlsPerPage) { - return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 128 个控件"); + return failure( + HmiEditorError::InvalidControl, + "当前配置下单个 HMI 页面最多允许 " + + std::to_string(limits.maximumHmiControlsPerPage) + " 个控件"); } if (totalControlCount(project_service_.project()) + controls.size() > ProjectLimits::kMaximumHmiControlsPerProject) diff --git a/app/src/services/hmi_editor_service.h b/app/src/services/hmi_editor_service.h index b768949..2f84891 100644 --- a/app/src/services/hmi_editor_service.h +++ b/app/src/services/hmi_editor_service.h @@ -59,7 +59,9 @@ public: * @brief 创建 HMI 编辑服务 * @param project_service 用于读取和原子修改当前工程的项目服务 */ - explicit HmiEditorService(ProjectService &project_service); + explicit HmiEditorService( + ProjectService &project_service, + const HmiDefaultSettings &hmi_defaults = defaultHmiSettings()); /** * @brief 按页面标识查找只读页面 @@ -290,5 +292,6 @@ private: * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 */ ProjectService &project_service_; + const HmiDefaultSettings &hmi_defaults_; EditorHistory history_; }; diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index 0b79e74..55154c9 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -55,10 +55,14 @@ std::size_t totalRungCount(const Project &project) return total; } -std::string rungLimitMessage(const Project &project, const ControlLogic &logic) -{ - return logic.rungs.size() >= ProjectLimits::kMaximumRungsPerLogic - ? "单组控制逻辑最多包含 256 个网络" +std::string rungLimitMessage( + const Project &project, + const ControlLogic &logic, + const ProjectLimitSettings &limits) +{ + return logic.rungs.size() >= limits.maximumRungsPerLogic + ? "当前配置下单组控制逻辑最多允许 " + + std::to_string(limits.maximumRungsPerLogic) + " 个网络" : totalRungCount(project) >= ProjectLimits::kMaximumRungsPerProject ? "单个工程最多包含 2048 个梯形图网络" : std::string{}; @@ -974,9 +978,13 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name) return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); } const Project ¤t = project_service_.project(); - if (current.controlLogics.size() >= ProjectLimits::kMaximumControlLogics) + const ProjectLimitSettings &limits = project_service_.projectLimits(); + if (current.controlLogics.size() >= limits.maximumControlLogics) { - return failure(LogicEditorError::InvalidOperation, "单个工程最多包含 32 组控制逻辑"); + return failure( + LogicEditorError::InvalidOperation, + "当前配置最多允许 " + + std::to_string(limits.maximumControlLogics) + " 组控制逻辑"); } if (name.size() > ProjectLimits::kMaximumTextBytes) { @@ -1130,7 +1138,7 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } const std::string limit_message = rungLimitMessage( - project_service_.project(), *logic); + project_service_.project(), *logic, project_service_.projectLimits()); if (!limit_message.empty()) { return failure(LogicEditorError::InvalidOperation, limit_message); @@ -1224,7 +1232,9 @@ LogicEditorResult LogicEditorService::appendCondition( return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); } const std::string limit_message = create_rung - ? rungLimitMessage(project_service_.project(), *logic) : std::string{}; + ? rungLimitMessage( + project_service_.project(), *logic, project_service_.projectLimits()) + : std::string{}; if (!limit_message.empty()) { return failure(LogicEditorError::InvalidOperation, limit_message); @@ -1571,7 +1581,9 @@ LogicEditorResult LogicEditorService::appendWire( create_rung = true; } const std::string limit_message = create_rung - ? rungLimitMessage(project_service_.project(), *logic) : std::string{}; + ? rungLimitMessage( + project_service_.project(), *logic, project_service_.projectLimits()) + : std::string{}; if (!limit_message.empty()) { return failure(LogicEditorError::InvalidOperation, limit_message); @@ -2468,7 +2480,9 @@ LogicEditorResult LogicEditorService::setOutput( : "梯形图输出必须使用有效的输出指令"); } const std::string limit_message = rung_id.empty() - ? rungLimitMessage(project_service_.project(), *logic) : std::string{}; + ? rungLimitMessage( + project_service_.project(), *logic, project_service_.projectLimits()) + : std::string{}; if (!limit_message.empty()) { return failure(LogicEditorError::InvalidOperation, limit_message); @@ -2612,7 +2626,9 @@ LogicEditorResult LogicEditorService::pasteConditionNodes( return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); } const std::string limit_message = rung_id.empty() - ? rungLimitMessage(project_service_.project(), *existing_logic) + ? rungLimitMessage( + project_service_.project(), *existing_logic, + project_service_.projectLimits()) : std::string{}; if (!limit_message.empty()) { @@ -2742,7 +2758,8 @@ LogicEditorResult LogicEditorService::pasteRung( return failure(LogicEditorError::InvalidOperation, source_error); } const std::string limit_message = rungLimitMessage( - project_service_.project(), *existing_logic); + project_service_.project(), *existing_logic, + project_service_.projectLimits()); if (!limit_message.empty()) { return failure(LogicEditorError::InvalidOperation, limit_message); diff --git a/app/src/services/project_service.cpp b/app/src/services/project_service.cpp index bcf89b2..42f7f49 100644 --- a/app/src/services/project_service.cpp +++ b/app/src/services/project_service.cpp @@ -25,7 +25,15 @@ std::string generateProjectId() } // namespace ProjectService::ProjectService(ProjectStorage &storage) + : ProjectService(storage, defaultProjectLimitSettings()) +{ +} + +ProjectService::ProjectService( + ProjectStorage &storage, + const ProjectLimitSettings &project_limits) : storage_(storage), + project_limits_(project_limits), project_(makeNewProject("未命名工程")) { } @@ -57,6 +65,11 @@ bool ProjectService::isModified() const return modified_; } +const ProjectLimitSettings &ProjectService::projectLimits() const +{ + return project_limits_; +} + void ProjectService::restoreModifiedState(bool modified) { modified_ = modified; @@ -103,7 +116,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path) } std::string validation_error; - if (!project_.validate(&validation_error)) + if (!project_.validate(project_limits_, &validation_error)) { return {false, ProjectServiceError::InvalidProject, @@ -142,7 +155,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) } std::string validation_error; - if (!result.project.validate(&validation_error)) + if (!result.project.validate(project_limits_, &validation_error)) { return {false, ProjectServiceError::InvalidProject, diff --git a/app/src/services/project_service.h b/app/src/services/project_service.h index 66d719c..a92f64d 100644 --- a/app/src/services/project_service.h +++ b/app/src/services/project_service.h @@ -29,6 +29,9 @@ class ProjectService public: // 使用外部提供的工程存储实现创建服务 explicit ProjectService(ProjectStorage &storage); + ProjectService( + ProjectStorage &storage, + const ProjectLimitSettings &project_limits); // 只读访问当前工程;UI 和其他服务不应绕过 editProject 修改它 const Project &project() const; @@ -37,6 +40,7 @@ public: const std::string ¤tFilePath() const; bool hasCurrentFile() const; bool isModified() const; + const ProjectLimitSettings &projectLimits() const; // 失败编辑回滚时恢复进入编辑前的脏状态 void restoreModifiedState(bool modified); @@ -74,6 +78,7 @@ private: ProjectStorageError error, const std::string &message); ProjectStorage &storage_; // 非拥有的工程存储依赖 + const ProjectLimitSettings &project_limits_; // 应用启动时确定的只读数量上限 Project project_; // 当前编辑中的工程 std::string current_file_path_; // 当前工程对应的文件路径 bool modified_ = true; // 当前工程是否有未保存修改 diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index e9c71c8..d196f44 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -63,7 +63,8 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() return state_.enterOfflineRunning(); } std::string error; - if (!project_service_.project().validateForRunning(&error)) + if (!project_service_.project().validateForRunning( + project_service_.projectLimits(), &error)) { return {false, ModeTransitionError::ProjectNotReady}; } diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index a34b4e3..153b254 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -48,6 +48,7 @@ #include #include #include +#include #include @@ -262,6 +263,7 @@ MainWindow::MainWindow( RegisterCommentService ®ister_comment_service, RegisterMonitorService ®ister_monitor_service, PlcDiscoveryGateway &plc_discovery_gateway, + const ApplicationSettingsLoadResult &application_settings_result, QWidget *parent) : QMainWindow(parent), ui_(std::make_unique()), @@ -275,9 +277,11 @@ MainWindow::MainWindow( register_comment_service_(register_comment_service), register_monitor_service_(register_monitor_service), plc_discovery_gateway_(plc_discovery_gateway), + application_settings_result_(application_settings_result), owned_hmi_navigation_service_( std::make_unique(project_service)), - hmi_navigation_service_(owned_hmi_navigation_service_.get()) + hmi_navigation_service_(owned_hmi_navigation_service_.get()), + plc_configuration_(application_settings_result.settings.plcDefaults) { initializeUi(); } @@ -294,6 +298,7 @@ MainWindow::MainWindow( RegisterCommentService ®ister_comment_service, RegisterMonitorService ®ister_monitor_service, PlcDiscoveryGateway &plc_discovery_gateway, + const ApplicationSettingsLoadResult &application_settings_result, QWidget *parent) : QMainWindow(parent), ui_(std::make_unique()), @@ -307,7 +312,9 @@ MainWindow::MainWindow( register_comment_service_(register_comment_service), register_monitor_service_(register_monitor_service), plc_discovery_gateway_(plc_discovery_gateway), - hmi_navigation_service_(&hmi_navigation_service) + application_settings_result_(application_settings_result), + hmi_navigation_service_(&hmi_navigation_service), + plc_configuration_(application_settings_result.settings.plcDefaults) { initializeUi(); } @@ -323,6 +330,7 @@ void MainWindow::initializeUi() project_service_, hmi_editor_service_, logic_editor_service_, + application_settings_result_.settings.hmiDefaults, [this] { return current_hmi_page_id_; }, [this] { return current_logic_id_; }, selected_control_id_, @@ -355,6 +363,22 @@ void MainWindow::initializeUi() showControlProperties({}); refreshProjectUi(); updateModeUi(tr("系统已进入编辑态")); + for (const std::string &message : application_settings_result_.messages) + { + appendOutputMessage(fromUtf8(message)); + } + if (application_settings_result_.warningRequired) + { + const QString warning = fromUtf8( + application_settings_result_.warningMessage); + QTimer::singleShot( + 0, + this, + [this, warning] + { + QMessageBox::warning(this, tr("应用配置"), warning); + }); + } } MainWindow::~MainWindow() @@ -1658,7 +1682,9 @@ void MainWindow::showProjectResult( void MainWindow::appendOutputMessage(const QString &message) { - while (ui_->outputList->count() >= ProjectLimits::kMaximumOutputMessages) + const int maximum = application_settings_result_ + .settings.projectLimits.maximumOutputMessages; + while (ui_->outputList->count() >= maximum) { delete ui_->outputList->takeItem(0); } diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index 418a8a9..e59fab1 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -12,6 +12,7 @@ #include "domain/control_logic_model.h" #include "domain/runtime_state.h" #include "services/plc_communication_gateway.h" +#include "services/application_settings.h" #include @@ -79,6 +80,7 @@ public: RegisterCommentService ®ister_comment_service, RegisterMonitorService ®ister_monitor_service, PlcDiscoveryGateway &plc_discovery_gateway, + const ApplicationSettingsLoadResult &application_settings_result, QWidget *parent = nullptr); /** * @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务 @@ -97,6 +99,7 @@ public: RegisterCommentService ®ister_comment_service, RegisterMonitorService ®ister_monitor_service, PlcDiscoveryGateway &plc_discovery_gateway, + const ApplicationSettingsLoadResult &application_settings_result, QWidget *parent = nullptr); /** 释放窗口拥有的控制器和可选导航服务 */ @@ -256,6 +259,8 @@ private: RegisterMonitorService ®ister_monitor_service_; /** 非拥有的 PLC 自动搜索服务依赖 */ PlcDiscoveryGateway &plc_discovery_gateway_; + /** 启动期配置和诊断结果,由应用入口保证生命周期 */ + const ApplicationSettingsLoadResult &application_settings_result_; /** 当调用方未提供导航服务时由主窗口负责拥有的实例 */ std::unique_ptr owned_hmi_navigation_service_; /** 当前使用的 HMI 页面导航服务 */ diff --git a/app/src/ui/property_panel_controller.cpp b/app/src/ui/property_panel_controller.cpp index 6681e94..1d240bf 100644 --- a/app/src/ui/property_panel_controller.cpp +++ b/app/src/ui/property_panel_controller.cpp @@ -62,6 +62,7 @@ PropertyPanelController::PropertyPanelController( ProjectService &project_service, HmiEditorService &hmi_editor_service, LogicEditorService &logic_editor_service, + const HmiDefaultSettings &hmi_defaults, std::function current_page_id, std::function current_logic_id, std::string &selected_control_id, @@ -74,6 +75,7 @@ PropertyPanelController::PropertyPanelController( project_service_(project_service), hmi_editor_service_(hmi_editor_service), logic_editor_service_(logic_editor_service), + hmi_defaults_(hmi_defaults), current_page_id_(std::move(current_page_id)), current_logic_id_(std::move(current_logic_id)), selected_control_id_(selected_control_id), @@ -331,8 +333,8 @@ void PropertyPanelController::showPageProperties(const std::string &page_id) ui_.applyPagePropertiesButton->setEnabled(has_page); if (!has_page) { - ui_.pageWidthSpinBox->setValue(ProjectLimits::kDefaultHmiPageWidth); - ui_.pageHeightSpinBox->setValue(ProjectLimits::kDefaultHmiPageHeight); + ui_.pageWidthSpinBox->setValue(hmi_defaults_.pageWidth); + ui_.pageHeightSpinBox->setValue(hmi_defaults_.pageHeight); return; } ui_.pageWidthSpinBox->setValue(page->width); diff --git a/app/src/ui/property_panel_controller.h b/app/src/ui/property_panel_controller.h index f843f53..0261f21 100644 --- a/app/src/ui/property_panel_controller.h +++ b/app/src/ui/property_panel_controller.h @@ -49,6 +49,7 @@ public: ProjectService &project_service, HmiEditorService &hmi_editor_service, LogicEditorService &logic_editor_service, + const HmiDefaultSettings &hmi_defaults, std::function current_page_id, std::function current_logic_id, std::string &selected_control_id, @@ -102,6 +103,8 @@ private: HmiEditorService &hmi_editor_service_; /** 梯形图编辑服务,不由控制器拥有 */ LogicEditorService &logic_editor_service_; + /** 新建页面的启动期只读默认尺寸 */ + const HmiDefaultSettings &hmi_defaults_; /** 获取当前 HMI 页面标识的回调 */ std::function current_page_id_; /** 获取当前控制逻辑标识的回调 */ diff --git a/app/tests/alarm_service_tests.cpp b/app/tests/alarm_service_tests.cpp index 795f7dc..1f32357 100644 --- a/app/tests/alarm_service_tests.cpp +++ b/app/tests/alarm_service_tests.cpp @@ -102,6 +102,22 @@ void testAlarmDefinitionsAndRuntimeLifecycle() "leaving runtime must clear session alarm records"); } +void testConfiguredAlarmLimit() +{ + TestProjectStorage storage; + ProjectLimitSettings limits; + limits.maximumAlarmDefinitions = 0U; + ProjectService project_service(storage, limits); + AlarmEditorService editor_service(project_service); + + AlarmDefinition definition; + definition.address = RegisterAddress{RegisterArea::M, 0}; + definition.condition = AlarmCondition::MOn; + definition.message = "Disabled by configuration"; + require(!editor_service.addDefinition(definition).succeeded, + "the alarm editor must use the configured definition limit"); +} + } // namespace int main() @@ -109,6 +125,7 @@ int main() try { testAlarmDefinitionsAndRuntimeLifecycle(); + testConfiguredAlarmLimit(); } catch (const std::exception &error) { diff --git a/app/tests/application_settings_tests.cpp b/app/tests/application_settings_tests.cpp new file mode 100644 index 0000000..ff64357 --- /dev/null +++ b/app/tests/application_settings_tests.cpp @@ -0,0 +1,252 @@ +#include "infrastructure/application_settings_loader.h" +#include "support/test_support.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +using TestSupport::require; + +void writeUtf8(const QString &path, const QByteArray &contents) +{ + QFile file(path); + require(file.open(QIODevice::WriteOnly), "test configuration file must open"); + require(file.write(contents) == contents.size(), + "test configuration file must be written completely"); +} + +bool containsMessage( + const ApplicationSettingsLoadResult &result, + const std::string &fragment) +{ + return std::any_of( + result.messages.cbegin(), result.messages.cend(), + [&fragment](const std::string &message) + { + return message.find(fragment) != std::string::npos; + }); +} + +QByteArray readAll(const QString &path) +{ + QFile file(path); + require(file.open(QIODevice::ReadOnly), "test configuration file must open for reading"); + return file.readAll(); +} + +QByteArray completeConfiguration() +{ + return QByteArrayLiteral( + "[Config]\n" + "Version=1\n" + "[ProjectLimits]\n" + "MaxHmiPages=8\n" + "MaxHmiControlsPerPage=24\n" + "MaxAlarmDefinitions=40\n" + "MaxControlLogics=6\n" + "MaxRungsPerLogic=30\n" + "MaxOutputMessages=120\n" + "[HmiDefaults]\n" + "DefaultPageWidth=1024\n" + "DefaultPageHeight=600\n" + "[PlcDefaults]\n" + "PortName=COM7\n" + "ServerAddress=2\n" + "BaudRate=19200\n" + "DataBits=7\n" + "Parity=3\n" + "StopBits=2\n"); +} + +void testMissingFileCreatesDefaults() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + const QString path = directory.filePath("config/application.ini"); + + const ApplicationSettingsLoadResult result = + ApplicationSettingsLoader::load(path); + require(QFile::exists(path), "a missing application.ini must be created"); + require(!result.warningRequired, + "successful first-run configuration creation must not warn"); + require(result.settings.projectLimits.maximumHmiPages == 32U + && result.settings.hmiDefaults.pageWidth == 800 + && result.settings.plcDefaults.portName == "COM3", + "first run must use code defaults"); + const QByteArray created = readAll(path); + require(created.contains("[HmiDefaults]\n") + && created.contains("DefaultPageWidth=800\n") + && created.contains("DefaultPageHeight=400\n") + && created.contains("[PlcDefaults]\n") + && created.contains("PortName=COM3\n"), + "the generated file must expose HMI dimensions and PLC defaults"); + require(containsMessage(result, "已创建默认配置文件"), + "first run must report the created file"); +} + +void testCompleteConfigurationLoadsEveryPublicField() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + const QString path = directory.filePath("application.ini"); + writeUtf8(path, completeConfiguration()); + + const ApplicationSettingsLoadResult result = + ApplicationSettingsLoader::load(path); + const ApplicationSettings &settings = result.settings; + require(!result.warningRequired, "a valid configuration must be accepted"); + require(settings.projectLimits.maximumHmiPages == 8U + && settings.projectLimits.maximumHmiControlsPerPage == 24U + && settings.projectLimits.maximumAlarmDefinitions == 40U + && settings.projectLimits.maximumControlLogics == 6U + && settings.projectLimits.maximumRungsPerLogic == 30U + && settings.projectLimits.maximumOutputMessages == 120, + "all configurable project limits must be loaded"); + require(settings.hmiDefaults.pageWidth == 1024 + && settings.hmiDefaults.pageHeight == 600, + "HMI default dimensions must be loaded"); + require(settings.plcDefaults.portName == "COM7" + && settings.plcDefaults.serverAddress == 2 + && settings.plcDefaults.baudRate == 19200 + && settings.plcDefaults.dataBits == 7 + && settings.plcDefaults.parity == 3 + && settings.plcDefaults.stopBits == 2, + "PLC defaults must be loaded"); +} + +void testMissingAndUnknownFieldsDoNotInvalidateConfiguration() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + const QString path = directory.filePath("application.ini"); + writeUtf8( + path, + QByteArrayLiteral( + "[Config]\nVersion=1\n" + "[ProjectLimits]\nMaxHmiPages=5\nFutureLimit=99\n")); + + const ApplicationSettingsLoadResult result = + ApplicationSettingsLoader::load(path); + require(!result.warningRequired + && result.settings.projectLimits.maximumHmiPages == 5U + && result.settings.projectLimits.maximumAlarmDefinitions == 256U, + "missing fields must default without discarding valid values"); + require(containsMessage(result, "未知字段") + && containsMessage(result, "缺失"), + "missing and unknown fields must be reported"); +} + +void testDuplicateOrInvalidFieldsRejectTheWholeFile() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + const QString duplicate_path = directory.filePath("duplicate.ini"); + QByteArray duplicate = completeConfiguration(); + duplicate.append("[ProjectLimits]\nMaxHmiPages=4\n"); + writeUtf8(duplicate_path, duplicate); + + const ApplicationSettingsLoadResult duplicate_result = + ApplicationSettingsLoader::load(duplicate_path); + require(duplicate_result.warningRequired + && duplicate_result.settings.projectLimits.maximumHmiPages == 32U + && containsMessage(duplicate_result, "重复"), + "a duplicate field must reject the entire configuration"); + + const QString invalid_path = directory.filePath("invalid.ini"); + QByteArray invalid = completeConfiguration(); + invalid.replace("DefaultPageWidth=1024", "DefaultPageWidth=2000"); + writeUtf8(invalid_path, invalid); + const ApplicationSettingsLoadResult invalid_result = + ApplicationSettingsLoader::load(invalid_path); + require(invalid_result.warningRequired + && invalid_result.settings.hmiDefaults.pageWidth == 800 + && invalid_result.settings.plcDefaults.portName == "COM3", + "one invalid value must roll every section back to code defaults"); + + const QString non_integer_path = directory.filePath("non-integer.ini"); + QByteArray non_integer = completeConfiguration(); + non_integer.replace("MaxHmiPages=8", "MaxHmiPages=8pages"); + writeUtf8(non_integer_path, non_integer); + const ApplicationSettingsLoadResult non_integer_result = + ApplicationSettingsLoader::load(non_integer_path); + require(non_integer_result.warningRequired + && non_integer_result.settings.projectLimits.maximumHmiPages == 32U + && containsMessage(non_integer_result, "8pages"), + "quantity settings must reject values that are not pure integers"); +} + +void testVersionAndEncodingErrorsRejectTheWholeFile() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + + const QString version_path = directory.filePath("future-version.ini"); + QByteArray future_version = completeConfiguration(); + future_version.replace("Version=1", "Version=2"); + writeUtf8(version_path, future_version); + const ApplicationSettingsLoadResult version_result = + ApplicationSettingsLoader::load(version_path); + require(version_result.warningRequired + && version_result.settings.projectLimits.maximumHmiPages == 32U + && containsMessage(version_result, "Config.Version") + && containsMessage(version_result, "允许范围为 1~1"), + "an incompatible configuration version must reject the whole file"); + + const QString encoding_path = directory.filePath("invalid-utf8.ini"); + QByteArray invalid_utf8("[Config]\nVersion=1\n# "); + invalid_utf8.append(static_cast(0xFF)); + writeUtf8(encoding_path, invalid_utf8); + const ApplicationSettingsLoadResult encoding_result = + ApplicationSettingsLoader::load(encoding_path); + require(encoding_result.warningRequired + && containsMessage(encoding_result, "UTF-8") + && encoding_result.settings.plcDefaults.portName == "COM3", + "invalid UTF-8 must fall back to all code defaults"); +} + +void testCreationFailureKeepsTheApplicationStartable() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + const QString blocker = directory.filePath("not-a-directory"); + writeUtf8(blocker, QByteArrayLiteral("block")); + + const ApplicationSettingsLoadResult result = + ApplicationSettingsLoader::load(blocker + "/application.ini"); + require(result.warningRequired + && result.settings.projectLimits.maximumOutputMessages == 1000 + && containsMessage(result, "使用代码默认值"), + "configuration creation failure must fall back without aborting startup"); +} + +} // namespace + +int main(int argc, char *argv[]) +{ + QCoreApplication application(argc, argv); + try + { + testMissingFileCreatesDefaults(); + testCompleteConfigurationLoadsEveryPublicField(); + testMissingAndUnknownFieldsDoNotInvalidateConfiguration(); + testDuplicateOrInvalidFieldsRejectTheWholeFile(); + testVersionAndEncodingErrorsRejectTheWholeFile(); + testCreationFailureKeepsTheApplicationStartable(); + } + catch (const std::exception &error) + { + std::cerr << "application settings tests failed: " + << error.what() << '\n'; + return 1; + } + std::cout << "application settings tests passed\n"; + return 0; +} diff --git a/app/tests/application_settings_tests.pro b/app/tests/application_settings_tests.pro new file mode 100644 index 0000000..8271308 --- /dev/null +++ b/app/tests/application_settings_tests.pro @@ -0,0 +1,17 @@ +include(pri/test_defaults.pri) + +TARGET = application_settings_tests +QT += core +CONFIG += testcase + +SOURCES += \ + application_settings_tests.cpp \ + ../src/infrastructure/application_settings_loader.cpp + +HEADERS += \ + ../src/infrastructure/application_settings_loader.h \ + ../src/services/application_settings.h \ + ../src/services/plc_communication_gateway.h \ + ../src/domain/project_limits.h \ + ../src/domain/register_address.h \ + $$TEST_SUPPORT_HEADERS diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index 98d10ba..f458b87 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -801,6 +801,55 @@ void testRuntimeStateBoundaries() require(state.policy().usesPlcRegisters, "online mode must use PLC registers"); } +void testRuntimeConfiguredProjectLimits() +{ + ProjectLimitSettings limits; + std::string error; + + Project project = makeValidProject(); + HmiPage second_page = project.hmiPages.front(); + second_page.id = "second-page"; + second_page.name = "Second page"; + project.hmiPages.push_back(second_page); + limits.maximumHmiPages = 1U; + require(!project.validate(limits, &error) + && error.find("当前配置上限为 1") != std::string::npos, + "runtime page limits must be enforced by aggregate validation"); + + project = makeValidProject(); + HmiControl second_control = project.hmiPages.front().controls.front(); + second_control.id = "second-control"; + project.hmiPages.front().controls.push_back(second_control); + limits = {}; + limits.maximumHmiControlsPerPage = 1U; + require(!project.validate(limits, &error), + "runtime per-page control limits must be enforced"); + + project = makeValidProject(); + project.alarmDefinitions.push_back({}); + limits = {}; + limits.maximumAlarmDefinitions = 0U; + require(!project.validate(limits, &error), + "runtime alarm limits must be enforced before child validation"); + + project = makeValidProject(); + ControlLogic second_logic = project.controlLogics.front(); + second_logic.id = "second-logic"; + second_logic.name = "Second logic"; + second_logic.rungs.clear(); + project.controlLogics.push_back(second_logic); + limits = {}; + limits.maximumControlLogics = 1U; + require(!project.validate(limits, &error), + "runtime control-logic limits must be enforced"); + + project = makeValidProject(); + limits = {}; + limits.maximumRungsPerLogic = 0U; + require(!project.validate(limits, &error), + "runtime per-logic rung limits must be enforced"); +} + } // namespace int main() @@ -820,6 +869,7 @@ int main() testModelsValidateBindingsAndIdentifiers(); testMultiPageAndLogicDomainRules(); testQuantityBoundaries(); + testRuntimeConfiguredProjectLimits(); testRuntimeStateBoundaries(); } catch (const std::exception &error) diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp index 5629307..f598cf4 100644 --- a/app/tests/hmi_editor_service_tests.cpp +++ b/app/tests/hmi_editor_service_tests.cpp @@ -449,6 +449,30 @@ void testPageResizeIsAtomicAndUndoable() "page width below the business minimum must be rejected"); } +void testConfiguredPageDefaultsAndLimits() +{ + TestProjectStorage storage; + ProjectLimitSettings limits; + limits.maximumHmiPages = 1U; + limits.maximumHmiControlsPerPage = 1U; + HmiDefaultSettings defaults; + defaults.pageWidth = 1024; + defaults.pageHeight = 600; + ProjectService project_service(storage, limits); + HmiEditorService service(project_service, defaults); + + const HmiEditorResult page = service.ensureDefaultPage(); + require(page.succeeded + && service.findPage(page.id)->width == 1024 + && service.findPage(page.id)->height == 600, + "configured HMI dimensions must initialize the default page"); + require(!service.addPage("Second page").succeeded, + "the HMI editor must use the configured page limit"); + require(service.addControl(page.id, HmiControlType::Label).succeeded + && !service.addControl(page.id, HmiControlType::Label).succeeded, + "the HMI editor must use the configured per-page control limit"); +} + } // namespace int main() @@ -464,6 +488,7 @@ int main() testRuntimeUsesRegisterRepository(); testPageLifecycleAndNavigation(); testPageResizeIsAtomicAndUndoable(); + testConfiguredPageDefaultsAndLimits(); } catch (const std::exception &error) { diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index 13ced0b..3070b6a 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -1440,6 +1440,24 @@ void testConditionPasteTargetsAndValidation() "an invalid paste target must roll back without recording history"); } +void testConfiguredLogicLimits() +{ + TestProjectStorage storage; + ProjectLimitSettings limits; + limits.maximumControlLogics = 1U; + limits.maximumRungsPerLogic = 0U; + ProjectService project_service(storage, limits); + LogicEditorService service(project_service); + + const LogicEditorResult initial = service.ensureDefaultLogic(); + require(initial.succeeded, + "the configured-limit fixture must create its one default logic"); + require(!service.addLogic("Second logic").succeeded, + "the logic editor must use the configured logic-group limit"); + require(!service.addRung(initial.id).succeeded, + "the logic editor must use the configured per-logic rung limit"); +} + } // namespace int main() @@ -1466,6 +1484,7 @@ int main() testHistoryAndAtomicBatchDelete(); testBatchPasteNodesAndRung(); testConditionPasteTargetsAndValidation(); + testConfiguredLogicLimits(); } catch (const std::exception &error) { diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 73528d7..04658d4 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -934,6 +934,31 @@ void testServiceStateAndSaveErrors() "failed save as must keep the previous current path"); } +void testLowerConfiguredLimitRejectsProjectAtomically() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + const QString path = directory.filePath("two-pages.json"); + + JsonProjectStorage default_storage; + require(default_storage.save(makeExampleProject(), path.toStdString()).succeeded, + "configured-limit fixture must be saved with default limits"); + + ProjectLimitSettings limits; + limits.maximumHmiPages = 1U; + JsonProjectStorage limited_storage(limits); + ProjectService service(limited_storage, limits); + const std::string original_id = service.project().metadata.id; + const ProjectOperationResult result = service.load(path.toStdString()); + require(!result.succeeded + && result.message.find("元素数量为 2,当前上限为 1") + != std::string::npos, + "a project above the configured limit must report actual and maximum counts"); + require(service.project().metadata.id == original_id + && !service.hasCurrentFile(), + "a rejected project must not replace or partially modify current state"); +} + void testRegisterCommentService() { JsonProjectStorage storage; @@ -994,6 +1019,7 @@ int main() testInvalidFiles(); testQuantityFileLimits(); testHmiPageJsonBoundaries(); + testLowerConfiguredLimitRejectsProjectAtomically(); testServiceStateAndSaveErrors(); } catch (const std::exception &error) diff --git a/app/tests/tests.pro b/app/tests/tests.pro index 028e6aa..c17f4d7 100644 --- a/app/tests/tests.pro +++ b/app/tests/tests.pro @@ -4,6 +4,7 @@ CONFIG += ordered SUBDIRS += \ domain \ + application_settings \ alarm_service \ hmi_editor_service \ logic_editor_service \ @@ -16,6 +17,7 @@ SUBDIRS += \ plc_runtime domain.file = domain_tests.pro +application_settings.file = application_settings_tests.pro alarm_service.file = alarm_service_tests.pro hmi_editor_service.file = hmi_editor_service_tests.pro logic_editor_service.file = logic_editor_service_tests.pro