| @@ -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 | |||
| @@ -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; | |||
| } | |||
| @@ -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; | |||
| }; | |||
| @@ -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; | |||
| } | |||
| @@ -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; | |||
| }; | |||
| @@ -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; | |||
| } | |||
| @@ -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; | |||
| } | |||
| @@ -3,6 +3,7 @@ | |||
| #include "alarm_model.h" | |||
| #include "control_logic_model.h" | |||
| #include "hmi_model.h" | |||
| #include "project_limits.h" | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -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; | |||
| }; | |||
| @@ -0,0 +1,509 @@ | |||
| #include "application_settings_loader.h" | |||
| #include "domain/project_limits.h" | |||
| #include <QCoreApplication> | |||
| #include <QDir> | |||
| #include <QFile> | |||
| #include <QFileInfo> | |||
| #include <QMap> | |||
| #include <QSaveFile> | |||
| #include <QSet> | |||
| #include <QStringList> | |||
| #include <QTextCodec> | |||
| #include <initializer_list> | |||
| 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<std::size_t>(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<int> values, int value) | |||
| { | |||
| for (int candidate : values) | |||
| { | |||
| if (candidate == value) | |||
| { | |||
| return true; | |||
| } | |||
| } | |||
| return false; | |||
| } | |||
| bool readInteger( | |||
| const QMap<QString, ParsedEntry> &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<QString, ParsedEntry> &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<QString> 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<QString, ParsedEntry> 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<int>(parsed); | |||
| } | |||
| if (readInteger( | |||
| entries, QStringLiteral("ProjectLimits.MaxHmiPages"), | |||
| 1, ProjectLimits::kMaximumHmiPages, | |||
| ProjectLimits::kMaximumHmiPages, &parsed, ¬ices, &errors)) | |||
| { | |||
| candidate.projectLimits.maximumHmiPages = static_cast<std::size_t>(parsed); | |||
| } | |||
| if (readInteger( | |||
| entries, QStringLiteral("ProjectLimits.MaxHmiControlsPerPage"), | |||
| 1, ProjectLimits::kMaximumHmiControlsPerPage, | |||
| ProjectLimits::kMaximumHmiControlsPerPage, | |||
| &parsed, ¬ices, &errors)) | |||
| { | |||
| candidate.projectLimits.maximumHmiControlsPerPage = | |||
| static_cast<std::size_t>(parsed); | |||
| } | |||
| if (readInteger( | |||
| entries, QStringLiteral("ProjectLimits.MaxAlarmDefinitions"), | |||
| 0, ProjectLimits::kMaximumAlarmDefinitions, | |||
| ProjectLimits::kMaximumAlarmDefinitions, | |||
| &parsed, ¬ices, &errors)) | |||
| { | |||
| candidate.projectLimits.maximumAlarmDefinitions = | |||
| static_cast<std::size_t>(parsed); | |||
| } | |||
| if (readInteger( | |||
| entries, QStringLiteral("ProjectLimits.MaxControlLogics"), | |||
| 1, ProjectLimits::kMaximumControlLogics, | |||
| ProjectLimits::kMaximumControlLogics, | |||
| &parsed, ¬ices, &errors)) | |||
| { | |||
| candidate.projectLimits.maximumControlLogics = | |||
| static_cast<std::size_t>(parsed); | |||
| } | |||
| if (readInteger( | |||
| entries, QStringLiteral("ProjectLimits.MaxRungsPerLogic"), | |||
| 0, ProjectLimits::kMaximumRungsPerLogic, | |||
| ProjectLimits::kMaximumRungsPerLogic, | |||
| &parsed, ¬ices, &errors)) | |||
| { | |||
| candidate.projectLimits.maximumRungsPerLogic = | |||
| static_cast<std::size_t>(parsed); | |||
| } | |||
| if (readInteger( | |||
| entries, QStringLiteral("ProjectLimits.MaxOutputMessages"), | |||
| 1, ProjectLimits::kMaximumOutputMessages, | |||
| ProjectLimits::kMaximumOutputMessages, | |||
| &parsed, ¬ices, &errors)) | |||
| { | |||
| candidate.projectLimits.maximumOutputMessages = static_cast<int>(parsed); | |||
| } | |||
| if (readInteger( | |||
| entries, QStringLiteral("HmiDefaults.DefaultPageWidth"), | |||
| ProjectLimits::kMinimumHmiPageWidth, | |||
| ProjectLimits::kMaximumHmiPageWidth, | |||
| ProjectLimits::kDefaultHmiPageWidth, | |||
| &parsed, ¬ices, &errors)) | |||
| { | |||
| candidate.hmiDefaults.pageWidth = static_cast<int>(parsed); | |||
| } | |||
| if (readInteger( | |||
| entries, QStringLiteral("HmiDefaults.DefaultPageHeight"), | |||
| ProjectLimits::kMinimumHmiPageHeight, | |||
| ProjectLimits::kMaximumHmiPageHeight, | |||
| ProjectLimits::kDefaultHmiPageHeight, | |||
| &parsed, ¬ices, &errors)) | |||
| { | |||
| candidate.hmiDefaults.pageHeight = static_cast<int>(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<int>(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<int>(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<int>(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<int>(parsed); | |||
| } | |||
| int parity = candidate.plcDefaults.parity; | |||
| if (readInteger( | |||
| entries, QStringLiteral("PlcDefaults.Parity"), | |||
| 0, 3, candidate.plcDefaults.parity, | |||
| &parsed, ¬ices, &errors)) | |||
| { | |||
| parity = static_cast<int>(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<int>(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; | |||
| } | |||
| @@ -0,0 +1,13 @@ | |||
| #pragma once | |||
| #include "services/application_settings.h" | |||
| #include <QString> | |||
| // 从程序目录下的严格 UTF-8 INI 文件加载一次应用配置 | |||
| class ApplicationSettingsLoader final | |||
| { | |||
| public: | |||
| static QString defaultFilePath(); | |||
| static ApplicationSettingsLoadResult load(const QString &file_path); | |||
| }; | |||
| @@ -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<int>(ProjectLimits::kMaximumHmiControlsPerPage))) | |||
| static_cast<int>(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<int>(ProjectLimits::kMaximumRungsPerLogic))) | |||
| static_cast<int>(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<int>(ProjectLimits::kMaximumHmiPages)) | |||
| static_cast<int>(limits.maximumHmiPages)) | |||
| || !readString( | |||
| object, "initialHmiPageId", "project", | |||
| &project->initialHmiPageId, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readArray( | |||
| object, "alarmDefinitions", "project", &alarms, state, | |||
| static_cast<int>(ProjectLimits::kMaximumAlarmDefinitions)) | |||
| static_cast<int>(limits.maximumAlarmDefinitions)) | |||
| || !readArray( | |||
| object, "registerComments", "project", ®ister_comments, state, | |||
| static_cast<int>(ProjectLimits::kMaximumRegisterComments)) | |||
| || !readArray( | |||
| object, "controlLogics", "project", &logics, state, | |||
| static_cast<int>(ProjectLimits::kMaximumControlLogics))) | |||
| static_cast<int>(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, | |||
| {}, | |||
| @@ -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_; | |||
| }; | |||
| @@ -9,6 +9,7 @@ | |||
| #include <QApplication> | |||
| #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(); | |||
| @@ -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(); | |||
| @@ -0,0 +1,30 @@ | |||
| #pragma once | |||
| #include "domain/project_limits.h" | |||
| #include "plc_communication_gateway.h" | |||
| #include <string> | |||
| #include <vector> | |||
| // 应用启动后保持只读的用户配置;工程文件不会保存这些机器级偏好 | |||
| struct ApplicationSettings | |||
| { | |||
| int version = 1; | |||
| ProjectLimitSettings projectLimits; | |||
| HmiDefaultSettings hmiDefaults; | |||
| PlcSerialConfiguration plcDefaults = [] | |||
| { | |||
| PlcSerialConfiguration configuration; | |||
| configuration.portName = "COM3"; | |||
| return configuration; | |||
| }(); | |||
| }; | |||
| // 配置加载结果同时携带主界面启动后需要显示的诊断信息 | |||
| struct ApplicationSettingsLoadResult | |||
| { | |||
| ApplicationSettings settings; | |||
| std::vector<std::string> messages; | |||
| bool warningRequired = false; | |||
| std::string warningMessage; | |||
| }; | |||
| @@ -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) | |||
| @@ -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<HistoryState> history_; | |||
| }; | |||
| @@ -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); | |||
| @@ -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, | |||
| @@ -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; // 当前工程是否有未保存修改 | |||
| @@ -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}; | |||
| } | |||
| @@ -48,6 +48,7 @@ | |||
| #include <QToolBar> | |||
| #include <QToolButton> | |||
| #include <QTextEdit> | |||
| #include <QTimer> | |||
| #include <algorithm> | |||
| @@ -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<Ui::MainWindow>()), | |||
| @@ -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<HmiNavigationService>(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<Ui::MainWindow>()), | |||
| @@ -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); | |||
| } | |||
| @@ -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 <QMainWindow> | |||
| @@ -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<HmiNavigationService> owned_hmi_navigation_service_; | |||
| /** 当前使用的 HMI 页面导航服务 */ | |||
| @@ -62,6 +62,7 @@ PropertyPanelController::PropertyPanelController( | |||
| ProjectService &project_service, | |||
| HmiEditorService &hmi_editor_service, | |||
| LogicEditorService &logic_editor_service, | |||
| const HmiDefaultSettings &hmi_defaults, | |||
| std::function<std::string()> current_page_id, | |||
| std::function<std::string()> 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); | |||
| @@ -49,6 +49,7 @@ public: | |||
| ProjectService &project_service, | |||
| HmiEditorService &hmi_editor_service, | |||
| LogicEditorService &logic_editor_service, | |||
| const HmiDefaultSettings &hmi_defaults, | |||
| std::function<std::string()> current_page_id, | |||
| std::function<std::string()> 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<std::string()> current_page_id_; | |||
| /** 获取当前控制逻辑标识的回调 */ | |||
| @@ -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) | |||
| { | |||
| @@ -0,0 +1,252 @@ | |||
| #include "infrastructure/application_settings_loader.h" | |||
| #include "support/test_support.h" | |||
| #include <QCoreApplication> | |||
| #include <QFile> | |||
| #include <QTemporaryDir> | |||
| #include <algorithm> | |||
| #include <exception> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| 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<char>(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; | |||
| } | |||
| @@ -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 | |||
| @@ -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) | |||
| @@ -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) | |||
| { | |||
| @@ -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) | |||
| { | |||
| @@ -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) | |||
| @@ -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 | |||