diff --git a/docs/de-DE/platform-operations.md b/docs/de-DE/platform-operations.md index 6a4398e..333b3a6 100644 --- a/docs/de-DE/platform-operations.md +++ b/docs/de-DE/platform-operations.md @@ -25,6 +25,8 @@ mxrb frontend migrate App.mpr --apply --json script/frontend_acceptance App.mpr -o frontend-round-trip.json script/frontend_acceptance App.mpr --mxbuild /pfad/mxbuild -o frontend.json script/frontend_acceptance App.mpr --mx /pfad/mx -o frontend-diagnostics.json +script/frontend_lifecycle_acceptance --version 11.12.1 --mx /pfad/mx \ + --mxbuild /pfad/mxbuild --strict-warnings -o frontend-lifecycle.json ``` `mxrb frontend migrate` ist standardmäßig eine unveränderliche, fail-closed @@ -56,6 +58,14 @@ Modellfehler schlägt geschlossen fehl. Am 4. August 2026 schloss die sichere Migration die akzeptierte Matrix 10.24.0.73019 und 11.12.1 ab: Quelle und Rebuild lieferten null MxBuild-Fehler, und beide Reports setzten `frontend_ready` auf `true`. +`script/frontend_lifecycle_acceptance` erstellt eine App ausschließlich über +die CLI, erzeugt das MPR, exportiert nach Ruby, ändert Home und Navigation, +fügt Entität, Formular, Microflow, Nanoflow und Asset hinzu, erzeugt erneut, +exportiert ein zweites Mal und verlangt einen strukturell identischen Rebuild. +In der offiziellen Matrix 10.24.0.73019 und 11.12.1 endeten Quelle und Rebuild +mit null Fehlern, Warnungen, Deprecations oder Empfehlungen in `mx check` sowie +null MxBuild-Fehlern. + `--mx` führt den offiziellen schreibgeschützten Checker mit Warnungen, Deprecations und Best-Practice-Empfehlungen aus. Das Gate validiert die Exit-Bitmaske und vergleicht normalisierte Signaturen von Quelle und Rebuild. diff --git a/docs/en-US/platform-operations.md b/docs/en-US/platform-operations.md index 746074e..89e7157 100644 --- a/docs/en-US/platform-operations.md +++ b/docs/en-US/platform-operations.md @@ -25,6 +25,8 @@ mxrb frontend migrate App.mpr --apply --json script/frontend_acceptance App.mpr -o frontend-round-trip.json script/frontend_acceptance App.mpr --mxbuild /path/to/mxbuild -o frontend.json script/frontend_acceptance App.mpr --mx /path/to/mx -o frontend-diagnostics.json +script/frontend_lifecycle_acceptance --version 11.12.1 --mx /path/to/mx \ + --mxbuild /path/to/mxbuild --strict-warnings -o frontend-lifecycle.json ``` `mxrb frontend migrate` is an immutable, fail-closed preview by default. For @@ -55,6 +57,14 @@ On August 4, 2026, the safe migration completed the accepted 10.24.0.73019 and 11.12.1 matrix: source and rebuilt projects returned zero MxBuild errors and both reports set `frontend_ready` to `true`. +`script/frontend_lifecycle_acceptance` creates an app exclusively through the +CLI, generates its MPR, exports it to Ruby, changes Home and navigation, adds +an entity, form, microflow, nanoflow, and asset, regenerates, exports again, +and requires a structurally identical rebuild. On the official 10.24.0.73019 +and 11.12.1 matrix, source and rebuilt projects completed with zero errors, +warnings, deprecations, or recommendations in `mx check`, plus zero MxBuild +errors. + `--mx` runs the official read-only checker with warnings, deprecations, and best-practice recommendations enabled. It validates the checker's exit bitmask and compares normalized source/rebuilt signatures. The accepted 10.24 fixture diff --git a/docs/pt-BR/platform-operations.md b/docs/pt-BR/platform-operations.md index ec20e19..8bc51e9 100644 --- a/docs/pt-BR/platform-operations.md +++ b/docs/pt-BR/platform-operations.md @@ -27,6 +27,8 @@ mxrb frontend migrate App.mpr --apply --json script/frontend_acceptance App.mpr -o frontend-round-trip.json script/frontend_acceptance App.mpr --mxbuild /caminho/mxbuild -o frontend.json script/frontend_acceptance App.mpr --mx /caminho/mx -o frontend-diagnostics.json +script/frontend_lifecycle_acceptance --version 11.12.1 --mx /caminho/mx \ + --mxbuild /caminho/mxbuild --strict-warnings -o frontend-lifecycle.json ``` `mxrb frontend migrate` é uma prévia imutável e fail-closed por padrão. Para @@ -58,6 +60,13 @@ Em 4 de agosto de 2026, a migração segura concluiu a matriz aceita 10.24.0.730 11.12.1: origem e reconstrução retornaram zero erros no MxBuild e os dois relatórios marcaram `frontend_ready` como `true`. +`script/frontend_lifecycle_acceptance` cria um app exclusivamente pela CLI, +gera o MPR, exporta-o para Ruby, altera a Home e a navegação, adiciona entidade, +formulário, microflow, nanoflow e asset, gera novamente, exporta uma segunda vez +e exige reconstrução estruturalmente idêntica. Na matriz oficial 10.24.0.73019 +e 11.12.1, origem e reconstrução concluíram com zero erros, warnings, +depreciações e recomendações no `mx check`, além de zero erros no MxBuild. + `--mx` executa o checker oficial somente leitura com warnings, depreciações e recomendações de boas práticas. O gate valida o bitmask de saída e compara as assinaturas normalizadas de origem e reconstrução. A fixture 10.24 aceita tem diff --git a/lib/mxrb/scaffold/generator.rb b/lib/mxrb/scaffold/generator.rb index 659b032..52d5e9c 100644 --- a/lib/mxrb/scaffold/generator.rb +++ b/lib/mxrb/scaffold/generator.rb @@ -10,6 +10,12 @@ class Generator Result = Data.define(:root, :files, :updated, :kind, :name, :dry_run) IDENTIFIER = /\A[A-Za-z][A-Za-z0-9_]*\z/ RESERVED_ENTITY_NAMES = %w[Owner ChangedBy CreatedDate ChangedDate].freeze + LAYER_AGGREGATORS = { + 'domain' => 'model.rb', + 'application' => 'application.rb', + 'presentation' => 'presentation.rb', + 'infrastructure' => 'infrastructure.rb' + }.freeze def initialize(kind, name = nil, target: Dir.pwd, dry_run: false) @kind = kind.to_sym @@ -58,19 +64,20 @@ def ensure_infrastructure(module_name) connect_module_aggregator(module_name, 'infrastructure', 'infrastructure.rb') end - def ensure_application_directory(module_name, directory) - ensure_module(module_name) - ensure_evaluate_dir(module_path(module_name, 'application', 'application.rb'), directory) - end + def connect_artifact(aggregator, *segments) + source = @transaction.content(aggregator) + raise ArgumentError, "#{aggregator}: aggregator not found" unless source - def ensure_evaluate_dir(path, directory) - source = @transaction.content(path) - raise ArgumentError, "#{path}: aggregator not found" unless source - - line = %(evaluate_dir File.join(__dir__, "#{directory}")) + line = artifact_evaluation(source, segments) return if source.include?(line) - @transaction.write(path, "#{source.rstrip}\n#{line}\n") + @transaction.write(aggregator, "#{source.rstrip}\n#{line}\n") + end + + def artifact_evaluation(source, segments) + return evaluate_line(segments) unless source.match?(/^\s*evaluate_dir File\.join\(__dir__, /) + + %(evaluate_dir File.join(__dir__, "#{segments.fetch(0)}")) end def connect_module_aggregator(module_name, *segments) @@ -116,6 +123,17 @@ def create_module_file(module_name, directories, artifact_name, template, suffix filename = "#{Templates.snake_case(artifact_name)}#{suffix}.rb" path = module_path(module_name, *directories, filename) @transaction.create(path, Templates.render(template, module_name:, name: artifact_name)) + filename + end + + def create_connected_module_file(module_name, directories, artifact_name, template, suffix: nil) + filename = create_module_file( + module_name, directories, artifact_name, template, suffix: + ) + layer, *relative = directories + aggregator = module_path(module_name, layer, LAYER_AGGREGATORS.fetch(layer)) + connect_artifact(aggregator, *relative, filename) + filename end def create_project_file(directories, artifact_name, content) diff --git a/lib/mxrb/scaffold/recipes.rb b/lib/mxrb/scaffold/recipes.rb index ab8973e..2f682aa 100644 --- a/lib/mxrb/scaffold/recipes.rb +++ b/lib/mxrb/scaffold/recipes.rb @@ -25,28 +25,34 @@ def scaffold_query = scaffold_simple(:query) def scaffold_constant module_name, artifact_name = qualified_name - create_module_file(module_name, SIMPLE.fetch(:constant), artifact_name, :constant) - ensure_evaluate_dir(module_path(module_name, 'domain', 'model.rb'), 'constants') + create_connected_module_file( + module_name, SIMPLE.fetch(:constant), artifact_name, :constant + ) end def scaffold_page module_name, artifact_name = qualified_name ensure_presentation(module_name) - create_module_file(module_name, %w[presentation pages], artifact_name, :page) + create_connected_module_file( + module_name, %w[presentation pages], artifact_name, :page + ) end def scaffold_nanoflow module_name, artifact_name = qualified_name ensure_presentation(module_name) - create_module_file(module_name, %w[presentation client_actions], artifact_name, :nanoflow) + create_connected_module_file( + module_name, %w[presentation client_actions], artifact_name, :nanoflow + ) end def scaffold_repository module_name, artifact_name = qualified_name - ensure_application_directory(module_name, 'repositories') ensure_infrastructure(module_name) - create_module_file(module_name, %w[application repositories], artifact_name, :repository) - create_module_file( + create_connected_module_file( + module_name, %w[application repositories], artifact_name, :repository + ) + create_connected_module_file( module_name, %w[infrastructure repositories], artifact_name, :repository_implementation, suffix: '_implementation' ) @@ -54,8 +60,9 @@ def scaffold_repository def scaffold_scheduled_event module_name, artifact_name = qualified_name - ensure_application_directory(module_name, 'jobs') - create_module_file(module_name, %w[application jobs], artifact_name, :scheduled_event) + create_connected_module_file( + module_name, %w[application jobs], artifact_name, :scheduled_event + ) end def scaffold_integration = scaffold_infrastructure_artifact(:integration) @@ -134,13 +141,13 @@ def scaffold_ci def scaffold_simple(kind) module_name, artifact_name = qualified_name - create_module_file(module_name, SIMPLE.fetch(kind), artifact_name, kind) + create_connected_module_file(module_name, SIMPLE.fetch(kind), artifact_name, kind) end def scaffold_infrastructure_artifact(kind) module_name, artifact_name = qualified_name ensure_infrastructure(module_name) - create_module_file( + create_connected_module_file( module_name, ['infrastructure', INFRASTRUCTURE.fetch(kind)], artifact_name, kind ) end diff --git a/lib/mxrb/writer.rb b/lib/mxrb/writer.rb index c7b2146..a215b24 100644 --- a/lib/mxrb/writer.rb +++ b/lib/mxrb/writer.rb @@ -295,6 +295,13 @@ def sanitize_project_settings!(document) %w[AfterStartupMicroflow BeforeShutdownMicroflow HealthCheckMicroflow].each do |key| setting[key] = '' if setting.key?(key) end + when 'Settings$LanguageSettings' + default = setting['DefaultLanguageCode'].to_s + array_items(setting['Languages']).each do |language| + next if language['Code'].to_s == default + + language['CheckCompleteness'] = false + end end end document @@ -759,7 +766,9 @@ def write_documents(mpr, module_id, mod) existing = documents_by_name(mpr, module_id) mod.fetch(:pages).each do |page| - upsert_document(mpr, module_id, existing[page.fetch(:name)], page_doc(page)) + upsert_document( + mpr, module_id, existing[page.fetch(:name)], page_doc(page, mod.fetch(:name)) + ) end mod.fetch(:microflows).each do |flow| upsert_document( @@ -1670,7 +1679,7 @@ def lifecycle_doc(callback) } end - def page_doc(page) + def page_doc(page, module_name = nil) if page[:deep_structure].is_a?(Hash) return page[:deep_structure].merge( "$ID" => SecureRandom.uuid, @@ -1680,7 +1689,8 @@ def page_doc(page) ) end - widgets = page.fetch(:widgets, []).map { widget_doc(_1) } + context_entity = page_context_entity(page, module_name) + widgets = page.fetch(:widgets, []).map { widget_doc(_1, context_entity:) } # Backwards-compatible page-level bindings target widgets by name. page.fetch(:events, []).each do |event| next unless event[:target] @@ -1997,8 +2007,10 @@ def no_action_doc(disabled: false) } end - def widget_doc(widget) + def widget_doc(widget = nil, context_entity: nil, **keyword_widget) + widget ||= keyword_widget type = widget.fetch(:type) + widget = qualify_page_widget_attribute(widget, context_entity) if type == :snippet return snippet_call_doc(widget) @@ -2044,7 +2056,7 @@ def widget_doc(widget) end if type == :container - children = Array(widget[:children]).map { widget_doc(_1) } + children = Array(widget[:children]).map { widget_doc(_1, context_entity:) } doc["Widgets"] = IO::BsonCodec.build_array(children) doc["Class"] = options[:class].to_s if options[:class] end @@ -2055,6 +2067,74 @@ def widget_doc(widget) doc end + def qualify_page_widget_attribute(widget, context_entity) + type = widget.fetch(:type).to_sym + return widget unless input_widget?(type) || type == :drop_down + + attribute = widget.dig(:options, :attribute) + return widget unless attribute + + qualified = qualified_attribute_identifier(attribute, context_entity) + options = widget.fetch(:options, {}).merge(attribute: qualified) + widget.merge(options:) + end + + def page_context_entity(page, module_name) + source = page[:data_source] + entity = flow_return_entity(source&.dig(:name), module_name) + return entity if entity + + attributes = simple_page_attributes(page.fetch(:widgets, [])) + return if attributes.empty? + + matching = module_entities(module_name).select do |candidate| + names = candidate.fetch(:attributes, []).map { _1.fetch(:name).to_s } + (attributes - names).empty? + end + return unless matching.one? + + "#{module_name}.#{matching.first.fetch(:name)}" + end + + def simple_page_attributes(widgets) + Array(widgets).flat_map do |widget| + type = widget.fetch(:type).to_sym + attribute = widget.dig(:options, :attribute).to_s + own = if (input_widget?(type) || type == :drop_down) && + !attribute.empty? && !attribute.include?('.') && !attribute.include?('/') + [attribute] + else + [] + end + own + simple_page_attributes(widget[:children]) + end.uniq + end + + def flow_return_entity(name, module_name) + return if name.to_s.empty? + + owner_name, flow_name = qualified_artifact_parts(name, module_name) + owner = @definition.fetch(:modules, []).find { _1.fetch(:name).to_s == owner_name } + flow = Array(owner&.fetch(:microflows, [])).find { _1.fetch(:name).to_s == flow_name } + return unless flow + + entity = object_entity_type(flow[:return_type], owner_name) + return entity if entity + + variables = flow_variable_entities(flow[:body], flow[:parameters], owner_name) + variables[flow[:return_variable_name].to_s] + end + + def qualified_artifact_parts(name, module_name) + value = name.to_s + value.include?('.') ? value.split('.', 2) : [module_name.to_s, value] + end + + def module_entities(module_name) + mod = @definition.fetch(:modules, []).find { _1.fetch(:name).to_s == module_name.to_s } + Array(mod&.fetch(:entities, [])) + end + def deep_copy(value) case value when Hash then value.to_h { |key, item| [key, deep_copy(item)] } @@ -2232,15 +2312,43 @@ def editable_widget_properties(options) } end - def attribute_ref_doc(attribute) - return nil unless attribute + def attribute_ref_doc(attribute, entity: nil) + identifier = qualified_attribute_identifier(attribute, entity) + return nil unless identifier { "$ID" => SecureRandom.uuid, "$Type" => "DomainModels$AttributeRef", - "Attribute" => attribute.to_s, "EntityRef" => nil + "Attribute" => identifier, "EntityRef" => nil } end + def qualified_attribute_identifier(attribute, entity = nil) + value = attribute.to_s + return if value.empty? + + value = value.tr('/', '.') + return value if value.split('.').size >= 3 + return unless entity + + entity_name = qualified_entity_name(entity) + return unless entity_name + if value.include?('.') && value.split('.').first == entity_name.split('.').last + return "#{entity_name.split('.').first}.#{value}" + end + return if value.include?('.') + + "#{entity_name}.#{value}" + end + + def qualified_entity_name(entity, module_name = nil) + value = entity.to_s + return if value.empty? + return value if value.include?('.') + return unless module_name + + "#{module_name}.#{value}" + end + def formatting_info_doc { "$ID" => SecureRandom.uuid, "$Type" => "Forms$FormattingInfo", @@ -2404,8 +2512,9 @@ def microflow_doc(flow, module_name = nil) mark_as_used = flow[:mark_as_used] excluded = flow[:excluded] + body = qualify_flow_member_references(flow[:body], flow[:parameters], module_name) graph = build_microflow_graph( - flow[:body], flow[:return_expression] || flow[:return_variable_name] + body, flow[:return_expression] || flow[:return_variable_name] ) object_collection = { "$ID" => stable_id(flow_name, "object_collection"), @@ -2456,6 +2565,95 @@ def microflow_data_type_doc(type, module_name) doc end + def qualify_flow_member_references(body, parameters, module_name) + variables = parameter_entity_types(parameters, module_name) + qualify_activity_members(Array(body), variables, module_name).first + end + + def flow_variable_entities(body, parameters, module_name) + variables = parameter_entity_types(parameters, module_name) + qualify_activity_members(Array(body), variables, module_name).last + end + + def parameter_entity_types(parameters, module_name) + Array(parameters).each_with_object({}) do |parameter, result| + entity = object_entity_type(parameter[:type], module_name) + result[parameter.fetch(:name).to_s] = entity if entity + end + end + + def object_entity_type(type, module_name) + if type.is_a?(Hash) + native = type['$Type'] || type[:'$Type'] + return unless native == 'DataTypes$ObjectType' + + return qualified_entity_name(type['Entity'] || type[:Entity], module_name) + end + + value = type.to_s + primitive = %w[ + void nil boolean bool string integer long decimal float datetime date_time + ] + return if value.empty? || primitive.include?(value.downcase) || value.include?('$') + + qualified_entity_name(value, module_name) + end + + def qualify_activity_members(activities, inherited_variables, module_name) + variables = inherited_variables.dup + qualified = Array(activities).map do |activity| + item = activity.dup + type = item.fetch(:type).to_sym + case type + when :create_object, :retrieve_objects + entity = qualified_entity_name(item[:entity], module_name) + item[:entity] = entity if entity + variables[item[:variable].to_s] = entity if entity + when :retrieve_association + entity = association_destination(item[:association]) + variables[item[:variable].to_s] = entity unless entity.to_s.empty? + when :change_object + item[:entity] ||= variables[item[:variable].to_s] + when :decision + item = qualify_decision_activity(item, variables, module_name) + when :inheritance_decision + item[:branches] = item.fetch(:branches).transform_values do |branch| + qualify_activity_members(branch, variables, module_name).first + end + when :loop_over + loop_variables = variables.dup + iterator_entity = variables[item[:variable].to_s] + loop_variables[item[:iterator].to_s] = iterator_entity if iterator_entity + item[:activities] = qualify_activity_members( + item[:activities], loop_variables, module_name + ).first + when :while_loop, :rescue_all + item[:activities] = qualify_activity_members( + item[:activities], variables, module_name + ).first + end + item + end + [qualified, variables] + end + + def qualify_decision_activity(activity, variables, module_name) + item = activity.dup + if item[:branches] + item[:branches] = item.fetch(:branches).transform_values do |branch| + qualify_activity_members(branch, variables, module_name).first + end + else + item[:true_branch] = qualify_activity_members( + item[:true_branch], variables, module_name + ).first + item[:false_branch] = qualify_activity_members( + item[:false_branch], variables, module_name + ).first + end + item + end + def build_microflow_graph(body, return_expression) objects = [] flows = [] @@ -2895,7 +3093,9 @@ def activity_action_doc(activity) "Commit" => commit, "ErrorHandlingType" => "Rollback", "Items" => IO::BsonCodec.build_array( - Array(activity[:members]).map { change_action_item_doc(_1) }, marker: 2 + Array(activity[:members]).map do |member| + change_action_item_doc(member, entity: activity[:entity]) + end, marker: 2 ), "RefreshInClient" => activity[:refresh] == true } when :retrieve_objects @@ -3463,18 +3663,37 @@ def variable_type_doc(type) end def change_action_item_doc(member, entity: nil) - attribute = member[:attribute].to_s - if entity && !attribute.empty? && !attribute.include?('.') && !attribute.include?('/') - attribute = "#{entity}.#{attribute}" + association = qualified_association_identifier(member[:association], entity) + attribute = qualified_attribute_identifier(member[:attribute], entity) + if member[:association] && !association + raise SerializationError, + "cannot qualify association #{member[:association].inspect} without an object entity" + end + if member[:attribute] && !attribute + raise SerializationError, + "cannot qualify attribute #{member[:attribute].inspect} without an object entity" end { "$ID" => SecureRandom.uuid, "$Type" => "Microflows$ChangeActionItem", - "Association" => member[:association].to_s, - "Attribute" => attribute, + "Association" => association.to_s, + "Attribute" => attribute.to_s, "Type" => mendix_enum(member[:operation] || "Set"), "Value" => member_value_expr(member[:value]), "ValueModel" => no_expression_doc } end + def qualified_association_identifier(association, entity = nil) + value = association.to_s + return if value.empty? + + value = value.tr('/', '.') + return value if value.include?('.') + + entity_name = qualified_entity_name(entity) + return unless entity_name + + "#{entity_name.split('.').first}.#{value}" + end + def member_value_expr(value) case value when Symbol then "$#{value}" @@ -3486,13 +3705,26 @@ def member_value_expr(value) end def nanoflow_doc(flow) - microflow_doc(flow).merge( + doc = microflow_doc(flow) + normalize_nanoflow_error_handling!(doc) + doc.merge( "$Type" => "Microflows$Nanoflow", "AllowConcurrentExecution" => nil, "UseListParameterByReference" => true ).compact end + def normalize_nanoflow_error_handling!(value) + case value + when Hash + value["ErrorHandlingType"] = "Abort" if value["ErrorHandlingType"] == "Rollback" + value.each_value { normalize_nanoflow_error_handling!(_1) } + when Array + value.each { normalize_nanoflow_error_handling!(_1) } + end + value + end + def text_doc(text) { "$ID" => SecureRandom.uuid, "$Type" => "Texts$Text", "Items" => IO::BsonCodec.build_array([ diff --git a/script/frontend_lifecycle_acceptance b/script/frontend_lifecycle_acceptance new file mode 100755 index 0000000..d370b67 --- /dev/null +++ b/script/frontend_lifecycle_acceptance @@ -0,0 +1,423 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# rubocop:disable Metrics + +require 'digest' +require 'fileutils' +require 'json' +require 'open3' +require 'optparse' +require 'rbconfig' +require 'tmpdir' + +# Reproducible, mutation-aware frontend lifecycle acceptance. Every operation +# which creates, reads, validates, exports, or compares an MPR is performed by +# the public mxrb CLI. Ruby file edits between export and generate represent the +# normal user workflow; official Mendix tools are optional read-only oracles. +module MxrbFrontendLifecycleAcceptance + class Failure < StandardError; end + + PROJECT_NAME = 'frontend_cycle' + MODULE_NAME = 'FrontendCycle' + MPR_NAME = 'FrontendCycle.mpr' + + # Executes and verifies one isolated lifecycle scenario. + class Runner + attr_reader :project_root + + def initialize(workspace:, version: '11.12.1', mxrb_root: File.expand_path('..', __dir__), + mxcheck: nil, mxbuild: nil, strict_warnings: false) + @workspace = File.expand_path(workspace) + @version = version.to_s + @mxrb_root = File.expand_path(mxrb_root) + @mxcheck = mxcheck + @mxbuild = mxbuild + @strict_warnings = strict_warnings + @commands = [] + @gates = {} + @project_root = File.join(@workspace, PROJECT_NAME) + @executable = File.join(@mxrb_root, 'bin', 'mxrb') + @acceptance = File.join(@mxrb_root, 'script', 'frontend_acceptance') + end + + def run + validate_inputs! + FileUtils.mkdir_p(@workspace) + build_baseline + export_and_mutate + build_mutated + final_round_trip + run_optional_oracles + report(true) + rescue StandardError => e + report(false, error: e.message) + end + + private + + def validate_inputs! + raise Failure, "mxrb executable is missing: #{@executable}" unless File.file?(@executable) + raise Failure, "workspace project already exists: #{@project_root}" if File.exist?(@project_root) + raise Failure, "unsupported lifecycle version: #{@version}" unless %w[10 11].include?(@version.split('.').first) + end + + def build_baseline + cli('init', PROJECT_NAME, '--mxrb-path', @mxrb_root, chdir: @workspace) + project = File.join(@project_root, 'project.rb') + replace_once(project, 'mendix_version "11.12.1"', "mendix_version #{@version.inspect}") + + @baseline = File.join(@project_root, MPR_NAME) + cli('generate', 'project.rb', chdir: @project_root) + cli('validate', @baseline, chdir: @project_root) + @baseline_preflight = preflight(@baseline, chdir: @project_root) + assert_counts(@baseline_preflight, pages: 1, layouts: 1, microflows: 0, nanoflows: 0) + @gates[:ruby_zero_to_mpr] = true + end + + def export_and_mutate + @ruby_export = File.join(@workspace, 'ruby-export') + cli('export', @baseline, @ruby_export, chdir: @project_root) + scaffold('entity', 'new', "#{MODULE_NAME}.Order") + scaffold('page', 'new', "#{MODULE_NAME}.Orders") + scaffold('use-case', 'new', "#{MODULE_NAME}.ACT_CreateOrder") + scaffold('nanoflow', 'new', "#{MODULE_NAME}.NAN_RefreshOrders") + write_order_entity + write_mutated_page + write_mutated_flows + mutate_home + mutate_navigation + add_asset + @gates[:mpr_to_editable_ruby] = true + @gates[:cli_scaffolds] = true + end + + def build_mutated + @mutated_root = File.join(@workspace, 'mutated') + FileUtils.mkdir_p(@mutated_root) + @mutated = File.join(@mutated_root, MPR_NAME) + cli('generate', 'project.rb', @mutated, chdir: @ruby_export) + cli('validate', @mutated, chdir: @mutated_root) + @mutated_preflight = preflight(@mutated, chdir: @mutated_root) + assert_counts(@mutated_preflight, pages: 2, layouts: 1, microflows: 1, nanoflows: 1) + + diff = cli('diff', @baseline, @mutated, chdir: @workspace, statuses: [1]).fetch(:stdout) + %w[Order Orders ACT_CreateOrder NAN_RefreshOrders].each do |name| + raise Failure, "semantic diff does not contain #{name}" unless diff.include?(name) + end + raise Failure, 'semantic diff did not record additions' unless diff.include?('added') + + assert_planned_diff(diff) + + @diff = diff.lines.map(&:strip).reject(&:empty?) + assert_asset(@mutated_root) + @gates[:planned_mutations_materialized] = true + @gates[:mutated_preflight] = true + end + + def final_round_trip + @final_ruby = File.join(@workspace, 'final-ruby') + @final_root = File.join(@workspace, 'final') + @final_mpr = File.join(@final_root, MPR_NAME) + FileUtils.mkdir_p(@final_root) + cli('export', @mutated, @final_ruby, chdir: @mutated_root) + cli('generate', 'project.rb', @final_mpr, chdir: @final_ruby) + cli('validate', @final_mpr, chdir: @final_root) + @final_preflight = preflight(@final_mpr, chdir: @final_root) + raise Failure, 'final preflight differs from mutated preflight' unless + @final_preflight.fetch('stats') == @mutated_preflight.fetch('stats') + + cli('compare', @mutated, @final_mpr, chdir: @workspace) + assert_exported_sources + assert_asset(@final_root) + @gates[:ruby_mutation_to_mpr] = true + @gates[:final_structural_round_trip] = true + @gates[:assets_added_and_preserved] = true + end + + def run_optional_oracles + @oracles = {} + run_oracle(:mx, @mxcheck) if @mxcheck + run_oracle(:mxbuild, @mxbuild) if @mxbuild + end + + def run_oracle(kind, executable) + output = File.join(@workspace, "#{kind}-frontend.json") + arguments = [@final_mpr, "--#{kind}", executable, '-o', output] + # Successful MxBuild versions may omit warning JSON entirely. Strict + # warning enforcement belongs to the observable `mx check` oracle. + arguments << '--strict-warnings' if @strict_warnings && kind == :mx + command(@acceptance, *arguments, chdir: @final_root) + payload = JSON.parse(File.read(output)) + raise Failure, "#{kind} frontend oracle did not pass" unless payload['passed'] + + @oracles[kind] = payload.fetch('oracle') + @gates["#{kind}_oracle".to_sym] = true + end + + def scaffold(*arguments) + cli(*arguments, chdir: @ruby_export) + end + + def preflight(mpr, chdir:) + result = cli('preflight', mpr, '--json', chdir:) + payload = JSON.parse(result.fetch(:stdout)) + raise Failure, "preflight is not compatible: #{mpr}" unless payload['compatible'] + raise Failure, "preflight contains findings: #{mpr}" unless payload.fetch('findings').empty? + + payload + end + + def assert_counts(payload, expected) + stats = payload.fetch('stats') + expected.each do |name, count| + actual = stats.fetch(name.to_s) + raise Failure, "expected #{count} #{name}, got #{actual}" unless actual == count + end + end + + def assert_planned_diff(diff) + allowed = [ + 'design_assets.theme/web/frontend-lifecycle.css', + "modules.#{MODULE_NAME}.entities.Order", + "modules.#{MODULE_NAME}.microflows.ACT_CreateOrder", + "modules.#{MODULE_NAME}.nanoflows.NAN_RefreshOrders", + "modules.#{MODULE_NAME}.pages.Home.widgets.pageHeader.children.pageTitle.options.caption", + "modules.#{MODULE_NAME}.pages.Orders", + 'navigation.profiles.Responsive.app_title.en_US', + 'navigation.profiles.Responsive.items.0' + ] + unexpected = diff.lines.filter_map do |line| + _operation, path, = line.split("\t", 3) + next if path&.start_with?('units.') || allowed.include?(path) + + path + end + raise Failure, "semantic diff contains unexpected paths: #{unexpected.inspect}" unless unexpected.empty? + end + + def write_order_entity + path = File.join(@ruby_export, 'modules', MODULE_NAME, 'domain', 'entities', 'order.rb') + File.write(path, <<~RUBY) + # frozen_string_literal: true + + entity :Order do + string :Reference, length: 80 + decimal :Total + boolean :Active, default: true + end + RUBY + end + + def write_mutated_page + path = File.join(@ruby_export, 'modules', MODULE_NAME, 'presentation', 'pages', 'orders.rb') + File.write(path, <<~RUBY) + # frozen_string_literal: true + + page :Orders do + title "Orders" + layout "#{MODULE_NAME}.ApplicationLayout" + data_source microflow: "#{MODULE_NAME}.ACT_CreateOrder" + + container :pageHeader, class_name: "mxrb-page-header" do + text :pageTitle, caption: "Orders created from exported Ruby" + end + + container :orderForm, class_name: "mxrb-card" do + text_box :reference, attribute: :Reference, caption: "Reference" + text_box :total, attribute: :Total, caption: "Total" + check_box :active, attribute: :Active, caption: "Active" + + button :saveOrder, caption: "Save order" do + on_click action: :save_changes + end + + button :cancelOrder, caption: "Cancel" do + on_click action: :cancel_changes + end + end + + button :refreshOrders, caption: "Refresh orders" do + on_click nanoflow: "#{MODULE_NAME}.NAN_RefreshOrders" + end + end + RUBY + end + + def write_mutated_flows + microflow = File.join( + @ruby_export, 'modules', MODULE_NAME, 'application', 'use_cases', 'act_create_order.rb' + ) + File.write(microflow, <<~RUBY) + # frozen_string_literal: true + + microflow :ACT_CreateOrder do + mark_as_used + return_type :Order + create_object "#{MODULE_NAME}.Order", as: :order, + set: { Reference: "'NEW'", Total: 0, Active: true } + commit :order + return_value :order + end + RUBY + + nanoflow = File.join( + @ruby_export, 'modules', MODULE_NAME, 'presentation', 'client_actions', + 'nan_refresh_orders.rb' + ) + File.write(nanoflow, <<~RUBY) + # frozen_string_literal: true + + nanoflow :NAN_RefreshOrders do + mark_as_used + show_message "Orders refreshed", type: :information + end + RUBY + end + + def mutate_home + path = File.join(@ruby_export, 'modules', MODULE_NAME, 'presentation', 'pages', 'home.rb') + replace_once(path, 'title "FrontendCycle"', 'title "Frontend lifecycle"') + replace_once( + path, '"Text" => "FrontendCycle"', '"Text" => "Frontend lifecycle verified"' + ) + end + + def mutate_navigation + path = File.join(@ruby_export, 'app', 'navigation', 'navigation.rb') + replace_once( + path, + 'title :en_US, "FrontendCycle"', + "title :en_US, \"Frontend lifecycle\"\n item \"Orders\", page: \"FrontendCycle.Orders\"" + ) + end + + def add_asset + relative = 'theme/web/frontend-lifecycle.css' + path = File.join(@ruby_export, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, ".frontend-lifecycle { display: block; }\n") + manifest_path = File.join(@ruby_export, '.mxrb', 'assets.json') + manifest = JSON.parse(File.read(manifest_path)) + manifest.fetch('files') << { + 'path' => relative, 'size' => File.size(path), 'sha256' => Digest::SHA256.file(path).hexdigest + } + File.write(manifest_path, "#{JSON.pretty_generate(manifest)}\n") + @asset = manifest.fetch('files').last + end + + def assert_exported_sources + expectations = { + File.join(@final_ruby, 'modules', MODULE_NAME, 'presentation', 'pages', 'home.rb') => + 'Frontend lifecycle verified', + File.join(@final_ruby, 'modules', MODULE_NAME, 'presentation', 'pages', 'orders.rb') => + '"Attribute" => "FrontendCycle.Order.Reference"', + File.join(@final_ruby, 'modules', MODULE_NAME, 'domain', 'entities', 'order.rb') => + 'entity :Order', + File.join(@final_ruby, 'modules', MODULE_NAME, 'application', 'use_cases', 'act_create_order.rb') => + 'microflow :ACT_CreateOrder', + File.join(@final_ruby, 'modules', MODULE_NAME, 'presentation', 'client_actions', + 'nan_refresh_orders.rb') => 'nanoflow :NAN_RefreshOrders', + File.join(@final_ruby, 'app', 'navigation', 'navigation.rb') => + 'item "Orders", page: "FrontendCycle.Orders"' + } + expectations.each do |path, text| + raise Failure, "final Ruby export is missing #{text.inspect}" unless File.read(path).include?(text) + end + end + + def assert_asset(root) + path = File.join(root, @asset.fetch('path')) + raise Failure, "lifecycle asset is missing: #{path}" unless File.file?(path) + raise Failure, "lifecycle asset size changed: #{path}" unless File.size(path) == @asset.fetch('size') + return if Digest::SHA256.file(path).hexdigest == @asset.fetch('sha256') + + raise Failure, "lifecycle asset checksum changed: #{path}" + end + + def replace_once(path, before, after) + content = File.read(path) + raise Failure, "mutation anchor not found in #{path}: #{before.inspect}" unless content.include?(before) + + File.write(path, content.sub(before, after)) + end + + def cli(*arguments, chdir:, statuses: [0]) + command(@executable, *arguments, chdir:, statuses:) + end + + def command(executable, *arguments, chdir:, statuses: [0]) + argv = [RbConfig.ruby, "-I#{File.join(@mxrb_root, 'lib')}", executable, *arguments] + stdout, stderr, status = Open3.capture3(*argv, chdir:) + entry = { + command: [File.basename(executable), *arguments], chdir:, status: status.exitstatus, + stdout:, stderr: + } + @commands << entry + return entry if statuses.include?(status.exitstatus) + + detail = [stdout, stderr].reject(&:empty?).join("\n").strip + raise Failure, "#{entry[:command].join(' ')} exited #{status.exitstatus}: #{detail}" + end + + def report(passed, error: nil) + { + passed:, scenario: 'frontend_cli_lifecycle', mendix_version: @version, + workspace: @workspace, project_root: @project_root, + gates: @gates, preflight: { + baseline: @baseline_preflight, mutated: @mutated_preflight, final: @final_preflight + }.compact, + mutations: { + entity: "#{MODULE_NAME}.Order", page: "#{MODULE_NAME}.Orders", + microflow: "#{MODULE_NAME}.ACT_CreateOrder", + nanoflow: "#{MODULE_NAME}.NAN_RefreshOrders", + home_text: 'Frontend lifecycle verified', asset: @asset, diff: @diff + }.compact, + oracles: @oracles || {}, commands: @commands, error: + }.compact + end + end + + # Parses the executable boundary and always emits JSON evidence. + module CLI + module_function + + def run(argv) + options = { version: '11.12.1', strict_warnings: false } + workspace = nil + output = nil + parser = OptionParser.new do |cli| + cli.banner = 'Usage: script/frontend_lifecycle_acceptance [options]' + cli.on('--version VERSION', 'Mendix 10/11 target version') { options[:version] = _1 } + cli.on('--workspace DIR', 'Keep the scenario below DIR/frontend_cycle') { workspace = _1 } + cli.on('--mx FILE', 'Use mx check as an optional read-only oracle') { options[:mxcheck] = _1 } + cli.on('--mxbuild FILE', 'Use MxBuild as an optional read-only oracle') { options[:mxbuild] = _1 } + cli.on('--strict-warnings', 'Require zero observable warning-class diagnostics') do + options[:strict_warnings] = true + end + cli.on('-o', '--output FILE', 'Write JSON evidence to FILE') { output = _1 } + end + parser.parse!(argv) + raise OptionParser::InvalidArgument, argv.join(' ') unless argv.empty? + + report = if workspace + Runner.new(workspace:, **options).run + else + Dir.mktmpdir('mxrb-frontend-lifecycle-') do |temporary| + Runner.new(workspace: temporary, **options).run + end + end + json = "#{JSON.pretty_generate(report)}\n" + output ? File.write(output, json) : puts(json) + report.fetch(:passed) ? 0 : 1 + rescue OptionParser::ParseError => e + payload = { passed: false, error: e.message } + output ? File.write(output, "#{JSON.pretty_generate(payload)}\n") : warn(JSON.pretty_generate(payload)) + 1 + end + end +end + +exit MxrbFrontendLifecycleAcceptance::CLI.run(ARGV) if $PROGRAM_NAME == __FILE__ + +# rubocop:enable Metrics diff --git a/spec/frontend_lifecycle_acceptance_spec.rb b/spec/frontend_lifecycle_acceptance_spec.rb new file mode 100644 index 0000000..167f294 --- /dev/null +++ b/spec/frontend_lifecycle_acceptance_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +load File.expand_path('../script/frontend_lifecycle_acceptance', __dir__) + +# rubocop:disable Metrics/BlockLength +RSpec.describe MxrbFrontendLifecycleAcceptance do + it 'proves the complete CLI-only Ruby/MPR mutation lifecycle on Mendix 10 and 11' do + Dir.mktmpdir do |root| + %w[10.24.0.73019 11.12.1].each do |version| + workspace = File.join(root, version) + report = described_class::Runner.new(workspace:, version:).run + + expect(report).to include( + passed: true, scenario: 'frontend_cli_lifecycle', mendix_version: version + ) + expect(report).not_to have_key(:error) + expect(report.fetch(:gates).values).to all(be(true)) + expect(report.dig(:preflight, :baseline, 'stats')).to include( + 'pages' => 1, 'layouts' => 1, 'microflows' => 0, 'nanoflows' => 0 + ) + expect(report.dig(:preflight, :final, 'stats')).to include( + 'pages' => 2, 'layouts' => 1, 'microflows' => 1, 'nanoflows' => 1 + ) + expect(report.dig(:mutations, :diff).join("\n")).to include( + 'Order', 'Orders', 'ACT_CreateOrder', 'NAN_RefreshOrders' + ) + expect(report.fetch(:oracles)).to eq({}) + expect(report.fetch(:commands).map { _1.fetch(:command).first }.uniq).to eq(['mxrb']) + + project_root = report.fetch(:project_root) + final_root = File.join(workspace, 'final') + final_ruby = File.join(workspace, 'final-ruby') + expect(File).to exist(File.join(project_root, 'FrontendCycle.mpr')) + expect(File).to exist(File.join(final_root, 'FrontendCycle.mpr')) + expect(File.read(File.join(final_ruby, 'app', 'navigation', 'navigation.rb'))) + .to include('item "Orders", page: "FrontendCycle.Orders"') + expect(File.read(File.join(final_ruby, 'modules', 'FrontendCycle', 'domain', + 'entities', 'order.rb'))) + .to include('entity :Order', 'string :Reference', 'decimal :Total', 'boolean :Active') + expect(File.read(File.join(final_ruby, 'modules', 'FrontendCycle', 'presentation', + 'pages', 'orders.rb'))) + .to include( + '"Attribute" => "FrontendCycle.Order.Reference"', + '"Attribute" => "FrontendCycle.Order.Total"', + '"Attribute" => "FrontendCycle.Order.Active"' + ) + expect(File.read(File.join(final_ruby, 'theme', 'web', 'frontend-lifecycle.css'))) + .to include('display: block') + end + end + end + + it 'fails closed for unsupported versions and an existing scenario project' do + Dir.mktmpdir do |root| + unsupported = described_class::Runner.new(workspace: File.join(root, 'old'), version: '9.6.1').run + expect(unsupported).to include(passed: false) + expect(unsupported.fetch(:error)).to include('unsupported lifecycle version') + + workspace = File.join(root, 'occupied') + FileUtils.mkdir_p(File.join(workspace, described_class::PROJECT_NAME)) + occupied = described_class::Runner.new(workspace:).run + expect(occupied).to include(passed: false) + expect(occupied.fetch(:error)).to include('workspace project already exists') + end + end + + it 'aggregates configurable mx check and MxBuild read-only oracles' do + Dir.mktmpdir do |root| + mx = File.join(root, 'mx') + mxbuild = File.join(root, 'mxbuild') + File.write(mx, <<~RUBY) + #!#{RbConfig.ruby} + require 'json' + output = ARGV.fetch(ARGV.index('--json') + 1) + File.write(output, JSON.generate( + 'serialization_version' => 1, 'errors' => [], 'warnings' => [], + 'deprecations' => [], 'performance' => { 'recommendations' => [] }, + 'total-problems' => 0 + )) + RUBY + File.write(mxbuild, <<~RUBY) + #!#{RbConfig.ruby} + output = ARGV.find { _1.start_with?('--output=') }.split('=', 2).last + File.binwrite(output, 'mda') + RUBY + FileUtils.chmod(0o755, [mx, mxbuild]) + + report = described_class::Runner.new( + workspace: File.join(root, 'workspace'), mxcheck: mx, mxbuild:, strict_warnings: true + ).run + + expect(report).to include(passed: true) + expect(report.fetch(:oracles).keys).to contain_exactly(:mx, :mxbuild) + expect(report.fetch(:gates)).to include(mx_oracle: true, mxbuild_oracle: true) + expect(report.dig(:oracles, :mx, 'strict_warnings')).to be(true) + expect(report.dig(:oracles, :mxbuild, 'strict_warnings')).to be(false) + end + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/scaffold_spec.rb b/spec/scaffold_spec.rb index 2ed5511..0518256 100644 --- a/spec/scaffold_spec.rb +++ b/spec/scaffold_spec.rb @@ -160,6 +160,63 @@ def scaffold(root, kind, name = nil) end end + it 'connects new artifacts to explicit aggregators in an exported Ruby project' do + Dir.mktmpdir do |dir| + source_root = project_in(dir) + load File.join(source_root, 'project.rb') + source_mpr = File.join(source_root, 'ScaffoldApp.mpr') + exported = File.join(dir, 'exported') + Mxrb::Exporter.new(source_mpr, exported).export! + + scaffold(exported, :entity, 'ScaffoldApp.Animal') + scaffold(exported, :use_case, 'ScaffoldApp.ACT_CreateAnimal') + scaffold(exported, :page, 'ScaffoldApp.AnimalOverview') + scaffold(exported, :nanoflow, 'ScaffoldApp.NAN_OpenAnimal') + scaffold(exported, :repository, 'ScaffoldApp.AnimalRepository') + scaffold(exported, :scheduled_event, 'ScaffoldApp.Cleanup') + scaffold(exported, :integration, 'ScaffoldApp.PetApi') + + expect(File.read(File.join(exported, 'modules/ScaffoldApp/domain/model.rb'))) + .to include('evaluate File.join(__dir__, "entities", "animal.rb")') + expect(File.read(File.join(exported, 'modules/ScaffoldApp/application/application.rb'))) + .to include( + 'evaluate File.join(__dir__, "use_cases", "act_create_animal.rb")', + 'evaluate File.join(__dir__, "repositories", "animal_repository.rb")', + 'evaluate File.join(__dir__, "jobs", "cleanup.rb")' + ) + expect(File.read(File.join(exported, 'modules/ScaffoldApp/presentation/presentation.rb'))) + .to include( + 'evaluate File.join(__dir__, "pages", "animal_overview.rb")', + 'evaluate File.join(__dir__, "client_actions", "nan_open_animal.rb")' + ) + expect(File.read(File.join(exported, 'modules/ScaffoldApp/infrastructure/infrastructure.rb'))) + .to include( + 'evaluate File.join(__dir__, "repositories", "animal_repository_implementation.rb")', + 'evaluate File.join(__dir__, "integrations", "pet_api.rb")' + ) + + rebuilt = File.join(dir, 'rebuilt.mpr') + previous = ENV['MXRB_OUTPUT_PATH'] + ENV['MXRB_OUTPUT_PATH'] = rebuilt + load File.join(exported, 'project.rb') + ENV['MXRB_OUTPUT_PATH'] = previous + + expect(Mxrb.validate(rebuilt)).to be_valid + Mxrb.open(rebuilt) do |project| + mod = project.modules.first + expect(mod.entities.map(&:name)).to include('Animal') + expect(mod.pages.map(&:name)).to include('AnimalOverview') + expect(mod.nanoflows.map(&:name)).to include('NAN_OpenAnimal') + expect(mod.microflows.map(&:name)).to include( + 'ACT_CreateAnimal', 'AnimalRepositoryImplementation', 'Cleanup', 'PetApi' + ) + expect(mod.scheduled_events.map { _1['Name'] }).to include('Cleanup') + end + ensure + previous.nil? ? ENV.delete('MXRB_OUTPUT_PATH') : ENV['MXRB_OUTPUT_PATH'] = previous + end + end + it 'rejects invalid names, missing roots, missing modules, and unsupported CI providers' do Dir.mktmpdir do |dir| root = project_in(dir) @@ -201,16 +258,12 @@ def scaffold(root, kind, name = nil) Dir.mktmpdir do |dir| root = project_in(dir) generator = described_class.new(:entity, 'ScaffoldApp.Animal', target: root) - application = File.join(root, 'modules/ScaffoldApp/application/application.rb') project = File.join(root, 'project.rb') - application_before = File.binread(application) project_before = File.binread(project) - generator.send(:ensure_evaluate_dir, application, 'use_cases') generator.send(:connect_project_file, 'modules', 'ScaffoldApp', 'module.rb') expect(generator.instance_variable_get(:@transaction).updated).to be_empty - expect(File.binread(application)).to eq(application_before) expect(File.binread(project)).to eq(project_before) end end diff --git a/spec/writer_member_reference_spec.rb b/spec/writer_member_reference_spec.rb new file mode 100644 index 0000000..1d13064 --- /dev/null +++ b/spec/writer_member_reference_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +# These contracts protect identifiers that Studio Pro resolves while loading a +# modern MPR, before ordinary model validation has a chance to report errors. +# rubocop:disable Metrics/BlockLength +RSpec.describe Mxrb::Writer, 'modern member references' do + def document(project, name) + raw = project.mpr.units_by_containment('Documents').find do |unit| + project.parse_bson(unit)['Name'] == name + end + project.parse_bson(raw) + end + + def flow_actions(project, name) + objects = Mxrb::IO::BsonCodec.parse_array( + document(project, name).dig('ObjectCollection', 'Objects') + )[:items] + objects.filter_map { _1['Action'] } + end + + it 'qualifies a simple page input from its data-source return entity' do + Dir.mktmpdir do |dir| + path = File.join(dir, 'page-members.mpr') + Mxrb.define(path) do + mendix_version '11.12.1' + self.module :App do + entity(:Order) { string :Name } + microflow(:LoadOrder) { return_type 'App.Order' } + layout :Main + page :Edit do + layout 'App.Main' + data_source microflow: :LoadOrder + container(:Fields) { text_box :Name, attribute: :Name } + end + end + end + + Mxrb.open(path) do |project| + argument = document(project, 'Edit').dig('FormCall', 'Arguments', 1) + input = argument.dig('Widgets', 1, 'Widgets', 1, 'Widgets', 1) + expect(input.dig('AttributeRef', 'Attribute')).to eq('App.Order.Name') + end + end + end + + it 'infers a unique module entity when a page has no typed data source' do + Dir.mktmpdir do |dir| + path = File.join(dir, 'unique-page-member.mpr') + Mxrb.define(path) do + mendix_version '11.12.1' + self.module :App do + entity(:Order) { string :Reference } + layout :Main + page(:Edit) { text_box :Reference, attribute: :Reference } + end + end + + Mxrb.open(path) do |project| + input = document(project, 'Edit').dig('FormCall', 'Arguments', 1, 'Widgets', 1) + expect(input.dig('AttributeRef', 'Attribute')).to eq('App.Order.Reference') + end + end + end + + it 'qualifies create and change attributes and associations from object types' do + Dir.mktmpdir do |dir| + path = File.join(dir, 'flow-members.mpr') + Mxrb.define(path) do + mendix_version '11.12.1' + self.module :App do + entity :Customer + entity :Order do + string :Name + decimal :Total + association 'App.Customer', name: :Order_Customer + end + microflow :CreateOrder do + parameter :customer, type: 'App.Customer' + create_object 'App.Order', as: :order do + set 'App.Order/Name', to: "'Draft'" + set_association :Order_Customer, to: :customer + end + change_object :order do + set :Total, to: 1 + set_association :Order_Customer, to: :customer + end + end + end + end + + Mxrb.open(path) do |project| + create, change = flow_actions(project, 'CreateOrder') + create_items = Mxrb::IO::BsonCodec.parse_array(create['Items'])[:items] + change_items = Mxrb::IO::BsonCodec.parse_array(change['Items'])[:items] + expect(create_items.map { [_1['Attribute'], _1['Association']] }).to eq( + [['App.Order.Name', ''], ['', 'App.Order_Customer']] + ) + expect(change_items.map { [_1['Attribute'], _1['Association']] }).to eq( + [['App.Order.Total', ''], ['', 'App.Order_Customer']] + ) + end + end + end + + it 'qualifies a change attribute from an object parameter' do + Dir.mktmpdir do |dir| + path = File.join(dir, 'parameter-member.mpr') + Mxrb.define(path) do + mendix_version '11.12.1' + self.module :App do + entity(:Order) { string :Name } + microflow :Rename do + parameter :order, type: 'App.Order' + change_object :order, set: { Name: "'Changed'" } + end + end + end + + Mxrb.open(path) do |project| + change = flow_actions(project, 'Rename').first + item = Mxrb::IO::BsonCodec.parse_array(change['Items'])[:items].first + expect(item['Attribute']).to eq('App.Order.Name') + end + end + end + + it 'fails before writing an invalid unqualified change member' do + Dir.mktmpdir do |dir| + expect do + Mxrb.define(File.join(dir, 'invalid-member.mpr')) do + mendix_version '11.12.1' + self.module(:App) do + microflow(:UnknownObject) { change_object :unknown, set: { Name: "'x'" } } + end + end + end.to raise_error(Mxrb::SerializationError, /cannot qualify attribute "Name"/) + end + end + + it 'handles qualified, ambiguous, absent, and legacy member-reference edges' do + writer = described_class.new('edge.mpr', version: '11.12.1', modules: []) + + expect(writer.send(:qualified_attribute_identifier, 'Order.Name', 'App.Order')) + .to eq('App.Order.Name') + expect(writer.send(:qualified_attribute_identifier, 'Other.Name', 'App.Order')).to be_nil + expect(writer.send(:qualified_attribute_identifier, :Name, '')).to be_nil + expect(writer.send(:qualified_entity_name, nil, 'App')).to be_nil + expect(writer.send(:qualified_entity_name, :Order)).to be_nil + expect(writer.send(:qualified_entity_name, :Order, 'App')).to eq('App.Order') + + expect(writer.send(:object_entity_type, { '$Type' => 'DataTypes$StringType' }, 'App')).to be_nil + expect( + writer.send( + :object_entity_type, + { '$Type' => 'DataTypes$ObjectType', 'Entity' => 'Order' }, 'App' + ) + ).to eq('App.Order') + expect(writer.send(:qualified_artifact_parts, 'Load', 'App')).to eq(%w[App Load]) + expect(writer.send(:qualified_artifact_parts, 'Shared.Load', 'App')).to eq(%w[Shared Load]) + expect(writer.send(:flow_return_entity, 'Missing.Load', 'App')).to be_nil + expect(writer.send(:module_entities, 'Missing')).to eq([]) + + activity, variables = writer.send( + :qualify_activity_members, + [{ type: :create_object, entity: nil, variable: :unknown }], {}, 'App' + ) + expect(activity.first[:entity]).to be_nil + expect(variables).to eq({}) + association_activity, association_variables = writer.send( + :qualify_activity_members, + [{ type: :retrieve_association, association: :Missing, variable: :unknown }], {}, 'App' + ) + expect(association_activity.first[:association]).to eq(:Missing) + expect(association_variables).to eq({}) + loop_activity, loop_variables = writer.send( + :qualify_activity_members, + [{ type: :loop_over, variable: :missing, iterator: :item, activities: [] }], {}, 'App' + ) + expect(loop_activity.first[:activities]).to eq([]) + expect(loop_variables).to eq({}) + decision = writer.send( + :qualify_decision_activity, + { type: :decision, true_branch: [], false_branch: [] }, {}, 'App' + ) + expect(decision).to include(true_branch: [], false_branch: []) + + expect(writer.send(:qualified_association_identifier, :Missing, nil)).to be_nil + expect do + writer.send(:change_action_item_doc, { association: :Missing, value: nil }) + end.to raise_error(Mxrb::SerializationError, /cannot qualify association :Missing/) + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/writer_modern_edge_spec.rb b/spec/writer_modern_edge_spec.rb index f6a3702..e6bf131 100644 --- a/spec/writer_modern_edge_spec.rb +++ b/spec/writer_modern_edge_spec.rb @@ -62,6 +62,27 @@ ) end + it 'does not require translations for non-default donor-template languages' do + document = { + 'Settings' => [2, { + '$Type' => 'Settings$LanguageSettings', 'DefaultLanguageCode' => 'en_US', + 'Languages' => [3, + { '$Type' => 'Texts$Language', 'Code' => 'en_US', + 'CheckCompleteness' => true }, + { '$Type' => 'Texts$Language', 'Code' => 'nl_NL', + 'CheckCompleteness' => true }] + }] + } + + writer.send(:sanitize_project_settings!, document) + + languages = Mxrb::IO::BsonCodec.parse_array(document['Settings'][1]['Languages'])[:items] + expect(languages).to contain_exactly( + hash_including('Code' => 'en_US', 'CheckCompleteness' => true), + hash_including('Code' => 'nl_NL', 'CheckCompleteness' => false) + ) + end + it 'leaves absent lifecycle fields absent and uses the prior legacy application title' do document = { 'Settings' => [2, { '$Type' => 'Settings$ModelSettings' }] } writer.send(:sanitize_project_settings!, document) diff --git a/spec/writer_regression_spec.rb b/spec/writer_regression_spec.rb index 320ed6e..b3290bd 100644 --- a/spec/writer_regression_spec.rb +++ b/spec/writer_regression_spec.rb @@ -50,4 +50,48 @@ mappings = Mxrb::IO::BsonCodec.parse_array(action.dig('MicroflowCall', 'ParameterMappings'))[:items] expect(mappings.map { _1['Parameter'] }).to eq(%w[Clinic.Create.Name Clinic.Create.Age]) end + + # rubocop:disable Metrics/BlockLength + it 'uses Abort instead of Rollback throughout generated nanoflow graphs' do + Dir.mktmpdir do |dir| + path = File.join(dir, 'nanoflow-errors.mpr') + Mxrb.define(path) do + mendix_version '11.12.1' + self.module :Ui do + microflow(:ServerNotice) { show_message 'Server notice' } + nanoflow :ClientNotice do + decision 'true' do + on(true) { show_message 'Accepted' } + on(false) { show_message 'Rejected' } + end + show_message 'May fail' + rescue_all { show_message 'Recovered' } + end + end + end + + flows = Mxrb.open(path) do |project| + project.mpr.units_by_containment('Documents').to_h do |unit| + document = project.parse_bson(unit) + [document['Name'], document] + end + end + error_types = lambda do |value| + case value + when Hash + [value['ErrorHandlingType'], *value.values.flat_map { error_types.call(_1) }].compact + when Array + value.flat_map { error_types.call(_1) } + else + [] + end + end + + expect(error_types.call(flows.fetch('ClientNotice'))).to contain_exactly( + 'Abort', 'Abort', 'Abort', 'Abort', 'CustomWithoutRollBack' + ) + expect(error_types.call(flows.fetch('ServerNotice'))).to eq(['Rollback']) + end + end + # rubocop:enable Metrics/BlockLength end