| @@ -55,6 +55,7 @@ SOURCES += \ | |||||
| src/infrastructure/plc_communication_error_classifier.cpp \ | src/infrastructure/plc_communication_error_classifier.cpp \ | ||||
| src/infrastructure/plc_communication_service.cpp \ | src/infrastructure/plc_communication_service.cpp \ | ||||
| src/infrastructure/plc_discovery_service.cpp \ | src/infrastructure/plc_discovery_service.cpp \ | ||||
| src/infrastructure/application_settings_loader.cpp \ | |||||
| src/infrastructure/json_project_storage.cpp \ | src/infrastructure/json_project_storage.cpp \ | ||||
| src/ui/hmi_editor_widget.cpp \ | src/ui/hmi_editor_widget.cpp \ | ||||
| src/ui/logic_editor_widget.cpp | src/ui/logic_editor_widget.cpp | ||||
| @@ -100,10 +101,12 @@ HEADERS += \ | |||||
| src/services/register_monitor_service.h \ | src/services/register_monitor_service.h \ | ||||
| src/services/register_comment_service.h \ | src/services/register_comment_service.h \ | ||||
| src/services/plc_communication_gateway.h \ | src/services/plc_communication_gateway.h \ | ||||
| src/services/application_settings.h \ | |||||
| src/infrastructure/plc_register_repository.h \ | src/infrastructure/plc_register_repository.h \ | ||||
| src/infrastructure/plc_communication_error_classifier.h \ | src/infrastructure/plc_communication_error_classifier.h \ | ||||
| src/infrastructure/plc_communication_service.h \ | src/infrastructure/plc_communication_service.h \ | ||||
| src/infrastructure/plc_discovery_service.h \ | src/infrastructure/plc_discovery_service.h \ | ||||
| src/infrastructure/application_settings_loader.h \ | |||||
| src/infrastructure/json_project_storage.h \ | src/infrastructure/json_project_storage.h \ | ||||
| src/ui/hmi_editor_widget.h \ | src/ui/hmi_editor_widget.h \ | ||||
| src/ui/logic_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 | 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; | return false; | ||||
| } | } | ||||
| @@ -835,6 +841,12 @@ bool ControlLogic::validate(std::string *error) const | |||||
| } | } | ||||
| bool ControlLogic::validateStructure(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()) | if (id.empty() || name.empty()) | ||||
| { | { | ||||
| @@ -851,9 +863,13 @@ bool ControlLogic::validateStructure(std::string *error) const | |||||
| setError(error, "控制逻辑名称不能超过 256 个 UTF-8 字节"); | setError(error, "控制逻辑名称不能超过 256 个 UTF-8 字节"); | ||||
| return false; | 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; | return false; | ||||
| } | } | ||||
| if (hasDuplicateId(rungs)) | if (hasDuplicateId(rungs)) | ||||
| @@ -902,7 +918,13 @@ bool ControlLogic::validateStructure(std::string *error) const | |||||
| bool ControlLogic::validateForRunning(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; | return false; | ||||
| } | } | ||||
| @@ -257,7 +257,16 @@ struct ControlLogic | |||||
| // 校验可保存结构 | // 校验可保存结构 | ||||
| bool validate(std::string *error = nullptr) const; | 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(std::string *error = nullptr) const; | ||||
| bool validateStructure( | |||||
| const ProjectLimitSettings &limits, | |||||
| std::string *error = nullptr) const; | |||||
| // 校验运行所需资源和网络配置 | // 校验运行所需资源和网络配置 | ||||
| bool validateForRunning(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 | 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()) | if (id.empty() || name.empty()) | ||||
| { | { | ||||
| @@ -246,9 +252,13 @@ bool HmiPage::validate(std::string *error) const | |||||
| "HMI 页面宽度必须在 320~1600、高度必须在 200~800 范围内"); | "HMI 页面宽度必须在 320~1600、高度必须在 200~800 范围内"); | ||||
| return false; | 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; | return false; | ||||
| } | } | ||||
| @@ -280,7 +290,13 @@ bool HmiPage::validate(std::string *error) const | |||||
| bool HmiPage::validateForRunning(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; | return false; | ||||
| } | } | ||||
| @@ -120,5 +120,11 @@ struct HmiPage | |||||
| * @return 页面及其全部控件有效时返回 true | * @return 页面及其全部控件有效时返回 true | ||||
| */ | */ | ||||
| bool validate(std::string *error = nullptr) 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(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 条,超过后删除最旧的一条 | 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 | 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()) | 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 字节"); | setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节"); | ||||
| return false; | 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; | 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; | return false; | ||||
| } | } | ||||
| if (registerComments.size() > ProjectLimits::kMaximumRegisterComments) | if (registerComments.size() > ProjectLimits::kMaximumRegisterComments) | ||||
| @@ -148,9 +163,13 @@ bool Project::validate(std::string *error) const | |||||
| setError(error, "单个工程最多包含 8002 条软元件注释"); | setError(error, "单个工程最多包含 8002 条软元件注释"); | ||||
| return false; | 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; | return false; | ||||
| } | } | ||||
| std::size_t total_hmi_controls = 0U; | std::size_t total_hmi_controls = 0U; | ||||
| @@ -246,7 +265,7 @@ bool Project::validate(std::string *error) const | |||||
| for (const HmiPage &page : hmiPages) | for (const HmiPage &page : hmiPages) | ||||
| { | { | ||||
| // 工程聚合校验会向下委托页面和控件的完整规则 | // 工程聚合校验会向下委托页面和控件的完整规则 | ||||
| if (!page.validate(error)) | |||||
| if (!page.validate(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -273,7 +292,7 @@ bool Project::validate(std::string *error) const | |||||
| } | } | ||||
| for (const ControlLogic &logic : controlLogics) | for (const ControlLogic &logic : controlLogics) | ||||
| { | { | ||||
| if (!logic.validate(error)) | |||||
| if (!logic.validate(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -334,22 +353,28 @@ bool Project::validate(std::string *error) const | |||||
| } | } | ||||
| bool Project::validateForRunning(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; | return false; | ||||
| } | } | ||||
| for (const HmiPage &page : hmiPages) | for (const HmiPage &page : hmiPages) | ||||
| { | { | ||||
| if (!page.validateForRunning(error)) | |||||
| if (!page.validateForRunning(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| for (const ControlLogic &logic : controlLogics) | for (const ControlLogic &logic : controlLogics) | ||||
| { | { | ||||
| if (logic.enabled && !logic.validateForRunning(error)) | |||||
| if (logic.enabled && !logic.validateForRunning(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -3,6 +3,7 @@ | |||||
| #include "alarm_model.h" | #include "alarm_model.h" | ||||
| #include "control_logic_model.h" | #include "control_logic_model.h" | ||||
| #include "hmi_model.h" | #include "hmi_model.h" | ||||
| #include "project_limits.h" | |||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| @@ -48,6 +49,12 @@ struct Project | |||||
| const RegisterComment *findRegisterComment(const RegisterAddress &address) const; | const RegisterComment *findRegisterComment(const RegisterAddress &address) const; | ||||
| // 编辑态校验:允许尚未绑定完成的草稿 | // 编辑态校验:允许尚未绑定完成的草稿 | ||||
| bool validate(std::string *error = nullptr) 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(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( | return state->fail( | ||||
| ProjectStorageError::InvalidField, | ProjectStorageError::InvalidField, | ||||
| fieldPath(context, field) + " 的元素数量超过上限"); | |||||
| fieldPath(context, field) + " 的元素数量为 " | |||||
| + std::to_string(value->size()) + ",当前上限为 " | |||||
| + std::to_string(maximum_count)); | |||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -771,6 +773,7 @@ QJsonObject serializeHmiPage(const HmiPage &page) | |||||
| bool parseHmiPage( | bool parseHmiPage( | ||||
| const QJsonObject &object, | const QJsonObject &object, | ||||
| const std::string &context, | const std::string &context, | ||||
| const ProjectLimitSettings &limits, | |||||
| HmiPage *page, | HmiPage *page, | ||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| @@ -789,7 +792,7 @@ bool parseHmiPage( | |||||
| &page->height, state) | &page->height, state) | ||||
| || !readArray( | || !readArray( | ||||
| object, "controls", context, &controls, state, | object, "controls", context, &controls, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumHmiControlsPerPage))) | |||||
| static_cast<int>(limits.maximumHmiControlsPerPage))) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1528,6 +1531,7 @@ QJsonObject serializeControlLogic(const ControlLogic &logic) | |||||
| bool parseControlLogic( | bool parseControlLogic( | ||||
| const QJsonObject &object, | const QJsonObject &object, | ||||
| const std::string &context, | const std::string &context, | ||||
| const ProjectLimitSettings &limits, | |||||
| ControlLogic *logic, | ControlLogic *logic, | ||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| @@ -1539,7 +1543,7 @@ bool parseControlLogic( | |||||
| || !readBool(object, "enabled", context, &logic->enabled, state) | || !readBool(object, "enabled", context, &logic->enabled, state) | ||||
| || !readArray( | || !readArray( | ||||
| object, "rungs", context, &rungs, state, | object, "rungs", context, &rungs, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumRungsPerLogic))) | |||||
| static_cast<int>(limits.maximumRungsPerLogic))) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1608,7 +1612,10 @@ QJsonObject serializeProject(const Project &project) | |||||
| // 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本 | // 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本 | ||||
| bool parseProject( | bool parseProject( | ||||
| const QJsonObject &object, Project *project, ParseState *state) | |||||
| const QJsonObject &object, | |||||
| const ProjectLimitSettings &limits, | |||||
| Project *project, | |||||
| ParseState *state) | |||||
| { | { | ||||
| QJsonArray pages; | QJsonArray pages; | ||||
| QJsonArray alarms; | QJsonArray alarms; | ||||
| @@ -1638,20 +1645,20 @@ bool parseProject( | |||||
| || !readString(object, "name", "project", &project->metadata.name, state) | || !readString(object, "name", "project", &project->metadata.name, state) | ||||
| || !readArray( | || !readArray( | ||||
| object, "hmiPages", "project", &pages, state, | object, "hmiPages", "project", &pages, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumHmiPages)) | |||||
| static_cast<int>(limits.maximumHmiPages)) | |||||
| || !readString( | || !readString( | ||||
| object, "initialHmiPageId", "project", | object, "initialHmiPageId", "project", | ||||
| &project->initialHmiPageId, state, | &project->initialHmiPageId, state, | ||||
| ProjectLimits::kMaximumIdBytes) | ProjectLimits::kMaximumIdBytes) | ||||
| || !readArray( | || !readArray( | ||||
| object, "alarmDefinitions", "project", &alarms, state, | object, "alarmDefinitions", "project", &alarms, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumAlarmDefinitions)) | |||||
| static_cast<int>(limits.maximumAlarmDefinitions)) | |||||
| || !readArray( | || !readArray( | ||||
| object, "registerComments", "project", ®ister_comments, state, | object, "registerComments", "project", ®ister_comments, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumRegisterComments)) | static_cast<int>(ProjectLimits::kMaximumRegisterComments)) | ||||
| || !readArray( | || !readArray( | ||||
| object, "controlLogics", "project", &logics, state, | object, "controlLogics", "project", &logics, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumControlLogics))) | |||||
| static_cast<int>(limits.maximumControlLogics))) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1670,6 +1677,7 @@ bool parseProject( | |||||
| if (!parseHmiPage( | if (!parseHmiPage( | ||||
| pages.at(index).toObject(), | pages.at(index).toObject(), | ||||
| "project.hmiPages[" + std::to_string(index) + ']', | "project.hmiPages[" + std::to_string(index) + ']', | ||||
| limits, | |||||
| &page, | &page, | ||||
| state)) | state)) | ||||
| { | { | ||||
| @@ -1733,6 +1741,7 @@ bool parseProject( | |||||
| if (!parseControlLogic( | if (!parseControlLogic( | ||||
| logics.at(index).toObject(), | logics.at(index).toObject(), | ||||
| "project.controlLogics[" + std::to_string(index) + ']', | "project.controlLogics[" + std::to_string(index) + ']', | ||||
| limits, | |||||
| &logic, | &logic, | ||||
| state)) | state)) | ||||
| { | { | ||||
| @@ -1752,13 +1761,19 @@ ProjectSaveResult saveFailure( | |||||
| } // namespace | } // namespace | ||||
| JsonProjectStorage::JsonProjectStorage( | |||||
| const ProjectLimitSettings &project_limits) | |||||
| : project_limits_(project_limits) | |||||
| { | |||||
| } | |||||
| // 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件 | // 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件 | ||||
| ProjectSaveResult JsonProjectStorage::save( | ProjectSaveResult JsonProjectStorage::save( | ||||
| const Project &project, const std::string &file_path) | const Project &project, const std::string &file_path) | ||||
| { | { | ||||
| // 写文件前先执行领域校验,防止持久化内部关系不合法的工程 | // 写文件前先执行领域校验,防止持久化内部关系不合法的工程 | ||||
| std::string validation_error; | std::string validation_error; | ||||
| if (!project.validate(&validation_error)) | |||||
| if (!project.validate(project_limits_, &validation_error)) | |||||
| { | { | ||||
| return {false, ProjectStorageError::InvalidProject, validation_error}; | return {false, ProjectStorageError::InvalidProject, validation_error}; | ||||
| } | } | ||||
| @@ -1856,13 +1871,13 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||||
| // 先在局部对象中完成结构解析,失败时不会暴露半成品工程 | // 先在局部对象中完成结构解析,失败时不会暴露半成品工程 | ||||
| Project project; | Project project; | ||||
| ParseState state; | ParseState state; | ||||
| if (!parseProject(document.object(), &project, &state)) | |||||
| if (!parseProject(document.object(), project_limits_, &project, &state)) | |||||
| { | { | ||||
| return {false, {}, state.error, state.message}; | return {false, {}, state.error, state.message}; | ||||
| } | } | ||||
| // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 | // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 | ||||
| std::string validation_error; | std::string validation_error; | ||||
| if (!project.validate(&validation_error)) | |||||
| if (!project.validate(project_limits_, &validation_error)) | |||||
| { | { | ||||
| return {false, | return {false, | ||||
| {}, | {}, | ||||
| @@ -6,7 +6,13 @@ | |||||
| class JsonProjectStorage final : public ProjectStorage | class JsonProjectStorage final : public ProjectStorage | ||||
| { | { | ||||
| public: | public: | ||||
| explicit JsonProjectStorage( | |||||
| const ProjectLimitSettings &project_limits = | |||||
| defaultProjectLimitSettings()); | |||||
| ProjectSaveResult save( | ProjectSaveResult save( | ||||
| const Project &project, const std::string &file_path) override; | const Project &project, const std::string &file_path) override; | ||||
| ProjectLoadResult load(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 <QApplication> | ||||
| #include "infrastructure/json_project_storage.h" | #include "infrastructure/json_project_storage.h" | ||||
| #include "infrastructure/application_settings_loader.h" | |||||
| #include "infrastructure/plc_communication_service.h" | #include "infrastructure/plc_communication_service.h" | ||||
| #include "infrastructure/plc_discovery_service.h" | #include "infrastructure/plc_discovery_service.h" | ||||
| #include "infrastructure/plc_register_repository.h" | #include "infrastructure/plc_register_repository.h" | ||||
| @@ -39,12 +40,18 @@ int main(int argc, char *argv[]) | |||||
| // 设置组织名称,用来区分这个程序保存的 Qt 配置 | // 设置组织名称,用来区分这个程序保存的 Qt 配置 | ||||
| application.setOrganizationName(QStringLiteral("QtProXinJe")); | application.setOrganizationName(QStringLiteral("QtProXinJe")); | ||||
| const ApplicationSettingsLoadResult application_settings = | |||||
| ApplicationSettingsLoader::load( | |||||
| ApplicationSettingsLoader::defaultFilePath()); | |||||
| // 负责把工程保存到 JSON 文件,也负责从 JSON 文件读取工程 | // 负责把工程保存到 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 页面和控件的添加、修改、删除、撤销与重做 | // 处理 HMI 页面和控件的添加、修改、删除、撤销与重做 | ||||
| HmiEditorService hmi_editor_service(project_service); | |||||
| HmiEditorService hmi_editor_service( | |||||
| project_service, application_settings.settings.hmiDefaults); | |||||
| // 处理运行画面在不同 HMI 页面之间的跳转 | // 处理运行画面在不同 HMI 页面之间的跳转 | ||||
| HmiNavigationService hmi_navigation_service(project_service); | HmiNavigationService hmi_navigation_service(project_service); | ||||
| // 处理梯形图网络和节点的编辑、校验、撤销与重做 | // 处理梯形图网络和节点的编辑、校验、撤销与重做 | ||||
| @@ -95,7 +102,8 @@ int main(int argc, char *argv[]) | |||||
| hmi_navigation_service, | hmi_navigation_service, | ||||
| register_comment_service, | register_comment_service, | ||||
| register_monitor_service, | register_monitor_service, | ||||
| plc_discovery_service); | |||||
| plc_discovery_service, | |||||
| application_settings); | |||||
| // 把主窗口显示到屏幕上 | // 把主窗口显示到屏幕上 | ||||
| main_window.show(); | main_window.show(); | ||||
| @@ -37,9 +37,13 @@ const AlarmDefinition *AlarmEditorService::findDefinition( | |||||
| AlarmEditorResult AlarmEditorService::addDefinition( | AlarmEditorResult AlarmEditorService::addDefinition( | ||||
| const AlarmDefinition &definition) | 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; | AlarmDefinition candidate = definition; | ||||
| candidate.id = makeUniqueId(); | 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 | } // 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; | HmiPage page; | ||||
| page.id = "page-1"; | page.id = "page-1"; | ||||
| page.name = "主操作页面"; | page.name = "主操作页面"; | ||||
| page.width = hmi_defaults_.pageWidth; | |||||
| page.height = hmi_defaults_.pageHeight; | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.hmiPages.push_back(std::move(page)); | project.hmiPages.push_back(std::move(page)); | ||||
| @@ -214,9 +219,13 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | ||||
| } | } | ||||
| const Project ¤t = project_service_.project(); | 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) | if (name.size() > ProjectLimits::kMaximumTextBytes) | ||||
| { | { | ||||
| @@ -233,6 +242,8 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = makeUniquePageId(current); | page.id = makeUniquePageId(current); | ||||
| page.name = name; | page.name = name; | ||||
| page.width = hmi_defaults_.pageWidth; | |||||
| page.height = hmi_defaults_.pageHeight; | |||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.hmiPages.push_back(std::move(page)); | project.hmiPages.push_back(std::move(page)); | ||||
| @@ -262,7 +273,7 @@ HmiEditorResult HmiEditorService::resizePage( | |||||
| candidate.width = width; | candidate.width = width; | ||||
| candidate.height = height; | candidate.height = height; | ||||
| std::string error; | std::string error; | ||||
| if (!candidate.validate(&error)) | |||||
| if (!candidate.validate(project_service_.projectLimits(), &error)) | |||||
| { | { | ||||
| return failure(HmiEditorError::InvalidPage, error); | return failure(HmiEditorError::InvalidPage, error); | ||||
| } | } | ||||
| @@ -426,9 +437,13 @@ HmiEditorResult HmiEditorService::addControl( | |||||
| { | { | ||||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | 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()) | if (totalControlCount(project_service_.project()) | ||||
| >= ProjectLimits::kMaximumHmiControlsPerProject) | >= ProjectLimits::kMaximumHmiControlsPerProject) | ||||
| @@ -624,11 +639,15 @@ HmiEditorResult HmiEditorService::pasteControls( | |||||
| { | { | ||||
| return failure(HmiEditorError::InvalidOperation, "请先复制 HMI 控件"); | 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() | || 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() | if (totalControlCount(project_service_.project()) + controls.size() | ||||
| > ProjectLimits::kMaximumHmiControlsPerProject) | > ProjectLimits::kMaximumHmiControlsPerProject) | ||||
| @@ -59,7 +59,9 @@ public: | |||||
| * @brief 创建 HMI 编辑服务 | * @brief 创建 HMI 编辑服务 | ||||
| * @param project_service 用于读取和原子修改当前工程的项目服务 | * @param project_service 用于读取和原子修改当前工程的项目服务 | ||||
| */ | */ | ||||
| explicit HmiEditorService(ProjectService &project_service); | |||||
| explicit HmiEditorService( | |||||
| ProjectService &project_service, | |||||
| const HmiDefaultSettings &hmi_defaults = defaultHmiSettings()); | |||||
| /** | /** | ||||
| * @brief 按页面标识查找只读页面 | * @brief 按页面标识查找只读页面 | ||||
| @@ -290,5 +292,6 @@ private: | |||||
| * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | ||||
| */ | */ | ||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| const HmiDefaultSettings &hmi_defaults_; | |||||
| EditorHistory<HistoryState> history_; | EditorHistory<HistoryState> history_; | ||||
| }; | }; | ||||
| @@ -55,10 +55,14 @@ std::size_t totalRungCount(const Project &project) | |||||
| return total; | 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 | : totalRungCount(project) >= ProjectLimits::kMaximumRungsPerProject | ||||
| ? "单个工程最多包含 2048 个梯形图网络" | ? "单个工程最多包含 2048 个梯形图网络" | ||||
| : std::string{}; | : std::string{}; | ||||
| @@ -974,9 +978,13 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name) | |||||
| return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); | return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); | ||||
| } | } | ||||
| const Project ¤t = project_service_.project(); | 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) | if (name.size() > ProjectLimits::kMaximumTextBytes) | ||||
| { | { | ||||
| @@ -1130,7 +1138,7 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) | |||||
| return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); | return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); | ||||
| } | } | ||||
| const std::string limit_message = rungLimitMessage( | const std::string limit_message = rungLimitMessage( | ||||
| project_service_.project(), *logic); | |||||
| project_service_.project(), *logic, project_service_.projectLimits()); | |||||
| if (!limit_message.empty()) | if (!limit_message.empty()) | ||||
| { | { | ||||
| return failure(LogicEditorError::InvalidOperation, limit_message); | return failure(LogicEditorError::InvalidOperation, limit_message); | ||||
| @@ -1224,7 +1232,9 @@ LogicEditorResult LogicEditorService::appendCondition( | |||||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | ||||
| } | } | ||||
| const std::string limit_message = create_rung | 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()) | if (!limit_message.empty()) | ||||
| { | { | ||||
| return failure(LogicEditorError::InvalidOperation, limit_message); | return failure(LogicEditorError::InvalidOperation, limit_message); | ||||
| @@ -1571,7 +1581,9 @@ LogicEditorResult LogicEditorService::appendWire( | |||||
| create_rung = true; | create_rung = true; | ||||
| } | } | ||||
| const std::string limit_message = create_rung | 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()) | if (!limit_message.empty()) | ||||
| { | { | ||||
| return failure(LogicEditorError::InvalidOperation, limit_message); | return failure(LogicEditorError::InvalidOperation, limit_message); | ||||
| @@ -2468,7 +2480,9 @@ LogicEditorResult LogicEditorService::setOutput( | |||||
| : "梯形图输出必须使用有效的输出指令"); | : "梯形图输出必须使用有效的输出指令"); | ||||
| } | } | ||||
| const std::string limit_message = rung_id.empty() | 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()) | if (!limit_message.empty()) | ||||
| { | { | ||||
| return failure(LogicEditorError::InvalidOperation, limit_message); | return failure(LogicEditorError::InvalidOperation, limit_message); | ||||
| @@ -2612,7 +2626,9 @@ LogicEditorResult LogicEditorService::pasteConditionNodes( | |||||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | ||||
| } | } | ||||
| const std::string limit_message = rung_id.empty() | 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{}; | : std::string{}; | ||||
| if (!limit_message.empty()) | if (!limit_message.empty()) | ||||
| { | { | ||||
| @@ -2742,7 +2758,8 @@ LogicEditorResult LogicEditorService::pasteRung( | |||||
| return failure(LogicEditorError::InvalidOperation, source_error); | return failure(LogicEditorError::InvalidOperation, source_error); | ||||
| } | } | ||||
| const std::string limit_message = rungLimitMessage( | const std::string limit_message = rungLimitMessage( | ||||
| project_service_.project(), *existing_logic); | |||||
| project_service_.project(), *existing_logic, | |||||
| project_service_.projectLimits()); | |||||
| if (!limit_message.empty()) | if (!limit_message.empty()) | ||||
| { | { | ||||
| return failure(LogicEditorError::InvalidOperation, limit_message); | return failure(LogicEditorError::InvalidOperation, limit_message); | ||||
| @@ -25,7 +25,15 @@ std::string generateProjectId() | |||||
| } // namespace | } // namespace | ||||
| ProjectService::ProjectService(ProjectStorage &storage) | ProjectService::ProjectService(ProjectStorage &storage) | ||||
| : ProjectService(storage, defaultProjectLimitSettings()) | |||||
| { | |||||
| } | |||||
| ProjectService::ProjectService( | |||||
| ProjectStorage &storage, | |||||
| const ProjectLimitSettings &project_limits) | |||||
| : storage_(storage), | : storage_(storage), | ||||
| project_limits_(project_limits), | |||||
| project_(makeNewProject("未命名工程")) | project_(makeNewProject("未命名工程")) | ||||
| { | { | ||||
| } | } | ||||
| @@ -57,6 +65,11 @@ bool ProjectService::isModified() const | |||||
| return modified_; | return modified_; | ||||
| } | } | ||||
| const ProjectLimitSettings &ProjectService::projectLimits() const | |||||
| { | |||||
| return project_limits_; | |||||
| } | |||||
| void ProjectService::restoreModifiedState(bool modified) | void ProjectService::restoreModifiedState(bool modified) | ||||
| { | { | ||||
| modified_ = modified; | modified_ = modified; | ||||
| @@ -103,7 +116,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path) | |||||
| } | } | ||||
| std::string validation_error; | std::string validation_error; | ||||
| if (!project_.validate(&validation_error)) | |||||
| if (!project_.validate(project_limits_, &validation_error)) | |||||
| { | { | ||||
| return {false, | return {false, | ||||
| ProjectServiceError::InvalidProject, | ProjectServiceError::InvalidProject, | ||||
| @@ -142,7 +155,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) | |||||
| } | } | ||||
| std::string validation_error; | std::string validation_error; | ||||
| if (!result.project.validate(&validation_error)) | |||||
| if (!result.project.validate(project_limits_, &validation_error)) | |||||
| { | { | ||||
| return {false, | return {false, | ||||
| ProjectServiceError::InvalidProject, | ProjectServiceError::InvalidProject, | ||||
| @@ -29,6 +29,9 @@ class ProjectService | |||||
| public: | public: | ||||
| // 使用外部提供的工程存储实现创建服务 | // 使用外部提供的工程存储实现创建服务 | ||||
| explicit ProjectService(ProjectStorage &storage); | explicit ProjectService(ProjectStorage &storage); | ||||
| ProjectService( | |||||
| ProjectStorage &storage, | |||||
| const ProjectLimitSettings &project_limits); | |||||
| // 只读访问当前工程;UI 和其他服务不应绕过 editProject 修改它 | // 只读访问当前工程;UI 和其他服务不应绕过 editProject 修改它 | ||||
| const Project &project() const; | const Project &project() const; | ||||
| @@ -37,6 +40,7 @@ public: | |||||
| const std::string ¤tFilePath() const; | const std::string ¤tFilePath() const; | ||||
| bool hasCurrentFile() const; | bool hasCurrentFile() const; | ||||
| bool isModified() const; | bool isModified() const; | ||||
| const ProjectLimitSettings &projectLimits() const; | |||||
| // 失败编辑回滚时恢复进入编辑前的脏状态 | // 失败编辑回滚时恢复进入编辑前的脏状态 | ||||
| void restoreModifiedState(bool modified); | void restoreModifiedState(bool modified); | ||||
| @@ -74,6 +78,7 @@ private: | |||||
| ProjectStorageError error, const std::string &message); | ProjectStorageError error, const std::string &message); | ||||
| ProjectStorage &storage_; // 非拥有的工程存储依赖 | ProjectStorage &storage_; // 非拥有的工程存储依赖 | ||||
| const ProjectLimitSettings &project_limits_; // 应用启动时确定的只读数量上限 | |||||
| Project project_; // 当前编辑中的工程 | Project project_; // 当前编辑中的工程 | ||||
| std::string current_file_path_; // 当前工程对应的文件路径 | std::string current_file_path_; // 当前工程对应的文件路径 | ||||
| bool modified_ = true; // 当前工程是否有未保存修改 | bool modified_ = true; // 当前工程是否有未保存修改 | ||||
| @@ -63,7 +63,8 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||||
| return state_.enterOfflineRunning(); | return state_.enterOfflineRunning(); | ||||
| } | } | ||||
| std::string error; | std::string error; | ||||
| if (!project_service_.project().validateForRunning(&error)) | |||||
| if (!project_service_.project().validateForRunning( | |||||
| project_service_.projectLimits(), &error)) | |||||
| { | { | ||||
| return {false, ModeTransitionError::ProjectNotReady}; | return {false, ModeTransitionError::ProjectNotReady}; | ||||
| } | } | ||||
| @@ -48,6 +48,7 @@ | |||||
| #include <QToolBar> | #include <QToolBar> | ||||
| #include <QToolButton> | #include <QToolButton> | ||||
| #include <QTextEdit> | #include <QTextEdit> | ||||
| #include <QTimer> | |||||
| #include <algorithm> | #include <algorithm> | ||||
| @@ -262,6 +263,7 @@ MainWindow::MainWindow( | |||||
| RegisterCommentService ®ister_comment_service, | RegisterCommentService ®ister_comment_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| PlcDiscoveryGateway &plc_discovery_gateway, | PlcDiscoveryGateway &plc_discovery_gateway, | ||||
| const ApplicationSettingsLoadResult &application_settings_result, | |||||
| QWidget *parent) | QWidget *parent) | ||||
| : QMainWindow(parent), | : QMainWindow(parent), | ||||
| ui_(std::make_unique<Ui::MainWindow>()), | ui_(std::make_unique<Ui::MainWindow>()), | ||||
| @@ -275,9 +277,11 @@ MainWindow::MainWindow( | |||||
| register_comment_service_(register_comment_service), | register_comment_service_(register_comment_service), | ||||
| register_monitor_service_(register_monitor_service), | register_monitor_service_(register_monitor_service), | ||||
| plc_discovery_gateway_(plc_discovery_gateway), | plc_discovery_gateway_(plc_discovery_gateway), | ||||
| application_settings_result_(application_settings_result), | |||||
| owned_hmi_navigation_service_( | owned_hmi_navigation_service_( | ||||
| std::make_unique<HmiNavigationService>(project_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(); | initializeUi(); | ||||
| } | } | ||||
| @@ -294,6 +298,7 @@ MainWindow::MainWindow( | |||||
| RegisterCommentService ®ister_comment_service, | RegisterCommentService ®ister_comment_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| PlcDiscoveryGateway &plc_discovery_gateway, | PlcDiscoveryGateway &plc_discovery_gateway, | ||||
| const ApplicationSettingsLoadResult &application_settings_result, | |||||
| QWidget *parent) | QWidget *parent) | ||||
| : QMainWindow(parent), | : QMainWindow(parent), | ||||
| ui_(std::make_unique<Ui::MainWindow>()), | ui_(std::make_unique<Ui::MainWindow>()), | ||||
| @@ -307,7 +312,9 @@ MainWindow::MainWindow( | |||||
| register_comment_service_(register_comment_service), | register_comment_service_(register_comment_service), | ||||
| register_monitor_service_(register_monitor_service), | register_monitor_service_(register_monitor_service), | ||||
| plc_discovery_gateway_(plc_discovery_gateway), | 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(); | initializeUi(); | ||||
| } | } | ||||
| @@ -323,6 +330,7 @@ void MainWindow::initializeUi() | |||||
| project_service_, | project_service_, | ||||
| hmi_editor_service_, | hmi_editor_service_, | ||||
| logic_editor_service_, | logic_editor_service_, | ||||
| application_settings_result_.settings.hmiDefaults, | |||||
| [this] { return current_hmi_page_id_; }, | [this] { return current_hmi_page_id_; }, | ||||
| [this] { return current_logic_id_; }, | [this] { return current_logic_id_; }, | ||||
| selected_control_id_, | selected_control_id_, | ||||
| @@ -355,6 +363,22 @@ void MainWindow::initializeUi() | |||||
| showControlProperties({}); | showControlProperties({}); | ||||
| refreshProjectUi(); | refreshProjectUi(); | ||||
| updateModeUi(tr("系统已进入编辑态")); | 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() | MainWindow::~MainWindow() | ||||
| @@ -1658,7 +1682,9 @@ void MainWindow::showProjectResult( | |||||
| void MainWindow::appendOutputMessage(const QString &message) | 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); | delete ui_->outputList->takeItem(0); | ||||
| } | } | ||||
| @@ -12,6 +12,7 @@ | |||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| #include "services/plc_communication_gateway.h" | #include "services/plc_communication_gateway.h" | ||||
| #include "services/application_settings.h" | |||||
| #include <QMainWindow> | #include <QMainWindow> | ||||
| @@ -79,6 +80,7 @@ public: | |||||
| RegisterCommentService ®ister_comment_service, | RegisterCommentService ®ister_comment_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| PlcDiscoveryGateway &plc_discovery_gateway, | PlcDiscoveryGateway &plc_discovery_gateway, | ||||
| const ApplicationSettingsLoadResult &application_settings_result, | |||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** | /** | ||||
| * @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务 | * @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务 | ||||
| @@ -97,6 +99,7 @@ public: | |||||
| RegisterCommentService ®ister_comment_service, | RegisterCommentService ®ister_comment_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| PlcDiscoveryGateway &plc_discovery_gateway, | PlcDiscoveryGateway &plc_discovery_gateway, | ||||
| const ApplicationSettingsLoadResult &application_settings_result, | |||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 释放窗口拥有的控制器和可选导航服务 */ | /** 释放窗口拥有的控制器和可选导航服务 */ | ||||
| @@ -256,6 +259,8 @@ private: | |||||
| RegisterMonitorService ®ister_monitor_service_; | RegisterMonitorService ®ister_monitor_service_; | ||||
| /** 非拥有的 PLC 自动搜索服务依赖 */ | /** 非拥有的 PLC 自动搜索服务依赖 */ | ||||
| PlcDiscoveryGateway &plc_discovery_gateway_; | PlcDiscoveryGateway &plc_discovery_gateway_; | ||||
| /** 启动期配置和诊断结果,由应用入口保证生命周期 */ | |||||
| const ApplicationSettingsLoadResult &application_settings_result_; | |||||
| /** 当调用方未提供导航服务时由主窗口负责拥有的实例 */ | /** 当调用方未提供导航服务时由主窗口负责拥有的实例 */ | ||||
| std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; | std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; | ||||
| /** 当前使用的 HMI 页面导航服务 */ | /** 当前使用的 HMI 页面导航服务 */ | ||||
| @@ -62,6 +62,7 @@ PropertyPanelController::PropertyPanelController( | |||||
| ProjectService &project_service, | ProjectService &project_service, | ||||
| HmiEditorService &hmi_editor_service, | HmiEditorService &hmi_editor_service, | ||||
| LogicEditorService &logic_editor_service, | LogicEditorService &logic_editor_service, | ||||
| const HmiDefaultSettings &hmi_defaults, | |||||
| std::function<std::string()> current_page_id, | std::function<std::string()> current_page_id, | ||||
| std::function<std::string()> current_logic_id, | std::function<std::string()> current_logic_id, | ||||
| std::string &selected_control_id, | std::string &selected_control_id, | ||||
| @@ -74,6 +75,7 @@ PropertyPanelController::PropertyPanelController( | |||||
| project_service_(project_service), | project_service_(project_service), | ||||
| hmi_editor_service_(hmi_editor_service), | hmi_editor_service_(hmi_editor_service), | ||||
| logic_editor_service_(logic_editor_service), | logic_editor_service_(logic_editor_service), | ||||
| hmi_defaults_(hmi_defaults), | |||||
| current_page_id_(std::move(current_page_id)), | current_page_id_(std::move(current_page_id)), | ||||
| current_logic_id_(std::move(current_logic_id)), | current_logic_id_(std::move(current_logic_id)), | ||||
| selected_control_id_(selected_control_id), | selected_control_id_(selected_control_id), | ||||
| @@ -331,8 +333,8 @@ void PropertyPanelController::showPageProperties(const std::string &page_id) | |||||
| ui_.applyPagePropertiesButton->setEnabled(has_page); | ui_.applyPagePropertiesButton->setEnabled(has_page); | ||||
| if (!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; | return; | ||||
| } | } | ||||
| ui_.pageWidthSpinBox->setValue(page->width); | ui_.pageWidthSpinBox->setValue(page->width); | ||||
| @@ -49,6 +49,7 @@ public: | |||||
| ProjectService &project_service, | ProjectService &project_service, | ||||
| HmiEditorService &hmi_editor_service, | HmiEditorService &hmi_editor_service, | ||||
| LogicEditorService &logic_editor_service, | LogicEditorService &logic_editor_service, | ||||
| const HmiDefaultSettings &hmi_defaults, | |||||
| std::function<std::string()> current_page_id, | std::function<std::string()> current_page_id, | ||||
| std::function<std::string()> current_logic_id, | std::function<std::string()> current_logic_id, | ||||
| std::string &selected_control_id, | std::string &selected_control_id, | ||||
| @@ -102,6 +103,8 @@ private: | |||||
| HmiEditorService &hmi_editor_service_; | HmiEditorService &hmi_editor_service_; | ||||
| /** 梯形图编辑服务,不由控制器拥有 */ | /** 梯形图编辑服务,不由控制器拥有 */ | ||||
| LogicEditorService &logic_editor_service_; | LogicEditorService &logic_editor_service_; | ||||
| /** 新建页面的启动期只读默认尺寸 */ | |||||
| const HmiDefaultSettings &hmi_defaults_; | |||||
| /** 获取当前 HMI 页面标识的回调 */ | /** 获取当前 HMI 页面标识的回调 */ | ||||
| std::function<std::string()> current_page_id_; | std::function<std::string()> current_page_id_; | ||||
| /** 获取当前控制逻辑标识的回调 */ | /** 获取当前控制逻辑标识的回调 */ | ||||
| @@ -102,6 +102,22 @@ void testAlarmDefinitionsAndRuntimeLifecycle() | |||||
| "leaving runtime must clear session alarm records"); | "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 | } // namespace | ||||
| int main() | int main() | ||||
| @@ -109,6 +125,7 @@ int main() | |||||
| try | try | ||||
| { | { | ||||
| testAlarmDefinitionsAndRuntimeLifecycle(); | testAlarmDefinitionsAndRuntimeLifecycle(); | ||||
| testConfiguredAlarmLimit(); | |||||
| } | } | ||||
| catch (const std::exception &error) | 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"); | 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 | } // namespace | ||||
| int main() | int main() | ||||
| @@ -820,6 +869,7 @@ int main() | |||||
| testModelsValidateBindingsAndIdentifiers(); | testModelsValidateBindingsAndIdentifiers(); | ||||
| testMultiPageAndLogicDomainRules(); | testMultiPageAndLogicDomainRules(); | ||||
| testQuantityBoundaries(); | testQuantityBoundaries(); | ||||
| testRuntimeConfiguredProjectLimits(); | |||||
| testRuntimeStateBoundaries(); | testRuntimeStateBoundaries(); | ||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| @@ -449,6 +449,30 @@ void testPageResizeIsAtomicAndUndoable() | |||||
| "page width below the business minimum must be rejected"); | "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 | } // namespace | ||||
| int main() | int main() | ||||
| @@ -464,6 +488,7 @@ int main() | |||||
| testRuntimeUsesRegisterRepository(); | testRuntimeUsesRegisterRepository(); | ||||
| testPageLifecycleAndNavigation(); | testPageLifecycleAndNavigation(); | ||||
| testPageResizeIsAtomicAndUndoable(); | testPageResizeIsAtomicAndUndoable(); | ||||
| testConfiguredPageDefaultsAndLimits(); | |||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| { | { | ||||
| @@ -1440,6 +1440,24 @@ void testConditionPasteTargetsAndValidation() | |||||
| "an invalid paste target must roll back without recording history"); | "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 | } // namespace | ||||
| int main() | int main() | ||||
| @@ -1466,6 +1484,7 @@ int main() | |||||
| testHistoryAndAtomicBatchDelete(); | testHistoryAndAtomicBatchDelete(); | ||||
| testBatchPasteNodesAndRung(); | testBatchPasteNodesAndRung(); | ||||
| testConditionPasteTargetsAndValidation(); | testConditionPasteTargetsAndValidation(); | ||||
| testConfiguredLogicLimits(); | |||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| { | { | ||||
| @@ -934,6 +934,31 @@ void testServiceStateAndSaveErrors() | |||||
| "failed save as must keep the previous current path"); | "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() | void testRegisterCommentService() | ||||
| { | { | ||||
| JsonProjectStorage storage; | JsonProjectStorage storage; | ||||
| @@ -994,6 +1019,7 @@ int main() | |||||
| testInvalidFiles(); | testInvalidFiles(); | ||||
| testQuantityFileLimits(); | testQuantityFileLimits(); | ||||
| testHmiPageJsonBoundaries(); | testHmiPageJsonBoundaries(); | ||||
| testLowerConfiguredLimitRejectsProjectAtomically(); | |||||
| testServiceStateAndSaveErrors(); | testServiceStateAndSaveErrors(); | ||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| @@ -4,6 +4,7 @@ CONFIG += ordered | |||||
| SUBDIRS += \ | SUBDIRS += \ | ||||
| domain \ | domain \ | ||||
| application_settings \ | |||||
| alarm_service \ | alarm_service \ | ||||
| hmi_editor_service \ | hmi_editor_service \ | ||||
| logic_editor_service \ | logic_editor_service \ | ||||
| @@ -16,6 +17,7 @@ SUBDIRS += \ | |||||
| plc_runtime | plc_runtime | ||||
| domain.file = domain_tests.pro | domain.file = domain_tests.pro | ||||
| application_settings.file = application_settings_tests.pro | |||||
| alarm_service.file = alarm_service_tests.pro | alarm_service.file = alarm_service_tests.pro | ||||
| hmi_editor_service.file = hmi_editor_service_tests.pro | hmi_editor_service.file = hmi_editor_service_tests.pro | ||||
| logic_editor_service.file = logic_editor_service_tests.pro | logic_editor_service.file = logic_editor_service_tests.pro | ||||