From 45545377be029dd9a21c4f5f87b9d07175abd0fe Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Wed, 22 Jul 2026 18:22:47 +0800 Subject: [PATCH 01/72] feat(spa): vendor Vue frontend + emit bundle in both output modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native SPA pipeline landed without a frontend — `ea spa` produced a 405-byte shell with `window.__SPA_SKELETON_URL__` set but no JS to mount, so opening the output rendered a blank page. Both SingleFileStrategy and ShardedMultiFileStrategy had the same hole. Vendor the full lutaml-uml frontend (source + dist + build pipeline) so the ea gem ships with a working SPA bundle and no longer depends on lutaml-uml for rendering. Dist is committed so `gem install ea` gets a working SPA without needing npm at install time. What landed - frontend/ copied from lutaml-uml/lutaml-uml@0.5.2 wholesale: - src/: Vue 3 + Pinia source (App.vue, components/, stores/, styles/) - dist/: pre-built app.iife.js (109 KB) + style.css (24.9 KB) - tests/e2e/: Vitest browser tests + reference screenshots - package.json / vite.config.ts / tsconfig.json / vitest.config.ts - package-lock.json (renamed package: lutaml-uml-spa → ea-spa) - frontend/README.md documenting when/how to rebuild and the data contract the shell has to honour - .gitignore: /frontend/node_modules/ (local only) - .github/workflows/frontend.yml: CI check that rebuilds the bundle and fails if dist/ drifts from src - lib/ea/spa/output/strategy.rb: constants for FRONTEND_APP_JS / FRONTEND_STYLE_CSS plus assert_frontend_bundle! helper that raises a clear error if the bundle is missing (e.g. running from a source checkout before npm build) - lib/ea/spa/output/single_file_strategy.rb: inline
+ HTML diff --git a/lib/ea/spa/output/strategy.rb b/lib/ea/spa/output/strategy.rb index 99667b5..d82624f 100644 --- a/lib/ea/spa/output/strategy.rb +++ b/lib/ea/spa/output/strategy.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "fileutils" + module Ea module Spa module Output @@ -13,6 +15,14 @@ module Output class Strategy SIZE_THRESHOLD_BYTES = 2 * 1024 * 1024 # 2 MB + # Path to the pre-built Vue IIFE bundle shipped with the gem. + # Built from frontend/ via `npm run build`; committed so the + # gem never needs npm at install time. Resolved lazily so a + # missing bundle raises a clear error pointing at frontend/. + FRONTEND_DIST_DIR = File.expand_path("../../../../frontend/dist", __dir__) + FRONTEND_APP_JS = File.join(FRONTEND_DIST_DIR, "app.iife.js") + FRONTEND_STYLE_CSS = File.join(FRONTEND_DIST_DIR, "style.css") + attr_reader :output_path def initialize(output_path) @@ -24,6 +34,30 @@ def render(_projector) "#{self.class} must implement #render" end + protected + + # The pre-built SPA application source as a String, for + # embedding directly into a single-file output. Returns nil + # if the bundle has not been built (e.g. running from a + # source checkout before `npm run build`). + def frontend_app_js + return nil unless File.exist?(FRONTEND_APP_JS) + File.read(FRONTEND_APP_JS) + end + + def frontend_style_css + return nil unless File.exist?(FRONTEND_STYLE_CSS) + File.read(FRONTEND_STYLE_CSS) + end + + def assert_frontend_bundle! + unless File.exist?(FRONTEND_APP_JS) && File.exist?(FRONTEND_STYLE_CSS) + raise Ea::Error, + "SPA frontend bundle not found under #{FRONTEND_DIST_DIR}. " \ + "Run `cd frontend && npm install && npm run build`." + end + end + private def write_json(path, data) diff --git a/spec/ea/spa/output_strategy_spec.rb b/spec/ea/spa/output_strategy_spec.rb index 4d37a80..0df7069 100644 --- a/spec/ea/spa/output_strategy_spec.rb +++ b/spec/ea/spa/output_strategy_spec.rb @@ -19,36 +19,52 @@ let(:projector) { Ea::Spa::Projector.new(document) } describe Ea::Spa::Output::SingleFileStrategy do - it "writes one HTML file with embedded JSON" do + it "writes one HTML file with embedded JSON, JS, and CSS" do Dir.mktmpdir do |dir| out = File.join(dir, "spa.html") described_class.new(out).render(projector) contents = File.read(out) expect(contents).to include("") + expect(contents).to include(" - - - - - - - - - - - - - - - - - - - - - SVG - end - - def background_layer # rubocop:disable Metrics/AbcSize - offset_x = bounds[:x].negative? ? bounds[:x].abs : 0 - offset_y = bounds[:y].negative? ? bounds[:y].abs : 0 - total_width = bounds[:width] + offset_x - total_height = bounds[:height] + offset_y - - <<~SVG - - - - SVG - end - - def grid_layer # rubocop:disable Metrics/AbcSize,Metrics/MethodLength - grid_size = 20 - grid_lines = +"" - - # Vertical lines - x = bounds[:x] - while x <= bounds[:x] + bounds[:width] - grid_lines << "\n" - x += grid_size - end - - # Horizontal lines - y = bounds[:y] - while y <= bounds[:y] + bounds[:height] - grid_lines << "\n" - y += grid_size - end - - "\n#{grid_lines}\n" - end - - def connectors_layer - connectors_svg = diagram_renderer.connectors.map do |connector| - render_connector(connector) - end.join("\n") - - "\n" \ - "#{connectors_svg}\n\n" - end - - def elements_layer - elements_svg = diagram_renderer.elements.map do |element| - render_element(element) - end.join("\n") - - "\n#{elements_svg}\n\n" - end - - def interactive_layer - # Add interactive JavaScript if needed - <<~SVG - - SVG - end - - def svg_footer - "\n" - end - - def render_connector(connector) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity - path_builder = PathBuilder.new( - connector, - connector[:source_element], - connector[:target_element], - ) - path_data = path_builder.build_path - - style = style_resolver.resolve_connector_style(connector) - - # Determine marker based on connector type - markers = determine_marker_type(connector[:type]) - marker_start = markers[:start] || "" - marker_end = markers[:end] || "" - - # Build style string - style_attrs = [] - style_attrs << "stroke:#{style[:stroke] || '#000000'}" - style_attrs << "stroke-width:#{style[:stroke_width] || '1'}" - style_attrs << "stroke-linecap:#{style[:stroke_linecap] || 'round'}" - style_attrs << "stroke-linejoin:#{style[:stroke_linejoin] || 'bevel'}" - style_attrs << "fill:#{style[:fill] || 'none'}" - style_attrs << "shape-rendering:#{style[:shape_rendering] || 'auto'}" - if style[:stroke_dasharray] - style_attrs << "stroke-dasharray:#{style[:stroke_dasharray]}" - end - - <<~SVG - - - - SVG - end - - def render_element(element) - registry = ElementRenderers::DEFAULT_REGISTRY - renderer_class = registry.renderer_for(element[:type]) || - ElementRenderers::BaseRenderer - - renderer = renderer_class.new(element, style_resolver) - renderer.render - end - - end - end -end diff --git a/lib/ea/diagram/util.rb b/lib/ea/diagram/util.rb deleted file mode 100644 index ccc9819..0000000 --- a/lib/ea/diagram/util.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -module Ea - module Diagram - module Util - # Convert a style hash to a CSS string - # - # @param style_hash [Hash] Style properties (e.g., { stroke: "#000" }) - # @return [String] CSS string (e.g., "stroke:#000;fill:none") - def style_to_css(style_hash) - style_hash.map { |k, v| "#{k}:#{v}" }.join(";") - end - - # Parse geometry offsets - def parse_geometry_offsets(geometry_string) - geometry = parse_ea_geometry(geometry_string) - - [ - geometry[:source_offset_x].to_i, - geometry[:source_offset_y].to_i, - geometry[:target_offset_x].to_i, - geometry[:target_offset_y].to_i, - ] - rescue TypeError, NoMethodError - [0, 0, 0, 0] - end - - def parse_ea_geometry(geometry_string) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity - return nil if geometry_string.nil? || geometry_string.strip.empty? - - data = {} - begin - geometry = geometry_string - .gsub(/\s/, "") - .downcase - .split(";") - .to_h { |pair| pair.split("=") } - - geometry.each do |k, v| - if v.include?(",") - # waypoints - data[:waypoints] ||= [] - x_str, y_str = v.split(",") - data[:waypoints] << { x: x_str.to_i, y: y_str.to_i } - else - key = case k - when "sx" - data[:has_relative_coords] ||= true - "source_offset_x" - when "sy" - data[:has_relative_coords] ||= true - "source_offset_y" - when "ex" - data[:has_relative_coords] ||= true - "target_offset_x" - when "ey" - data[:has_relative_coords] ||= true - "target_offset_y" - else - k - end - - data[key.to_sym] = v.to_i - end - end - rescue ArgumentError, TypeError - data = {} - end - data - end - end - end -end diff --git a/lib/ea/transformations.rb b/lib/ea/transformations.rb index 87f45e7..51f07ac 100644 --- a/lib/ea/transformations.rb +++ b/lib/ea/transformations.rb @@ -1,38 +1,12 @@ # frozen_string_literal: true module Ea + # Transformations is the legacy entry point for parse and to_uml. + # Both methods now delegate directly to Ea::Qea, Ea::Xmi, and + # Ea::Bridge — the per-format parsers and the UML bridge are + # reached without an intermediate engine layer. module Transformations - autoload :Configuration, "ea/transformations/configuration" - autoload :FormatRegistry, "ea/transformations/format_registry" - autoload :TransformationEngine, "ea/transformations/transformation_engine" - - module Parsers - autoload :BaseParser, "ea/transformations/parsers/base_parser" - autoload :XmiParser, "ea/transformations/parsers/xmi_parser" - autoload :QeaParser, "ea/transformations/parsers/qea_parser" - end - - # Resolve a class name string to a constant - # @param class_name [String] Fully qualified class name (e.g. "Ea::Foo::Bar") - # @return [Class, nil] The resolved class constant, or nil if not found - def self.constantize(class_name) - parts = class_name.split("::") - constant = Object - parts.each { |part| constant = constant.const_get(part) } - constant - rescue NameError - nil - end - class << self - def engine - @engine ||= TransformationEngine.new - end - - def engine=(engine) - @engine = engine - end - # Parse an EA file into its native representation. # # Pure entry point — does NOT require `lutaml-uml`. Returns: @@ -40,10 +14,6 @@ def engine=(engine) # .xmi → Xmi::Sparx::Root # # To get a Lutaml::Uml::Document instead, use {to_uml}. - # - # @param file_path [String] path to a .qea or .xmi file - # @param options [Hash] parser options (e.g. config: for QEA) - # @return [Ea::Qea::Database, Xmi::Sparx::Root] def parse(file_path, options = {}) ext = File.extname(file_path).downcase case ext @@ -62,17 +32,8 @@ def parse(file_path, options = {}) # `Lutaml::Uml::Document`. # # Bridge entry point — requires the optional `lutaml-uml` gem. - # Lazy-loads the bridge code on first call. - # - # @param path_or_model [String, Ea::Qea::Database, Xmi::Sparx::Root] - # @param options [Hash] transformation options - # @return [Lutaml::Uml::Document] def to_uml(path_or_model, options = {}) - model = if path_or_model.is_a?(String) - parse(path_or_model, options) - else - path_or_model - end + model = path_or_model.is_a?(String) ? parse(path_or_model, options) : path_or_model case model when Ea::Qea::Database @@ -85,50 +46,6 @@ def to_uml(path_or_model, options = {}) "Expected Ea::Qea::Database or Xmi::Sparx::Root." end end - - def detect_parser(file_path) - engine.detect_parser(file_path) - end - - def supported_extensions - engine.supported_extensions - end - - def supports_file?(file_path) - engine.supports_file?(file_path) - end - - def statistics - engine.statistics - end - - def reset_statistics - engine.clear_history - end - - def validate_setup - engine.validate_setup - end - - def register_parser(extension, parser_class) - engine.register_parser(extension, parser_class) - end - - def load_configuration(config_path) - engine.configuration = Configuration.load(config_path) - end - - def configuration - engine.configuration - end - - def configuration=(config) - engine.configuration = config - end - - def configure - yield(configuration) if block_given? - end end end end diff --git a/lib/ea/transformations/configuration.rb b/lib/ea/transformations/configuration.rb deleted file mode 100644 index a9d58fa..0000000 --- a/lib/ea/transformations/configuration.rb +++ /dev/null @@ -1,333 +0,0 @@ -# frozen_string_literal: true - -require "lutaml/model" -require "yaml" - -module Ea - module Transformations - # Configuration service for model transformations using external YAML - # configuration. - # - # This class follows the Dependency Inversion Principle by allowing external - # configuration instead of hardcoded behavior. It uses lutaml-model for - # structured YAML parsing and validation. - # - # @example Load default configuration - # config = Configuration.load - # puts config.enabled_formats - # - # @example Load custom configuration - # config = Configuration.load("my_config.yml") - # parser_config = config.parser_config_for("xmi") - class Configuration < Lutaml::Model::Serializable - # Parser configuration model - class ParserConfig < Lutaml::Model::Serializable - attribute :format, :string - attribute :extension, :string - attribute :parser_class, :string - attribute :enabled, :boolean, default: -> { true } - attribute :priority, :integer, default: -> { 100 } - attribute :description, :string - attribute :options, :string, collection: true - - yaml do - map "format", to: :format - map "extension", to: :extension - map "parser_class", to: :parser_class - map "enabled", to: :enabled - map "priority", to: :priority - map "description", to: :description - map "options", to: :options - end - - # Check if this parser handles the given extension - # - # @param ext [String] File extension (e.g., ".xmi") - # @return [Boolean] true if this parser handles the extension - def handles_extension?(ext) - extension == ext.downcase - end - end - - # Transformation options model - class TransformationOptions < Lutaml::Model::Serializable - attribute :validate_output, :boolean, default: -> { false } - attribute :include_diagrams, :boolean, default: -> { true } - attribute :preserve_ids, :boolean, default: -> { true } - attribute :resolve_references, :boolean, default: -> { true } - attribute :strict_mode, :boolean, default: -> { false } - - yaml do - map "validate_output", to: :validate_output - map "include_diagrams", to: :include_diagrams - map "preserve_ids", to: :preserve_ids - map "resolve_references", to: :resolve_references - map "strict_mode", to: :strict_mode - end - end - - # Format detection rules model - class FormatDetection < Lutaml::Model::Serializable - attribute :use_file_extension, :boolean, default: -> { true } - attribute :use_content_sniffing, :boolean, default: -> { true } - attribute :fallback_parser, :string - attribute :magic_bytes, :string, collection: true - - yaml do - map "use_file_extension", to: :use_file_extension - map "use_content_sniffing", to: :use_content_sniffing - map "fallback_parser", to: :fallback_parser - map "magic_bytes", to: :magic_bytes - end - end - - # Error handling configuration - class ErrorHandling < Lutaml::Model::Serializable - attribute :strategy, :string, default: -> { "continue" } - attribute :log_errors, :boolean, default: -> { true } - attribute :max_errors, :integer, default: -> { 10 } - attribute :fail_fast, :boolean, default: -> { false } - - yaml do - map "strategy", to: :strategy - map "log_errors", to: :log_errors - map "max_errors", to: :max_errors - map "fail_fast", to: :fail_fast - end - end - - attribute :version, :string - attribute :description, :string - attribute :parsers, ParserConfig, collection: true - attribute :transformation_options, TransformationOptions - attribute :format_detection, FormatDetection - attribute :error_handling, ErrorHandling - - yaml do - map "version", to: :version - map "description", to: :description - map "parsers", to: :parsers - map "transformation_options", to: :transformation_options - map "format_detection", to: :format_detection - map "error_handling", to: :error_handling - end - - class << self - # Load configuration from YAML file - # - # @param config_path [String, nil] Path to configuration file - # Defaults to config/model_transformations.yml - # @return [Configuration] The loaded configuration - # @raise [Errno::ENOENT] if config file not found - # @raise [Lutaml::Model::Error] if YAML is invalid - def load(config_path = nil) - config_path ||= default_config_path - - unless File.exist?(config_path) - # Create default configuration if none exists - return create_default_configuration - end - - yaml_content = File.read(config_path) - from_yaml(yaml_content) - end - - # Get default configuration file path - # - # @return [String] Path to default config file - def default_config_path - File.expand_path("../../../config/model_transformations.yml", __dir__) - end - - # Create default configuration when no config file exists - # - # @return [Configuration] Default configuration instance - def create_default_configuration - new.tap do |config| - config.version = "1.0" - config.description = "Default Model Transformations Configuration" - - # Default parsers - config.parsers = [ - create_xmi_parser_config, - create_qea_parser_config, - ] - - # Default options - config.transformation_options = TransformationOptions.new - config.format_detection = FormatDetection.new - config.error_handling = ErrorHandling.new - end - end - - private - - # Create default XMI parser configuration - # - # @return [ParserConfig] XMI parser configuration - def create_xmi_parser_config - ParserConfig.new.tap do |parser| - parser.format = "xmi" - parser.extension = ".xmi" - parser.parser_class = "Ea::Transformations::Parsers::XmiParser" - parser.enabled = true - parser.priority = 100 - parser.description = "XML Metadata Interchange parser" - parser.options = ["validate_xml", "resolve_references"] - end - end - - # Create default QEA parser configuration - # - # @return [ParserConfig] QEA parser configuration - def create_qea_parser_config - ParserConfig.new.tap do |parser| - parser.format = "qea" - parser.extension = ".qea" - parser.parser_class = "Ea::Transformations::Parsers::QeaParser" - parser.enabled = true - parser.priority = 90 - parser.description = "Enterprise Architect database parser" - parser.options = ["include_diagrams", "resolve_references"] - end - end - end - - # Get list of enabled parsers, sorted by priority - # - # @return [Array] Array of enabled parser configurations - def enabled_parsers - parsers&.select(&:enabled)&.sort_by { |p| -p.priority } || [] - end - - # Get parser configuration by format name - # - # @param format [String] The format name (e.g., "xmi", "qea") - # @return [ParserConfig, nil] The parser configuration or nil if not found - def parser_config_for(format) - parsers&.find { |p| p.format == format.downcase } - end - - # Get parser configuration by file extension - # - # @param extension [String] The file extension (e.g., ".xmi", ".qea") - # @return [ParserConfig, nil] The parser configuration or nil if not found - def parser_config_for_extension(extension) - normalized_ext = extension.downcase - unless normalized_ext.start_with?(".") - normalized_ext = ".#{normalized_ext}" - end - - enabled_parsers.find { |p| p.handles_extension?(normalized_ext) } - end - - # Check if a format is enabled - # - # @param format [String] The format name - # @return [Boolean] true if format is enabled - def format_enabled?(format) - parser = parser_config_for(format) - parser&.enabled == true - end - - # Get all enabled format names - # - # @return [Array] Array of enabled format names - def enabled_formats - enabled_parsers.map(&:format) - end - - # Get all supported file extensions - # - # @return [Array] Array of supported extensions - def supported_extensions - enabled_parsers.filter_map(&:extension) - end - - # Check if content sniffing is enabled - # - # @return [Boolean] true if content sniffing should be used - def content_sniffing_enabled? - format_detection&.use_content_sniffing == true - end - - # Check if file extension detection is enabled - # - # @return [Boolean] true if file extension should be used for detection - def file_extension_detection_enabled? - format_detection&.use_file_extension == true - end - - # Get fallback parser when format detection fails - # - # @return [String, nil] The fallback parser class name - def fallback_parser - format_detection&.fallback_parser - end - - # Get transformation options with defaults - # - # @return [TransformationOptions] Transformation options - def transformation_options - @transformation_options ||= TransformationOptions.new - end - - # Get error handling configuration with defaults - # - # @return [ErrorHandling] Error handling configuration - def error_handling - @error_handling ||= ErrorHandling.new - end - - # Merge with another configuration (this takes precedence) - # - # @param other [Configuration] Configuration to merge with - # @return [Configuration] New merged configuration - def merge(other) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength - merged = self.class.new - - # Basic attributes - merged.version = version || other.version - merged.description = description || other.description - - # Merge parsers (this config takes precedence) - merged.parsers = merge_parsers(other.parsers) - - # Use this config's options, fallback to other - merged.transformation_options = transformation_options || - other.transformation_options - merged.format_detection = format_detection || other.format_detection - merged.error_handling = error_handling || other.error_handling - - merged - end - - private - - # Merge parser configurations - # - # @param other_parsers [Array] Other parsers to merge - # @return [Array] Merged parser list - def merge_parsers(other_parsers) # rubocop:disable Metrics/MethodLength - return parsers unless other_parsers - return other_parsers unless parsers - - # Create a hash of this config's parsers by format - our_parsers = {} - parsers.each do |parser| - our_parsers[parser.format] = parser - end - - # Add other parsers that we don't have - merged = parsers.dup - other_parsers.each do |other_parser| - unless our_parsers.key?(other_parser.format) - merged << other_parser - end - end - - merged - end - end - end -end diff --git a/lib/ea/transformations/format_registry.rb b/lib/ea/transformations/format_registry.rb deleted file mode 100644 index 4c63d83..0000000 --- a/lib/ea/transformations/format_registry.rb +++ /dev/null @@ -1,366 +0,0 @@ -# frozen_string_literal: true - -module Ea - module Transformations - # Format Registry manages parser registration and discovery. - # - # This class implements the Registry pattern to provide a centralized - # location for managing format parsers. It follows the Open/Closed Principle - # by allowing new parsers to be registered without modifying existing code. - # - # @example Register a parser - # registry = FormatRegistry.new - # registry.register(".custom", MyCustomParser) - # - # @example Get parser for extension - # parser_class = registry.parser_for_extension(".xmi") - # parser = parser_class.new - class FormatRegistry - # @return [Hash] Map of extensions to parser classes - attr_reader :parsers - - def initialize - @parsers = {} - @extensions = [] - @default_parsers_loaded = false - end - - # Register a parser for a file extension - # - # @param extension [String] File extension (e.g., ".xmi", ".qea") - # @param parser_class [Class] Parser class implementing BaseParser - # interface - # @raise [ArgumentError] if extension or parser_class is invalid - def register(extension, parser_class) # rubocop:disable Metrics/MethodLength - if extension.is_a?(Array) - extension.each { |ext| register(ext, parser_class) } - return - end - - validate_extension!(extension) - validate_parser_class!(parser_class) - - normalized_ext = normalize_extension(extension) - @parsers[normalized_ext] = parser_class - unless @extensions.include?(normalized_ext) - @extensions << normalized_ext - end - end - - # Unregister a parser for a file extension - # - # @param extension [String] File extension to unregister - # @return [Class, nil] The unregistered parser class, or nil if not found - def unregister(extension) - normalized_ext = normalize_extension(extension) - @extensions.delete(normalized_ext) - @parsers.delete(normalized_ext) - end - - # Auto-register a parser class based on its supported extensions - # - # @param parser_class [Class] Parser class inherited from BaseParser - # @return [void] - def auto_register_from_parser(parser_class) - unless parser_class.is_a?(Class) && - parser_class <= Ea::Transformations::Parsers::BaseParser - raise ArgumentError, - "#{parser_class} must inherit from " \ - "Ea::Transformations::Parsers::BaseParser" - end - - extensions = parser_class.new.supported_extensions - raise ArgumentError, "Extension cannot be nil or empty" if extensions.nil? || extensions.empty? - - register(extensions, parser_class) - end - - # Get parser class for a file extension - # - # @param extension [String] File extension (e.g., ".xmi", ".qea") - # @return [Class, nil] Parser class, or nil if not found - def parser_for_extension(extension) - # ensure_default_parsers_loaded! - normalized_ext = normalize_extension(extension) - @parsers[normalized_ext] - end - - # Get parser class for a file path - # - # @param file_path [String] Path to the file - # @return [Class, nil] Parser class, or nil if not found - def parser_for_file(file_path) - extension = File.extname(file_path) - parser_for_extension(extension) - end - - # Check if an extension is supported - # - # @param extension [String] File extension to check - # @return [Boolean] true if extension is supported - def supports_extension?(extension) - parser_for_extension(extension) != nil - end - - # Check if a file is supported - # - # @param file_path [String] Path to the file - # @return [Boolean] true if file format is supported - def supports_file?(file_path) - parser_for_file(file_path) != nil - end - - # Get all supported extensions - # - # @return [Array] List of supported file extensions - def supported_extensions - # ensure_default_parsers_loaded! - @parsers.keys.sort - end - - # Get all registered parsers - # - # @return [Hash] Map of extensions to parser classes - def all_parsers - # ensure_default_parsers_loaded! - @parsers.dup - end - - # Get parsers sorted by priority (highest first) - # - # @return [Array] List of [extension, parser_class] - def parsers_by_priority - @parsers.sort_by do |_ext, parser_class| - parser_class.new.priority - end.reverse - end - - # Get all registered extensions - # - # @return [Array] List of registered extensions - def all_extensions - @extensions.dup - end - - # Get statistics about registered parsers - # - # @return [Hash] Statistics hash - def statistics # rubocop:disable Metrics/MethodLength - parsers = @parsers.values.uniq - total_parsers = parsers.size - ext_size = @extensions.size - - { - total_parsers: total_parsers, - total_extensions: ext_size, - extensions_per_parser: (ext_size.to_f / total_parsers).round(2), - parser_details: parsers.map do |parser_class| - { - parser: parser_class, - extensions: parser_class.new.supported_extensions, - priority: parser_class.new.priority, - format_name: parser_class.new.format_name, - } - end, - } - end - - def export_configuration # rubocop:disable Metrics/MethodLength - { - exported_at: Time.now, - parsers: @parsers.map do |parser| - _ext, parser_class = parser - - { - parser_class: parser_class, - extensions: parser_class.new.supported_extensions, - priority: parser_class.new.priority, - format: parser_class.new.format_name, - } - end, - } - end - - # Clear all registered parsers - # - # @return [void] - def clear - @extensions.clear - @parsers.clear - @default_parsers_loaded = false - end - - # Load parsers from configuration - # - # @param configuration [Configuration] Configuration with parser - # definitions - # @return [void] - def load_from_configuration(configuration) - configuration.enabled_parsers.each do |parser_config| - parser_class = constantize_parser_class(parser_config.parser_class) - register(parser_config.extension, parser_class) - rescue NameError => e - warn "Warning: Could not load parser " \ - "#{parser_config.parser_class}: #{e.message}" - end - end - - # Create a new instance with default parsers loaded - # - # @return [FormatRegistry] New registry instance - def self.with_defaults - registry = new - registry.load_default_parsers - registry - end - - # Load default parsers (called automatically when needed) - # - # @return [void] - def load_default_parsers # rubocop:disable Metrics/MethodLength - return if @default_parsers_loaded - - # Load XMI parser if available - begin - register(".xmi", Parsers::XmiParser) - rescue LoadError - # XMI parser not available, skip - end - - # Load QEA parser if available - begin - register(".qea", Parsers::QeaParser) - rescue LoadError - # QEA parser not available, skip - end - - @default_parsers_loaded = true - end - - # Detect format by file content (magic bytes/signatures) - # - # @param file_path [String] Path to the file - # @return [Class, nil] Parser class based on content detection - def detect_by_content(file_path) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity - # ensure_default_parsers_loaded! - unless File.exist?(file_path) - raise ArgumentError, "#{file_path} does not exist!" - end - - # Read first few bytes to detect format - File.open(file_path, "rb") do |file| - header = file.read(1024) # Read first 1KB - - return nil if header.nil? || header.empty? - - # Check header match for content patterns - @parsers.each do |ext, parser_class| - parser_klass = parser_class.new - parser_klass.content_patterns.each do |pattern| - if header.match?(pattern) - return parser_for_extension(ext) - end - end - end - - # Check for XML/XMI signatures - if header.include?(" e - [false, handle_parsing_error(e, file_path)] - end - - def record_parse_stats(succeeded, handled, start_time) - unless succeeded || handled - @stats_mutex.synchronize { @parse_stats[:failed_parses] += 1 } - end - duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time - @last_duration = duration - @stats_mutex.synchronize do - @parse_stats[:total_duration] += duration - @parse_stats[:durations] << duration - end - end - - # Check if this parser can handle the given file - # - # @param file_path [String] Path to the file - # @return [Boolean] true if parser can handle the file - def can_parse?(file_path) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity - extension = File.extname(file_path).downcase - return true if supported_extensions.include?(extension) - - if File.exist?(file_path) - File.open(file_path, "rb") do |file| - header = file.read(1024) # Read first 1KB - return false if header.nil? || header.empty? - - content_patterns.each do |pattern| - return true if header.match?(pattern) - end - end - end - - false - end - - # Get parser format name - # - # @return [String] Human-readable format name - # @abstract Implement in subclass - def format_name - raise NotImplementedError, "Subclasses must implement #format_name" - end - - # Get list of supported file extensions - # - # @return [Array] List of extensions (e.g., [".xmi", ".xml"]) - # @abstract Implement in subclass - def supported_extensions - raise NotImplementedError, - "Subclasses must implement #supported_extensions" - end - - # Default parser priority - def priority - 100 - end - - def content_patterns - [] - end - # Check if parser has any errors - # - # @return [Boolean] true if there are parsing errors - def has_errors? - !@errors.empty? - end - - # Check if parser has any warnings - # - # @return [Boolean] true if there are parsing warnings - def has_warnings? - !@warnings.empty? - end - - # Get all parsing errors - # - # @return [Array] List of error messages - def errors - @errors.dup - end - - # Get all parsing warnings - # - # @return [Array] List of warning messages - def warnings - @warnings.dup - end - - # Get parsing statistics - # - # @return [Hash] Statistics about the parsing process - def statistics - stats = @stats_mutex.synchronize { @parse_stats.dup } - durations = stats[:durations] - { - format: format_name, - errors: @errors.size, - warnings: @warnings.size, - options: @options, - total_parses: stats[:total_parses], - successful_parses: stats[:successful_parses], - failed_parses: stats[:failed_parses], - success_rate: calculate_success_rate(stats), - average_duration: calculate_average_duration(durations), - total_duration: stats[:total_duration], - } - end - - # Reset all parsing statistics - # - # @return [void] - def reset_statistics - @stats_mutex.synchronize do - @parse_stats = { - total_parses: 0, - successful_parses: 0, - failed_parses: 0, - total_duration: 0, - durations: [], - } - end - @last_duration = nil - end - - # Core parsing implementation - # - # @param file_path [String] Path to the file to parse - # @return [Lutaml::Uml::Document] Parsed UML document - # @abstract Implement in subclass - def parse_internal(file_path) - raise NotImplementedError, "Subclasses must implement #parse_internal" - end - - # Hook called before parsing starts - # - # @param file_path [String] Path to the file being parsed - # @return [void] - def before_parse(file_path) - # Default implementation does nothing - # Subclasses can override for pre-processing - end - - # Hook called after parsing completes - # - # @param document [Lutaml::Uml::Document] Parsed document - # @param file_path [String] Path to the source file - # @return [Lutaml::Uml::Document] Processed document - def after_parse(document, _file_path) - # Default implementation returns document unchanged - # Subclasses can override for post-processing - document - end - - # Get default parsing options - # - # @return [Hash] Default options hash - def default_options - { - validate_input: true, - validate_output: false, - include_diagrams: true, - preserve_ids: true, - resolve_references: true, - strict_mode: false, - } - end - - # Add an error message - # - # @param message [String] Error message - # @param context [Hash] Additional context information - # @return [void] - def add_error(message, context = {}) - error_entry = { - message: message, - context: context, - timestamp: Time.now, - } - @errors << error_entry - - # Log error if configuration allows - log_error(error_entry) if should_log_errors? - end - - # Add a warning message - # - # @param message [String] Warning message - # @param context [Hash] Additional context information - # @return [void] - def add_warning(message, context = {}) - warning_entry = { - message: message, - context: context, - timestamp: Time.now, - } - @warnings << warning_entry - - # Log warning if configuration allows - log_warning(warning_entry) if should_log_errors? - end - - def add_info(message, context = {}) - add_warning(message, context) - end - - def assign_if_supported(target, setter, value) - target.public_send(setter, value) - rescue NoMethodError - nil - end - def format_file_size(size) - units = %w[B KB MB GB] - size = size.to_f - unit_index = 0 - - while size >= 1024 && unit_index < units.length - 1 - size /= 1024 - unit_index += 1 - end - - "#{size.round(1)} #{units[unit_index]}" - end - - def format_statistics(stats) - parts = stats.map { |key, value| "#{value} #{key}" } - parts.join(", ") - end - - # Check if input validation is enabled - # - # @return [Boolean] true if input should be validated - def should_validate_input? - @options[:validate_input] - end - - # Check if output validation is enabled - # - # @return [Boolean] true if output should be validated - def should_validate_output? - @options[:validate_output] || - @configuration&.transformation_options&.validate_output - end - - # Check if errors should be logged - # - # @return [Boolean] true if errors should be logged - def should_log_errors? - @configuration&.error_handling&.log_errors != false - end - - # Check if parser should fail fast on errors - # - # @return [Boolean] true if parser should fail on first error - def should_fail_fast? - @configuration&.error_handling&.fail_fast == true - end - - - public - - def validate_file!(file_path) - if file_path.nil? || file_path.empty? - raise ArgumentError, "File path cannot be nil" - end - - unless File.exist?(file_path) - raise ArgumentError, "File does not exist: \#{file_path}" - end - - unless File.readable?(file_path) - raise ArgumentError, "File is not readable: \#{file_path}" - end - end - - def clear_errors_and_warnings - @errors.clear - @warnings.clear - end - - def validate_output!(document) - unless document.is_a?(Lutaml::Uml::Document) - raise ArgumentError, "Parser must return a Lutaml::Uml::Document" - end - - if document.packages.nil? && document.classes.nil? - add_warning("Document contains no packages or classes") - end - end - - private - - # Calculate success rate percentage - # - # @param stats [Hash] Stats hash - # @return [Float] Success rate as percentage - def calculate_success_rate(stats) - return 0.0 if stats[:total_parses].zero? - - (stats[:successful_parses].to_f / stats[:total_parses]) * 100.0 - end - - # Calculate average parse duration - # - # @param durations [Array] List of parse durations - # @return [Float] Average duration - def calculate_average_duration(durations) - return 0.0 if durations.empty? - - durations.sum / durations.size - end - - # Handle parsing errors according to configuration - # - # @param error [StandardError] The error that occurred - # @param file_path [String] Path to the file being parsed - # @raise [ParseError] Wrapped parsing error - def handle_parsing_error(error, file_path) # rubocop:disable Metrics/MethodLength - error_context = { - file_path: file_path, - parser: self.class.name, - original_error: error.class.name, - } - - add_error("Failed to parse #{file_path}: #{error.message}", - error_context) - - # Re-raise as ParseError with additional context - raise ParseError.new( - "Parsing failed for #{file_path}", - original_error: error, - parser: self, - file_path: file_path, - ) - end - - # Log error entry - # - # @param error_entry [Hash] Error entry to log - # @return [void] - def log_error(error_entry) - warn "[#{self.class.name}] ERROR: #{error_entry[:message]}" - end - - # Log warning entry - # - # @param warning_entry [Hash] Warning entry to log - # @return [void] - def log_warning(warning_entry) - warn "[#{self.class.name}] WARNING: #{warning_entry[:message]}" - end - end - - # Custom error class for parsing failures - class ParseError < Ea::Error - # @return [StandardError] Original error that caused parsing failure - attr_reader :original_error - - # @return [BaseParser] Parser instance that failed - attr_reader :parser - - # @return [String] Path to file that failed to parse - attr_reader :file_path - - # Initialize parsing error - # - # @param message [String] Error message - # @param original_error [StandardError] Original error - # @param parser [BaseParser] Parser that failed - # @param file_path [String] File that failed to parse - def initialize( - message, original_error: nil, parser: nil, - file_path: nil - ) - super(message) - @original_error = original_error - @parser = parser - @file_path = file_path - end - - # Get detailed error information - # - # @return [Hash] Error details - def details # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity - { - message: message, - file_path: @file_path, - parser: @parser&.class&.name, - original_error: @original_error&.class&.name, - original_message: @original_error&.message, - backtrace: @original_error&.backtrace&.first(5), - } - end - end - end - end -end diff --git a/lib/ea/transformations/parsers/qea_parser.rb b/lib/ea/transformations/parsers/qea_parser.rb deleted file mode 100644 index cdcba26..0000000 --- a/lib/ea/transformations/parsers/qea_parser.rb +++ /dev/null @@ -1,401 +0,0 @@ -# frozen_string_literal: true - -module Ea - module Transformations - module Parsers - # QEA Parser implements the BaseParser interface for Enterprise Architect - # database files. - # - # This parser wraps the existing Ea::Qea functionality and adapts it - # to the new unified transformation architecture. It provides enhanced - # error handling, progress tracking, and configuration integration. - class QeaParser < BaseParser - # @return [Ea::Qea::Database] Loaded QEA database - attr_reader :qea_database - - # @return [Hash] Database statistics - attr_reader :database_stats - - # Get parser format name - # - # @return [String] Human-readable format name - def format_name - "Enterprise Architect Database (QEA)" - end - - # Get list of supported file extensions - # - # @return [Array] List of extensions - def supported_extensions - [".qea", ".eap", ".eapx"] - end - - def content_patterns - [/^SQLite format/] - end - - def priority - 90 - end - - protected - - # Core parsing implementation for QEA files - # - # @param file_path [String] Path to the QEA file - # @return [Lutaml::Uml::Document] Parsed UML document - def parse_internal(file_path) - # Validate QEA file format - validate_qea_format!(file_path) - - # Load QEA database with progress tracking - @qea_database = load_qea_database(file_path) - - # Get database statistics - @database_stats = @qea_database.stats - - # Transform to UML document using existing factory - document = transform_qea_to_uml(@qea_database, file_path) - - # Post-process document - post_process_qea_document(document, file_path) - - document - end - - # Hook called before parsing starts - # - # @param file_path [String] Path to the file being parsed - # @return [void] - def before_parse(file_path) # rubocop:disable Metrics/MethodLength - add_info("Starting QEA parsing for: #{file_path}") - - # Check file size and provide estimates - file_size = File.size(file_path) - add_info("QEA file size: #{format_file_size(file_size)}") - - if file_size > 500 * 1024 * 1024 # 500MB - add_warning("Very large QEA file detected, " \ - "parsing may take significant time") - end - - # Quick database info check - begin - quick_stats = get_quick_database_stats(file_path) - add_info("Database contains approximately: " \ - "#{format_database_stats(quick_stats)}") - rescue StandardError => e - add_warning("Could not get quick database stats: #{e.message}") - end - end - - # Hook called after parsing completes - # - # @param document [Lutaml::Uml::Document] Parsed document - # @param file_path [String] Path to the source file - # @return [Lutaml::Uml::Document] Processed document - def after_parse(document, file_path) - # Add QEA-specific metadata - add_qea_metadata(document, file_path) - - # Validate QEA-specific aspects - if @options[:validate_transformation] - validate_qea_transformation(document) - end - - # Add comprehensive statistics - add_transformation_statistics(document) - - document - end - - # Get default parsing options for QEA - # - # @return [Hash] Default options hash - def default_options - super.merge( - include_diagrams: true, - validate_transformation: false, - load_progress_callback: true, - cache_database: false, - strict_schema_validation: false, - ) - end - - private - - # Validate QEA file format - # - # @param file_path [String] Path to validate - # @raise [ParseError] if file is not valid QEA - def validate_qea_format!(file_path) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength - # Check if it's a SQLite database (QEA files are SQLite) - File.open(file_path, "rb") do |file| - header = file.read(16) - - unless header == "SQLite format 3\0" - add_error("File does not appear to be a SQLite database") - raise Parsers::ParseError.new("Invalid QEA format - " \ - "not a SQLite database") - end - end - - # Additional validation using QEA infrastructure - begin - Ea::Qea.connect(file_path).with_connection do |db| - # Check for required EA tables - required_tables = %w[t_object t_package t_connector t_attribute] - missing_tables = required_tables.reject do |table| - db.execute( - "SELECT name FROM sqlite_master " \ - "WHERE type='table' AND name=?", table - ).any? - end - - if missing_tables.any? - add_warning("Missing expected EA tables: " \ - "#{missing_tables.join(', ')}") - end - end - rescue StandardError => e - add_error("Failed to validate QEA database structure: #{e.message}") - raise Parsers::ParseError.new("QEA validation failed", - original_error: e) - end - end - - # Load QEA database with progress tracking - # - # @param file_path [String] Path to QEA file - # @return [Ea::Qea::Database] Loaded database - def load_qea_database(file_path) # rubocop:disable Metrics/MethodLength - progress_callback = nil - - if @options[:load_progress_callback] - progress_callback = create_progress_callback - end - - # Load database using existing QEA infrastructure - if progress_callback - Ea::Qea.load_database(file_path, &progress_callback) - else - Ea::Qea.load_database(file_path) - end - rescue StandardError => e - add_error("Failed to load QEA database: #{e.message}") - raise Parsers::ParseError.new("QEA database loading failed", - original_error: e) - end - - # Create progress callback for database loading - # - # @return [Proc] Progress callback procedure - def create_progress_callback - proc do |table_name, current, total| - percentage = (current.to_f / total * 100).round(1) - add_info("Loading #{table_name}: #{current}/#{total} " \ - "(#{percentage}%)") - - # Check if we should fail fast on too many errors - if should_fail_fast? && has_errors? - raise Parsers::ParseError.new("Failing fast due to errors " \ - "during loading") - end - end - end - - # Transform QEA database to UML document - # - # @param database [Ea::Qea::Database] QEA database - # @param file_path [String] Source file path - # @return [Lutaml::Uml::Document] UML document - def transform_qea_to_uml(database, file_path) # rubocop:disable Metrics/MethodLength - # Prepare transformation options - transform_options = prepare_transformation_options(file_path) - - # Use existing QEA factory for transformation - factory = Ea::Qea::Factory::EaToUmlFactory.new(database, - transform_options) - - # Apply custom transformers if configured - apply_custom_transformers(factory) if @options[:custom_transformers] - - # Execute transformation - factory.create_document - rescue StandardError => e - add_error("Failed to transform QEA to UML: #{e.message}") - raise Parsers::ParseError.new("QEA transformation failed", - original_error: e) - end - - # Prepare transformation options from parser configuration - # - # @param file_path [String] Source file path - # @return [Hash] Transformation options - def prepare_transformation_options(file_path) - { - include_diagrams: @options[:include_diagrams], - validate: @options[:validate_output], - document_name: extract_document_name(file_path), - } - end - - # Extract document name from file path - # - # @param file_path [String] File path - # @return [String] Document name - def extract_document_name(file_path) - @options[:document_name] || File.basename(file_path, ".*") - end - - # Apply custom transformers to factory - # - # @param factory [Ea::Qea::Factory::EaToUmlFactory] - # Factory to enhance - # @return [void] - def apply_custom_transformers(_factory) - # This is a placeholder for future custom transformer support - # Could allow configuration-driven transformer customization - add_info("Custom transformers would be applied here") - end - - # Post-process QEA document - # - # @param document [Lutaml::Uml::Document] Document to process - # @param file_path [String] Source file path - # @return [void] - def post_process_qea_document(document, file_path) - assign_if_supported(document, :source_file=, file_path) - assign_if_supported(document, :source_format=, "QEA") - assign_if_supported(document, :database_stats=, @database_stats) - end - - # Add QEA-specific metadata to document - # - # @param document [Lutaml::Uml::Document] Document to enhance - # @param file_path [String] Source file path - # @return [void] - def add_qea_metadata(document, file_path) # rubocop:disable Metrics/MethodLength - metadata = { - source_file: file_path, - source_format: "Enterprise Architect Database", - parsed_at: Time.now, - parser: self.class.name, - parser_version: "1.0", - database_stats: @database_stats, - qea_version: detect_qea_version, - options: @options, - } - - assign_if_supported(document, :parsing_metadata=, metadata) - assign_if_supported(document, :metadata=, metadata) - end - - # Detect QEA/EA version from database - # - # @return [String] Detected version or "unknown" - def detect_qea_version - return "unknown" unless @qea_database - - # This is a simplified version detection - # In practice, you might check specific tables or metadata - "EA Database" - end - - # Validate QEA transformation quality - # - # @param document [Lutaml::Uml::Document] Document to validate - # @return [void] - def validate_qea_transformation(document) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity - # Compare document stats with database stats - if @database_stats - doc_packages = document.packages&.size || 0 - db_packages = @database_stats["packages"] || 0 - - if doc_packages < db_packages - add_warning("Document has fewer packages (#{doc_packages}) " \ - "than database (#{db_packages})") - end - - doc_classes = document.classes&.size || 0 - db_objects = @database_stats["objects"] || 0 - - if doc_classes < db_objects * 0.8 # Allow some variance - add_warning("Document classes (#{doc_classes}) significantly " \ - "fewer than database objects (#{db_objects})") - end - end - end - - # Add comprehensive transformation statistics - # - # @param document [Lutaml::Uml::Document] Document to analyze - # @return [void] - def add_transformation_statistics(document) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity - doc_stats = { - packages: document.packages&.size || 0, - classes: document.classes&.size || 0, - data_types: document.data_types&.size || 0, - enumerations: document.enums&.size || 0, - associations: document.associations&.size || 0, - diagrams: document.diagrams&.size || 0, - } - - comparison = compare_stats_with_database(doc_stats) - - add_info("QEA transformation completed: " \ - "#{format_statistics(doc_stats)}") - if comparison.any? - add_info("Database comparison: #{comparison.join(', ')}") - end - end - - # Compare document statistics with database statistics - # - # @param doc_stats [Hash] Document statistics - # @return [Array] Comparison notes - def compare_stats_with_database(doc_stats) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength - return [] unless @database_stats - - comparisons = [] - - if @database_stats["packages"] - ratio = doc_stats[:packages].to_f / @database_stats["packages"] - comparisons << "packages #{(ratio * 100).round(1)}%" - end - - if @database_stats["objects"] - ratio = doc_stats[:classes].to_f / @database_stats["objects"] - comparisons << "classes #{(ratio * 100).round(1)}%" - end - - comparisons - end - - # Get quick database statistics without full loading - # - # @param file_path [String] Path to QEA file - # @return [Hash] Quick statistics - def get_quick_database_stats(file_path) - Ea::Qea.database_info(file_path) - end - - # Format database statistics for display - # - # @param stats [Hash] Statistics hash - # @return [String] Formatted string - def format_database_stats(stats) - return "unknown structure" if stats.empty? - - parts = [] - parts << "#{stats['objects']} objects" if stats["objects"] - parts << "#{stats['packages']} packages" if stats["packages"] - parts << "#{stats['connectors']} connectors" if stats["connectors"] - - parts.join(", ") - end - - end - end - end -end diff --git a/lib/ea/transformations/parsers/xmi_parser.rb b/lib/ea/transformations/parsers/xmi_parser.rb deleted file mode 100644 index 7b79b3d..0000000 --- a/lib/ea/transformations/parsers/xmi_parser.rb +++ /dev/null @@ -1,243 +0,0 @@ -# frozen_string_literal: true - - -module Ea - module Transformations - module Parsers - # XMI Parser implements the BaseParser interface for XML Metadata - # Interchange files. - # - # Delegates to Ea::Xmi::Parser for actual parsing. - class XmiParser < BaseParser - # Get parser format name - # - # @return [String] Human-readable format name - def format_name - "XML Metadata Interchange (XMI)" - end - - # Get list of supported file extensions - # - # @return [Array] List of extensions - def supported_extensions - [".xmi", ".xml"] - end - - def content_patterns - [/xmi:version/] - end - - def priority - 80 - end - - protected - - # Core parsing implementation for XMI files - # - # @param file_path [String] Path to the XMI file - # @return [Lutaml::Uml::Document] Parsed UML document - def parse_internal(file_path) - # Validate XMI file format - validate_xmi_format!(file_path) - - # Use Ea::Xmi::Parser for XMI parsing - document = Ea::Xmi::Parser.parse(File.new(file_path)) - - if document.nil? - add_error("No document found in XMI file") - raise Parsers::ParseError.new("Empty XMI file or parsing failed") - end - - # Post-process document if needed - post_process_xmi_document(document, file_path) - - document - end - - # Hook called before parsing starts - # - # @param file_path [String] Path to the file being parsed - # @return [void] - def before_parse(file_path) - add_info("Starting XMI parsing for: #{file_path}") - - # Check file size and warn if very large - file_size = File.size(file_path) - if file_size > 100 * 1024 * 1024 # 100MB - add_warning("Large XMI file detected " \ - "(#{format_file_size(file_size)}), " \ - "parsing may take time") - end - end - - # Hook called after parsing completes - # - # @param document [Lutaml::Uml::Document] Parsed document - # @param file_path [String] Path to the source file - # @return [Lutaml::Uml::Document] Processed document - def after_parse(document, file_path) - # Add metadata about the parsing process - add_parsing_metadata(document, file_path) - - # Validate references if requested - validate_references(document) if @options[:resolve_references] - - # Count elements and add statistics - add_parsing_statistics(document) - - document - end - - # Get default parsing options for XMI - # - # @return [Hash] Default options hash - def default_options - super.merge( - validate_xml: true, - resolve_references: true, - preserve_namespaces: true, - include_documentation: true, - ) - end - - private - - # Validate XMI file format - # - # @param file_path [String] Path to validate - # @raise [ParseError] if file is not valid XMI - def validate_xmi_format!(file_path) # rubocop:disable Metrics/MethodLength - # Quick validation by reading first few lines - File.open(file_path, "r") do |file| - header = file.read(1024) - - unless header.include?("] List of unresolved reference IDs - def find_unresolved_references(document) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength - unresolved = [] - - # This is a simplified implementation - # In practice, you would traverse the document structure - # and check for dangling references - - # Check class generalizations - document.classes&.each do |klass| - klass.generalizations&.each do |gen| - # Reference by ID that might be unresolved - if gen.general.is_a?(String) && !find_element_by_id(document, - gen.general) - unresolved << gen.general - end - end - end - - unresolved.uniq - end - - # Find element by ID in document - # - # @param document [Lutaml::Uml::Document] Document to search - # @param id [String] ID to find - # @return [Object, nil] Found element or nil - def find_element_by_id(document, id) - # Simplified implementation - in practice would use proper indexing - all_elements = [] - all_elements.concat(document.packages || []) - all_elements.concat(document.classes || []) - all_elements.concat(document.data_types || []) - all_elements.concat(document.enums || []) - - all_elements.find { |element| element.xmi_id == id } - end - - # Add parsing statistics - # - # @param document [Lutaml::Uml::Document] Document to analyze - # @return [void] - def add_parsing_statistics(document) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity - stats = { - packages: document.packages&.size || 0, - classes: document.classes&.size || 0, - data_types: document.data_types&.size || 0, - enumerations: document.enums&.size || 0, - associations: document.associations&.size || 0, - diagrams: document.diagrams&.size || 0, - } - - add_info("Parsed XMI successfully: #{format_statistics(stats)}") - end - - # Normalize package paths - # - # @param document [Lutaml::Uml::Document] Document to process - # @return [void] - def normalize_package_paths(_document) - # Implementation would normalize package path formats - # This is a placeholder for future enhancement - add_info("Package path normalization completed") - end - - end - end - end -end diff --git a/lib/ea/transformations/transformation_engine.rb b/lib/ea/transformations/transformation_engine.rb deleted file mode 100644 index f458033..0000000 --- a/lib/ea/transformations/transformation_engine.rb +++ /dev/null @@ -1,390 +0,0 @@ -# frozen_string_literal: true - -module Ea - module Transformations - # Transformation Engine orchestrates the entire model transformation - # process. - # - # This class implements the Facade pattern to provide a simple interface - # for complex model transformation operations. It coordinates between - # configuration, format detection, parser selection, and transformation. - # - # The engine follows the Dependency Inversion Principle by depending on - # abstractions (BaseParser interface) rather than concrete implementations. - # - # @example Basic usage - # engine = TransformationEngine.new - # document = engine.parse("model.xmi") - # - # @example With custom configuration - # config = Configuration.load("my_config.yml") - # engine = TransformationEngine.new(config) - # document = engine.parse("model.qea") - class TransformationEngine - # @return [Configuration] Current configuration - attr_reader :configuration - - # @return [FormatRegistry] Format registry - attr_reader :format_registry - - # @return [Array] Transformation history - attr_reader :transformation_history - - # @return [Parser] Parser instance - attr_reader :current_parser - - # Initialize transformation engine - # - # @param configuration [Configuration, nil] Configuration to use - # (defaults to auto-loaded configuration) - def initialize(configuration = nil) - @configuration = configuration || Configuration.load - @format_registry = FormatRegistry.new - @transformation_history = [] - @parser_cache = {} - - # Load parsers from configuration - setup_parsers - end - - # Parse a model file into a UML document - # - # This is the main entry point for model transformation. It auto-detects - # the file format and uses the appropriate parser. - # - # @param file_path [String] Path to the model file - # @param options [Hash] Parsing options (merged with configuration) - # @return [Lutaml::Uml::Document] Parsed UML document - # @raise [UnsupportedFormatError] if file format is not supported - # @raise [ParseError] if parsing fails - def parse(file_path, options = {}) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength - validate_file_path!(file_path) - - # Detect format and get parser - parser_class = detect_parser(file_path) - raise UnsupportedFormatError.new(file_path) unless parser_class - - # Create parser instance with merged options - merged_options = merge_options(options) - @current_parser = get_parser_instance(parser_class, merged_options) - - # Record transformation start - transformation_start = Time.now - - begin - # Perform parsing - document = @current_parser.parse(file_path) - - # Record successful transformation - record_transformation( - file_path: file_path, - parser: @current_parser, - duration: Time.now - transformation_start, - success: true, - document: document, - ) - - document - rescue StandardError => e - # Record failed transformation - record_transformation( - file_path: file_path, - parser: @current_parser, - duration: Time.now - transformation_start, - success: false, - error: e, - ) - - # Re-raise the error - raise - end - end - - # Auto-detect file format and return appropriate parser class - # - # Uses multiple detection strategies: - # 1. File extension - # 2. Content detection (magic bytes) - # 3. Fallback parser from configuration - # - # @param file_path [String] Path to the model file - # @return [Class, nil] Parser class, or nil if format cannot be detected - def detect_parser(file_path) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength - # Strategy 1: File extension detection - if @configuration.file_extension_detection_enabled? - parser_class = @format_registry.parser_for_file(file_path) - return parser_class if parser_class - end - - # Strategy 2: Content detection - if @configuration.content_sniffing_enabled? - parser_class = @format_registry.detect_by_content(file_path) - return parser_class if parser_class - end - - # Strategy 3: Fallback parser - fallback_parser_name = @configuration.fallback_parser - if fallback_parser_name - return Transformations.constantize(fallback_parser_name) - end - - nil - rescue StandardError - nil - end - - # Get list of supported file extensions - # - # @return [Array] List of supported extensions - def supported_extensions - @format_registry.supported_extensions - end - - # Check if a file format is supported - # - # @param file_path [String] Path to check - # @return [Boolean] true if format is supported - def supports_file?(file_path) - detect_parser(file_path) != nil - end - - # Register a custom parser for a file extension - # - # @param extension [String] File extension (e.g., ".custom") - # @param parser_class [Class] Parser class implementing BaseParser - # interface - # @return [void] - def register_parser(extension, parser_class) - @format_registry.register(extension, parser_class) - end - - # Unregister a parser for a file extension - # - # @param extension [String] File extension to unregister - # @return [Class, nil] The unregistered parser class - def unregister_parser(extension) - @format_registry.unregister(extension) - end - - # Set configuration and reload parsers - # - # @param config [Configuration] New configuration - # @return [void] - def configuration=(config) - @configuration = config - @parser_cache.clear - setup_parsers - end - - # Get comprehensive transformation statistics - # - # @return [Hash] Statistics about transformations - def statistics # rubocop:disable Metrics/MethodLength - successful_transformations = @transformation_history.count do |t| - t[:success] - end - failed_transformations = @transformation_history.count do |t| - !t[:success] - end - - { - total_transformations: @transformation_history.size, - successful_transformations: successful_transformations, - failed_transformations: failed_transformations, - success_rate: calculate_success_rate, - average_duration: calculate_average_duration, - supported_extensions: supported_extensions, - registered_parsers: @format_registry.all_parsers.keys, - configuration_version: @configuration.version, - } - end - - # Clear transformation history - # - # @return [void] - def clear_history - @transformation_history.clear - end - - # Get transformation history for a specific file - # - # @param file_path [String] Path to the file - # @return [Array] Transformation history entries for the file - def history_for_file(file_path) - @transformation_history.select do |entry| - entry[:file_path] == file_path - end - end - - # Get recent transformation failures - # - # @param limit [Integer] Maximum number of failures to return - # @return [Array] Recent failure entries - def recent_failures(limit = 10) - @transformation_history - .reject { |entry| entry[:success] } - .last(limit) - end - - # Validate configuration and parsers - # - # @return [Hash] Validation results - def validate_setup # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity - results = { - configuration_valid: false, - parsers_loaded: 0, - parser_errors: [], - warnings: [], - } - - # Validate configuration - begin - if @configuration&.enabled_parsers&.any? - results[:configuration_valid] = true - else - results[:warnings] << "No enabled parsers in configuration" - end - rescue StandardError => e - results[:parser_errors] << "Configuration error: #{e.message}" - end - - # Validate each parser - @format_registry.all_parsers.each_value do |parser_class| - # Try to create instance to validate - parser = parser_class.new(configuration: @configuration) - if parser.is_a?(Parsers::BaseParser) - results[:parsers_loaded] += 1 - else - results[:parser_errors] << "Parser #{parser_class} does not " \ - "implement parse method" - end - rescue StandardError => e - results[:parser_errors] << "Failed to instantiate #{parser_class}: " \ - "#{e.message}" - end - - results - end - - private - - # Setup parsers from configuration - # - # @return [void] - def setup_parsers - # Clear existing parsers - @format_registry.clear - - # Load parsers from configuration - @format_registry.load_from_configuration(@configuration) - - # Load default parsers if none configured - if @format_registry.supported_extensions.empty? - @format_registry.load_default_parsers - end - end - - # Get parser instance (with caching) - # - # @param parser_class [Class] Parser class - # @param options [Hash] Parser options - # @return [BaseParser] Parser instance - def get_parser_instance(parser_class, options) - cache_key = [parser_class, options.hash] - - @parser_cache[cache_key] ||= parser_class.new( - configuration: @configuration, - options: options, - ) - end - - # Merge options with configuration defaults - # - # @param options [Hash] User-provided options - # @return [Hash] Merged options - def merge_options(options) # rubocop:disable Metrics/MethodLength - default_options = {} - - if @configuration.transformation_options - default_options = { - validate_output: @configuration - .transformation_options.validate_output, - include_diagrams: @configuration - .transformation_options.include_diagrams, - preserve_ids: @configuration.transformation_options.preserve_ids, - resolve_references: @configuration - .transformation_options.resolve_references, - strict_mode: @configuration.transformation_options.strict_mode, - } - end - - default_options.merge(options) - end - - # Record transformation in history - # - # @param entry [Hash] Transformation entry - # @return [void] - def record_transformation(entry) - # Add timestamp and additional metadata - full_entry = entry.merge( - timestamp: Time.now, - engine_version: self.class.name, - configuration_version: @configuration.version, - ) - - @transformation_history.push(full_entry) - @transformation_history.shift if @transformation_history.size > 1000 - end - - # Calculate success rate - # - # @return [Float] Success rate as percentage - def calculate_success_rate - return 0.0 if @transformation_history.empty? - - successful = @transformation_history.count { |t| t[:success] } - (successful.to_f / @transformation_history.size * 100).round(2) - end - - # Calculate average transformation duration - # - # @return [Float] Average duration in seconds - def calculate_average_duration - return 0.0 if @transformation_history.empty? - - total_duration = @transformation_history.sum { |t| t[:duration] || 0 } - (total_duration / @transformation_history.size).round(3) - end - - # Validate file path - # - # @param file_path [String] File path to validate - # @raise [ArgumentError] if path is invalid - def validate_file_path!(file_path) - raise ArgumentError, "File path cannot be nil" if file_path.nil? - raise ArgumentError, "File path cannot be empty" if file_path.empty? - - unless File.exist?(file_path) - raise ArgumentError, - "File does not exist: #{file_path}" - end - end - end - - # Error class for unsupported file formats - class UnsupportedFormatError < Ea::Error - # @return [String] Path to the unsupported file - attr_reader :file_path - - # Initialize error - # - # @param file_path [String] Path to unsupported file - def initialize(file_path) - @file_path = file_path - extension = File.extname(file_path) - super("Unsupported file format: #{extension} (file: #{file_path})") - end - end - end -end diff --git a/spec/ea/cli/command/diagrams_spec.rb b/spec/ea/cli/command/diagrams_spec.rb index 4b91789..d174e22 100644 --- a/spec/ea/cli/command/diagrams_spec.rb +++ b/spec/ea/cli/command/diagrams_spec.rb @@ -9,19 +9,18 @@ let(:lur_path) { fixtures_path("basic_test.lur") } describe "list action" do - it "lists diagram names with types and GUIDs" do + it "lists diagram names with types" do output = capture_stdout do described_class.new(action: "list", file: qea_path, format: "table").call end expect(output).to include("name") expect(output).to include("type") - expect(output).to include("guid") end it "raises FileNotFound for missing files" do expect { described_class.new(action: "list", file: "/no/such.qea").call - }.to raise_error(Ea::Cli::FileNotFound) + }.to raise_error(Errno::ENOENT) end end diff --git a/spec/ea/diagram/configuration_spec.rb b/spec/ea/diagram/configuration_spec.rb deleted file mode 100644 index 1c2a33d..0000000 --- a/spec/ea/diagram/configuration_spec.rb +++ /dev/null @@ -1,402 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Ea::Diagram::Configuration do - let(:config_path) { "spec/fixtures/diagram_styles.yml" } - let(:config) { described_class.new(config_path) } - - # Helper to create a mock element with specified properties - def mock_element(name: nil, stereotype: nil, package_name: nil) - if package_name - el = Lutaml::Uml::Diagram.new(name: name, package_name: package_name) - else - st = if stereotype.is_a?(Array) - stereotype - else - (stereotype ? [stereotype] : []) - end - el = Lutaml::Uml::UmlClass.new(name: name, stereotype: st) - end - el - end - - before do - # Create a test configuration file - FileUtils.mkdir_p("spec/fixtures") - File.write("spec/fixtures/diagram_styles.yml", <<~YAML) - defaults: - colors: - background: "#FFFFFF" - default_fill: "#E0E0E0" - default_stroke: "#000000" - fonts: - default: - family: "Arial, sans-serif" - size: 9 - weight: 400 - class_name: - family: "Arial, sans-serif" - size: 9 - weight: 700 - box: - stroke_width: 2 - padding: 5 - - stereotypes: - DataType: - colors: - fill: "#FFCCFF" - stroke: "#000000" - fonts: - class_name: - weight: 700 - style: italic - - FeatureType: - colors: - fill: "#FFFFCC" - - packages: - "CityGML::*": - colors: - fill: "#FFFFCC" - - "i-UR::*": - colors: - fill: "#FFCCFF" - - classes: - SpecialClass: - colors: - fill: "#FF0000" - - connectors: - generalization: - arrow: - type: hollow_triangle - size: 10 - line: - stroke_width: 1 - - legend: - enabled: true - position: bottom_right - YAML - end - - after do - FileUtils.rm_f("spec/fixtures/diagram_styles.yml") - end - - describe "#initialize" do - it "loads configuration from file", :aggregate_failures do - expect(config.config_data).to be_a(Hash) - expect(config.config_data["defaults"]).to be_a(Hash) - end - - it "uses built-in defaults when no file exists", :aggregate_failures do - config_no_file = described_class.new("nonexistent.yml") - expect(config_no_file.config_data["defaults"]).to be_a(Hash) - expect(config_no_file.config_data["defaults"]["colors"]["background"]) - .to eq("#FFFFFF") - end - - it "merges configuration with defaults", :aggregate_failures do - expect(config.config_data["defaults"]["colors"]["background"]) - .to eq("#FFFFFF") - expect(config.config_data["stereotypes"]["DataType"]["colors"]["fill"]) - .to eq("#FFCCFF") - end - end - - describe "#style_for" do - context "with class-specific override" do - it "returns class-specific style (highest priority)" do - element = mock_element(name: "SpecialClass", stereotype: ["DataType"]) - fill_color = config.style_for(element, "colors.fill") - expect(fill_color).to eq("#FF0000") - end - end - - context "with package-based styling" do - it "returns package-specific style for exact match" do - element = mock_element(name: "MyClass", package_name: "CityGML::Core") - fill_color = config.style_for(element, "colors.fill") - expect(fill_color).to eq("#FFFFCC") - end - - it "returns package-specific style for wildcard match" do - element = mock_element(name: "MyClass", package_name: "i-UR::DataTypes") - fill_color = config.style_for(element, "colors.fill") - expect(fill_color).to eq("#FFCCFF") - end - - it "does not match unrelated packages" do - element = mock_element(name: "MyClass", package_name: "Other::Package") - fill_color = config.style_for(element, "colors.fill") - expect(fill_color).to eq("#E0E0E0") # Falls back to default - end - end - - context "with stereotype-based styling" do - it "returns stereotype-specific style" do - element = mock_element(name: "MyClass", stereotype: ["DataType"]) - fill_color = config.style_for(element, "colors.fill") - expect(fill_color).to eq("#FFCCFF") - end - - it "handles multiple stereotypes" do - element = mock_element(name: "MyClass", - stereotype: [ - "DataType", "Abstract" - ]) - fill_color = config.style_for(element, "colors.fill") - expect(fill_color).to eq("#FFCCFF") # First matching stereotype - end - - it "returns nested stereotype properties" do - element = mock_element(name: "MyClass", stereotype: ["DataType"]) - font_weight = config.style_for(element, "fonts.class_name.weight") - expect(font_weight).to eq(700) - end - end - - context "with default values" do - it "returns default when no overrides exist" do - element = mock_element(name: "PlainClass") - fill_color = config.style_for(element, "colors.fill") - expect(fill_color).to eq("#E0E0E0") - end - - it "returns nested default values" do - element = mock_element(name: "PlainClass") - font_family = config.style_for(element, "fonts.default.family") - expect(font_family).to eq("Arial, sans-serif") - end - end - - context "with priority resolution" do - it "prioritizes class > package > stereotype > defaults" do - # Class-specific wins over stereotype - element = mock_element( - name: "SpecialClass", - stereotype: ["DataType"], - package_name: "CityGML::Core", - ) - fill_color = config.style_for(element, "colors.fill") - expect(fill_color).to eq("#FF0000") # Class-specific, not DataType pink - end - - it "prioritizes package > stereotype when no class override" do - element = mock_element( - name: "RegularClass", - stereotype: ["DataType"], - package_name: "CityGML::Core", - ) - fill_color = config.style_for(element, "colors.fill") - expect(fill_color).to eq("#FFFFCC") # Package yellow, not DataType pink - end - - it "prioritizes stereotype > defaults when no package/class override" do - element = mock_element(name: "RegularClass", stereotype: ["DataType"]) - fill_color = config.style_for(element, "colors.fill") - expect(fill_color).to eq("#FFCCFF") # DataType pink, not default gray - end - end - - context "with edge cases" do - it "returns nil for nil property" do - element = mock_element(name: "MyClass") - expect(config.style_for(element, nil)).to be_nil - end - - it "returns nil for empty property" do - element = mock_element(name: "MyClass") - expect(config.style_for(element, "")).to be_nil - end - - it "returns nil for non-existent property" do - element = mock_element(name: "MyClass") - expect(config.style_for(element, "nonexistent.property")).to be_nil - end - end - end - - describe "#connector_style" do - it "returns connector style for type" do - arrow_type = config.connector_style("generalization", "arrow.type") - expect(arrow_type).to eq("hollow_triangle") - end - - it "returns nested connector properties" do - stroke_width = config.connector_style("generalization", - "line.stroke_width") - expect(stroke_width).to eq(1) - end - - it "returns nil for non-existent connector type" do - result = config.connector_style("nonexistent", "arrow.type") - expect(result).to be_nil - end - end - - describe "#legend_config" do - it "returns legend configuration", :aggregate_failures do - legend = config.legend_config - expect(legend).to be_a(Hash) - expect(legend["enabled"]).to be true - expect(legend["position"]).to eq("bottom_right") - end - - it "returns empty hash when legend not configured" do - config_no_legend = described_class.new - legend = config_no_legend.legend_config - expect(legend).to be_a(Hash) - end - end - - describe "#to_h" do - it "returns complete configuration data", :aggregate_failures do - data = config.to_h - expect(data).to be_a(Hash) - expect(data).to have_key("defaults") - expect(data).to have_key("stereotypes") - end - end - - describe "private methods" do - describe "#matches_package?" do - it "matches exact package names" do - result = config.matches_package?("CityGML::Core", "CityGML::Core") - expect(result).to be true - end - - it "matches wildcard patterns" do - result = config.matches_package?("CityGML::Core", "CityGML::*") - expect(result).to be true - end - - it "does not match unrelated packages" do - result = config.matches_package?("Other::Package", "CityGML::*") - expect(result).to be false - end - - it "handles nil package names" do - result = config.matches_package?(nil, "CityGML::*") - expect(result).to be false - end - - it "handles nil patterns" do - result = config.matches_package?("CityGML::Core", nil) - expect(result).to be false - end - - it "handles complex wildcard patterns" do - result = config.matches_package?("CityGML::Core::Feature", - "CityGML::*") - expect(result).to be true - end - end - - describe "#dig_hash" do - it "navigates nested hashes with dot notation" do - hash = { "a" => { "b" => { "c" => "value" } } } - result = config.dig_hash(hash, "a.b.c") - expect(result).to eq("value") - end - - it "returns nil for non-existent paths" do - hash = { "a" => { "b" => "value" } } - result = config.dig_hash(hash, "a.x.y") - expect(result).to be_nil - end - - it "returns nil for nil path" do - hash = { "a" => "value" } - result = config.dig_hash(hash, nil) - expect(result).to be_nil - end - - it "returns nil for empty path" do - hash = { "a" => "value" } - result = config.dig_hash(hash, "") - expect(result).to be_nil - end - end - - describe "#deep_merge" do - it "merges nested hashes", :aggregate_failures do - hash1 = { "a" => { "b" => 1, "c" => 2 } } - hash2 = { "a" => { "b" => 3, "d" => 4 } } - result = config.deep_merge(hash1, hash2) - - expect(result["a"]["b"]).to eq(3) # Overridden - expect(result["a"]["c"]).to eq(2) # Preserved from hash1 - expect(result["a"]["d"]).to eq(4) # Added from hash2 - end - - it "handles non-hash values" do - hash1 = { "a" => 1 } - hash2 = { "a" => 2 } - result = config.deep_merge(hash1, hash2) - - expect(result["a"]).to eq(2) - end - - it "preserves keys from both hashes" do - hash1 = { "a" => 1, "b" => 2 } - hash2 = { "c" => 3 } - result = config.deep_merge(hash1, hash2) - - expect(result).to eq({ "a" => 1, "b" => 2, "c" => 3 }) - end - end - end - - describe "configuration file loading" do - it "handles YAML parse errors gracefully" do - FileUtils.mkdir_p("spec/fixtures") - File.write("spec/fixtures/invalid.yml", "invalid: yaml: content:") - - expect do - described_class.new("spec/fixtures/invalid.yml") - end.not_to raise_error - - FileUtils.rm_f("spec/fixtures/invalid.yml") - end - - it "prioritizes user config over defaults" do - expect(config.config_data["defaults"]["colors"]["background"]) - .to eq("#FFFFFF") - end - end - - describe "built-in stereotypes" do - let(:config_defaults) { described_class.new } - - it "has DataType stereotype configured" do - element = mock_element(name: "MyType", stereotype: ["DataType"]) - fill_color = config_defaults.style_for(element, "colors.fill") - expect(fill_color).to eq("#FFCCFF") - end - - it "has FeatureType stereotype configured" do - element = mock_element(name: "MyFeature", stereotype: ["FeatureType"]) - fill_color = config_defaults.style_for(element, "colors.fill") - expect(fill_color).to eq("#FFFFCC") - end - - it "has GMLType stereotype configured" do - element = mock_element(name: "MyGML", stereotype: ["GMLType"]) - fill_color = config_defaults.style_for(element, "colors.fill") - expect(fill_color).to eq("#CCFFCC") - end - - it "has Interface stereotype configured" do - element = mock_element(name: "MyInterface", stereotype: ["Interface"]) - fill_color = config_defaults.style_for(element, "colors.fill") - expect(fill_color).to eq("#FFFFEE") - end - end -end diff --git a/spec/ea/diagram/element_renderers/base_renderer_spec.rb b/spec/ea/diagram/element_renderers/base_renderer_spec.rb deleted file mode 100644 index 19618fc..0000000 --- a/spec/ea/diagram/element_renderers/base_renderer_spec.rb +++ /dev/null @@ -1,252 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Ea::Diagram::ElementRenderers::BaseRenderer do - let(:style_resolver) { Ea::Diagram::StyleResolver.new } - - # Use a real Struct instead of a double (project rule: no doubles) - ElementStub = Struct.new(:name, :package_name, :stereotype, keyword_init: true) - - let(:element_data) do - { - id: "test-1", - type: "class", - name: "TestClass", - x: 100, - y: 50, - width: 120, - height: 80, - element: ElementStub.new(name: "TestClass", - package_name: nil, - stereotype: nil), - diagram_object: nil, - } - end - let(:renderer) { described_class.new(element_data, style_resolver) } - - describe "#initialize" do - it "stores element data" do - expect(renderer.element).to eq(element_data) - end - - it "stores style resolver" do - expect(renderer.style_resolver).to eq(style_resolver) - end - end - - describe "#render" do - it "returns SVG group element", :aggregate_failures do - svg = renderer.render - - expect(svg).to include("") - end - - it "includes element type in class attribute" do - svg = renderer.render - - expect(svg) - .to include('class="lutaml-diagram-element lutaml-diagram-class"') - end - - it "includes data attributes for element ID and type", - :aggregate_failures do - svg = renderer.render - - expect(svg).to include('data-element-id="test-1"') - expect(svg).to include('data-element-type="class"') - end - end - - describe "#render_shape" do - it "returns empty string by default" do - shape = renderer.render_shape({}) - - expect(shape).to eq("") - end - - it "is intended to be overridden in subclasses" do - expect(renderer.render_shape({})).to be_a(String) - end - end - - describe "#render_label" do - let(:style) do - { - font_family: "Arial, sans-serif", - font_size: 12, - font_weight: 700, - } - end - - it "returns SVG text element", :aggregate_failures do - label = renderer.render_label(style) - - expect(label).to include("") - end - - it "centers text at element center", :aggregate_failures do - label = renderer.render_label(style) - - expect(label).to include('x="160"') - expect(label).to include('y="95"') - end - - it "includes text-anchor middle" do - label = renderer.render_label(style) - - expect(label).to include('text-anchor="middle"') - end - - it "includes dominant-baseline middle" do - label = renderer.render_label(style) - - expect(label).to include('dominant-baseline="middle"') - end - - it "applies font family from style" do - label = renderer.render_label(style) - - expect(label).to include('font-family="Arial, sans-serif"') - end - - it "applies font size from style" do - label = renderer.render_label(style) - - expect(label).to include('font-size="12"') - end - - it "applies font weight from style" do - label = renderer.render_label(style) - - expect(label).to include('font-weight="700"') - end - - it "defaults font weight to normal when not in style" do - label = renderer.render_label({}) - - expect(label).to include('font-weight="normal"') - end - - it "includes element name as text content" do - label = renderer.render_label(style) - - expect(label).to include("TestClass") - end - - it "escapes text content" do - element_data[:name] = "Test& \"Name\"" - label = renderer.render_label(style) - - expect(label).to include("Test<Class>& "Name"") - end - - it "uses default text color when not in style" do - label = renderer.render_label(style) - - expect(label).to include('fill="#000000"') - end - - it "applies text color from style" do - style[:text_color] = "#FF0000" - label = renderer.render_label(style) - - expect(label).to include('fill="#FF0000"') - end - - it "includes lutaml-diagram-label class" do - label = renderer.render_label(style) - - expect(label).to include('class="lutaml-diagram-label"') - end - - it "returns empty string when element has no name" do - element_data[:name] = nil - label = renderer.render_label(style) - - expect(label).to eq("") - end - - it "handles missing dimensions by using defaults" do - element_data.delete(:width) - element_data.delete(:height) - label = renderer.render_label(style) - - expect(label).to include(" B") - - expect(result).to eq("A > B") - end - - it "escapes double quotes" do - result = renderer.escape_text('Say "Hello"') - - expect(result).to eq("Say "Hello"") - end - - it "escapes single quotes" do - result = renderer.escape_text("It's working") - - expect(result).to eq("It's working") - end - - it "escapes multiple special characters" do - result = renderer.escape_text("") - - expect(result) - .to eq("<tag attr="value" & 'more'>") - end - - it "returns empty string for nil input" do - result = renderer.escape_text(nil) - - expect(result).to eq("") - end - - it "handles empty string" do - result = renderer.escape_text("") - - expect(result).to eq("") - end - - it "converts non-string input to string" do - result = renderer.escape_text(123) - - expect(result).to eq("123") - end - - it "does not modify text without special characters" do - result = renderer.escape_text("Plain text") - - expect(result).to eq("Plain text") - end - end -end diff --git a/spec/ea/diagram/element_renderers/class_renderer_spec.rb b/spec/ea/diagram/element_renderers/class_renderer_spec.rb deleted file mode 100644 index 5c944a5..0000000 --- a/spec/ea/diagram/element_renderers/class_renderer_spec.rb +++ /dev/null @@ -1,546 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Ea::Diagram::ElementRenderers::ClassRenderer do - let(:style_resolver) { Ea::Diagram::StyleResolver.new } - let(:element_data) do - { - id: "class-1", - type: "class", - name: "Person", - x: 100, - y: 50, - width: 120, - height: 80, - stereotype: nil, - attributes: [], - operations: [], - element: double("Element", - name: "Person", - package_name: nil, - stereotype: nil), - diagram_object: nil, - } - end - let(:renderer) { described_class.new(element_data, style_resolver) } - - describe "inheritance" do - it "inherits from BaseRenderer" do - expect(described_class).to be < Ea::Diagram::ElementRenderers::BaseRenderer - end - end - - describe "#render_shape" do - let(:style) do - { - fill: "#E0E0E0", - stroke: "#000000", - stroke_width: 2, - stroke_linecap: "round", - stroke_linejoin: "bevel", - corner_radius: 0, - fill_opacity: "1.00", - stroke_opacity: "1.00", - } - end - - it "renders rectangle for class box", :aggregate_failures do - shape = renderer.render_shape(style) - - expect(shape).to include("= name_height(25) + attributes_height(40) + - # operations_height(0) - expect(shape).to match(/height="\d+"/) - end - end - - context "with operations" do - before do - element_data[:operations] = [ - { name: "getName", return_type: "String", visibility: "public" }, - ] - end - - it "includes operations compartment separator" do - shape = renderer.render_shape(style) - - # Operations height = 1 * 15 + 10 = 25 - # Should have a separator path - expect(shape).to include("") - end - end - - describe "private methods" do - describe "#calculate_attributes_height" do - it "returns 0 when no attributes" do - height = renderer.calculate_attributes_height() - - expect(height).to eq(0) - end - - it "calculates height based on number of attributes" do - element_data[:attributes] = [{}, {}, {}] # 3 attributes - - height = renderer.calculate_attributes_height() - - expect(height).to eq(55) # 3 * 15 + 10 - end - - it "returns 0 for empty attributes array" do - element_data[:attributes] = [] - - height = renderer.calculate_attributes_height() - - expect(height).to eq(0) - end - end - - describe "#calculate_operations_height" do - it "returns 0 when no operations" do - height = renderer.calculate_operations_height() - - expect(height).to eq(0) - end - - it "calculates height based on number of operations" do - element_data[:operations] = [{}, {}] # 2 operations - - height = renderer.calculate_operations_height() - - expect(height).to eq(40) # 2 * 15 + 10 - end - - it "returns 0 for empty operations array" do - element_data[:operations] = [] - - height = renderer.calculate_operations_height() - - expect(height).to eq(0) - end - end - - describe "#format_attribute" do - it "formats attribute with visibility, name, and type" do - attr = { name: "id", type: "Integer", visibility: "private" } - - result = renderer.format_attribute(attr) - - expect(result).to eq("-id: Integer") - end - - it "handles missing type" do - attr = { name: "name", visibility: "public" } - - result = renderer.format_attribute(attr) - - expect(result).to eq("+name") - end - - it "handles missing visibility" do - attr = { name: "value", type: "String" } - - result = renderer.format_attribute(attr) - - expect(result).to eq("value: String") - end - - it "handles plain string attribute" do - result = renderer.format_attribute("name") - - expect(result).to eq("name") - end - end - - describe "#format_operation" do - it "formats operation with visibility, name, parameters, " \ - "and return type" do - op = { - name: "calculate", - visibility: "public", - parameters: [{ name: "x", type: "Integer" }], - return_type: "Boolean", - } - - result = renderer.format_operation(op) - - expect(result).to eq("+calculate(x: Integer): Boolean") - end - - it "handles missing return type" do - op = { name: "doSomething", visibility: "public", parameters: [] } - - result = renderer.format_operation(op) - - expect(result).to eq("+doSomething()") - end - - it "handles multiple parameters" do - op = { - name: "add", - visibility: "public", - parameters: [ - { name: "a", type: "Integer" }, - { name: "b", type: "Integer" }, - ], - return_type: "Integer", - } - - result = renderer.format_operation(op) - - expect(result).to eq("+add(a: Integer, b: Integer): Integer") - end - - it "handles plain string operation" do - result = renderer.format_operation("method()") - - expect(result).to eq("method()") - end - end - - describe "#format_parameters" do - it "formats array of parameter hashes" do - params = [ - { name: "x", type: "Integer" }, - { name: "y", type: "String" }, - ] - - result = renderer.format_parameters(params) - - expect(result).to eq("x: Integer, y: String") - end - - it "handles plain string parameters" do - params = ["param1", "param2"] - - result = renderer.format_parameters(params) - - expect(result).to eq("param1, param2") - end - - it "handles empty parameters" do - result = renderer.format_parameters([]) - - expect(result).to eq("") - end - - it "handles mixed hash and string parameters" do - params = [{ name: "x", type: "Integer" }, "other"] - - result = renderer.format_parameters(params) - - expect(result).to eq("x: Integer, other") - end - end - - describe "#visibility_symbol" do - it "returns + for public" do - symbol = renderer.visibility_symbol("public") - - expect(symbol).to eq("+") - end - - it "returns - for private" do - symbol = renderer.visibility_symbol("private") - - expect(symbol).to eq("-") - end - - it "returns # for protected" do - symbol = renderer.visibility_symbol("protected") - - expect(symbol).to eq("#") - end - - it "returns ~ for package" do - symbol = renderer.visibility_symbol("package") - - expect(symbol).to eq("~") - end - - it "returns empty string for unknown visibility" do - symbol = renderer.visibility_symbol("unknown") - - expect(symbol).to eq("") - end - - it "returns empty string for nil visibility" do - symbol = renderer.visibility_symbol(nil) - - expect(symbol).to eq("") - end - - it "handles symbol input" do - symbol = renderer.visibility_symbol(:public) - - expect(symbol).to eq("+") - end - end - - describe "#render_text_element" do - let(:style) do - { - font_family: "Arial", - font_size: 12, - font_weight: 700, - font_style: "italic", - } - end - - it "renders SVG text element", :aggregate_failures do - text = renderer.render_text_element("Test", 100, 50, style, - "test-class") - - expect(text).to include("") - end - - it "positions text at specified coordinates", :aggregate_failures do - text = renderer.render_text_element("Test", 100, 50, style, - "test-class") - - expect(text).to include(/x="100/) - expect(text).to include(/y="50/) - end - - it "includes CSS class" do - text = renderer.render_text_element("Test", 100, 50, style, - "my-class") - - expect(text).to include('class="my-class"') - end - - it "applies font styles", :aggregate_failures do - text = renderer.render_text_element("Test", 100, 50, style, - "test-class") - - expect(text).to include("font-family:Arial") - expect(text).to include("font-weight:700") - expect(text).to include("font-style:italic") - expect(text).to include("font-size:12") - end - - it "escapes text content" do - text = renderer.render_text_element("", 100, 50, style, - "test-class") - - expect(text).to include("<tag>") - end - - it "returns empty string for nil text" do - text = renderer.render_text_element(nil, 100, 50, style, - "test-class") - - expect(text).to eq("") - end - - it "accepts custom options" do - text = renderer.render_text_element( - "Test", 100, 50, style, "test-class", - font_size: "14pt", - text_anchor: "end", - fill: "#FF0000") - - expect(text).to include("font-size:14pt") - expect(text).to include('text-anchor="end"') - expect(text).to include("fill:#FF0000") - end - end - end -end diff --git a/spec/ea/diagram/element_renderers/connector_renderer_spec.rb b/spec/ea/diagram/element_renderers/connector_renderer_spec.rb deleted file mode 100644 index 15fbce8..0000000 --- a/spec/ea/diagram/element_renderers/connector_renderer_spec.rb +++ /dev/null @@ -1,215 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Ea::Diagram::ElementRenderers::ConnectorRenderer do - let(:style_resolver) { Ea::Diagram::StyleResolver.new } - let(:connector_data) do - { - id: "conn-1", - type: "association", - source: "elem-1", - target: "elem-2", - geometry: "SX=10;SY=5;EX=-10;EY=-5;EDGE=1;", - } - end - let(:source_element) do - { id: "elem-1", x: 100, y: 50, width: 120, height: 80 } - end - let(:target_element) do - { id: "elem-2", x: 400, y: 200, width: 150, height: 100 } - end - let(:renderer) do - described_class.new(connector_data, style_resolver, source_element, - target_element) - end - - describe "inheritance" do - it "inherits from BaseRenderer" do - expect(described_class).to be < Ea::Diagram::ElementRenderers::BaseRenderer - end - end - - describe "#initialize" do - it "calls super to initialize element and style_resolver" do - expect(renderer.element).to eq(connector_data) - expect(renderer.style_resolver).to eq(style_resolver) - end - - it "stores source element" do - expect(renderer.source_element).to eq(source_element) - end - - it "stores target element" do - expect(renderer.target_element).to eq(target_element) - end - - it "accepts nil source element" do - renderer_no_source = described_class.new(connector_data, - style_resolver, - nil, target_element) - expect(renderer_no_source.source_element).to be_nil - end - - it "accepts nil target element" do - renderer_no_target = described_class.new(connector_data, - style_resolver, - source_element, nil) - expect(renderer_no_target.target_element).to be_nil - end - end - - describe "#render" do - it "returns SVG path element", :aggregate_failures do - svg = renderer.render - - expect(svg).to include("= 0 - expect(result[:diagrams]).to be_an(Array) - end - - it "includes diagram details" do - result = extractor.list_diagrams(lur_path) - - if result[:diagrams].any? - diagram = result[:diagrams].first - expect(diagram).to include( - :xmi_id, - :name, - :type, - :package, - :objects, - :links, - ) - end - end - end - end - - describe "#extract_batch" do - let(:diagram_ids) { ["diagram1", "diagram2", "diagram3"] } - let(:output_dir) { File.join(temp_dir, "diagrams") } - - context "with non-existent file" do - it "returns failure result" do - result = extractor.extract_batch("nonexistent.lur", diagram_ids) - - expect(result[:success]).to be false - end - end - - context "with valid LUR file", :requires_fixtures do - it "creates output directory if it doesn't exist", :aggregate_failures do - expect(Dir.exist?(output_dir)).to be false - - extractor.extract_batch(lur_path, diagram_ids, output_dir: output_dir) - - expect(Dir.exist?(output_dir)).to be true - end - - it "extracts multiple diagrams", :aggregate_failures do - result = extractor.extract_batch(lur_path, diagram_ids, - output_dir: output_dir) - - expect(result[:results]).to be_an(Array) - expect(result[:results].size).to eq(diagram_ids.size) - expect(result[:summary]).to include(:total, :successful, :failed) - end - - it "returns success when all diagrams extracted" do - # This test depends on fixture data - result = extractor.extract_batch(lur_path, [], output_dir: output_dir) - - expect(result[:summary][:total]).to eq(0) - end - end - end - - describe "private methods" do - describe "#sanitize_filename" do - it "replaces invalid characters with underscores" do - result = extractor.sanitize_filename("My Diagram/Name: Test") - - expect(result).to eq("My_Diagram_Name__Test") - end - - it "preserves valid characters" do - result = extractor.sanitize_filename("valid_diagram-123") - - expect(result).to eq("valid_diagram-123") - end - end - - describe "#format_cardinality" do - it "formats cardinality with to_s" do - cardinality = double(to_s: "1..*") - result = extractor.format_cardinality(cardinality) - - expect(result).to eq("1..*") - end - - it "returns empty string for nil" do - result = extractor.format_cardinality(nil) - - expect(result).to eq("") - end - end - - describe "#element_type" do - it "returns correct type for Class" do - klass = Lutaml::Uml::UmlClass.new(name: "TestClass") - result = extractor.element_type(klass) - - expect(result).to eq("class") - end - - it "returns correct type for Package" do - package = Lutaml::Uml::Package.new(name: "TestPackage") - result = extractor.element_type(package) - - expect(result).to eq("package") - end - - it "returns correct type for DataType" do - datatype = Lutaml::Uml::DataType.new(name: "TestDataType") - result = extractor.element_type(datatype) - - expect(result).to eq("datatype") - end - - it "returns correct type for Enum" do - enum = Lutaml::Uml::Enum.new(name: "TestEnum") - result = extractor.element_type(enum) - - expect(result).to eq("enumeration") - end - - it "returns unknown for other types" do - other = Object.new - result = extractor.element_type(other) - - expect(result).to eq("unknown") - end - end - - describe "#connector_type" do - it "returns correct type for Association" do - assoc = Lutaml::Uml::Association.new - result = extractor.connector_type(assoc) - - expect(result).to eq("association") - end - - it "returns connector for unknown types" do - other = Object.new - result = extractor.connector_type(other) - - expect(result).to eq("connector") - end - end - - describe "#default_output_path" do - it "generates path from diagram name" do - diagram = double(name: "Test Diagram") - result = extractor.default_output_path(diagram) - - expect(result).to eq("Test_Diagram.svg") - end - end - end -end diff --git a/spec/ea/diagram/layout_engine_spec.rb b/spec/ea/diagram/layout_engine_spec.rb deleted file mode 100644 index 33ef563..0000000 --- a/spec/ea/diagram/layout_engine_spec.rb +++ /dev/null @@ -1,433 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Ea::Diagram::LayoutEngine do - let(:engine) { described_class.new } - let(:custom_engine) do - described_class.new( - spacing: 100, - element_width: 200, - element_height: 150, - ) - end - - describe "#initialize" do - it "uses default values when no options provided", :aggregate_failures do - expect(engine.spacing).to eq(50) - expect(engine.element_width).to eq(120) - expect(engine.element_height).to eq(80) - end - - it "accepts custom spacing value" do - expect(custom_engine.spacing).to eq(100) - end - - it "accepts custom element dimensions", :aggregate_failures do - expect(custom_engine.element_width).to eq(200) - expect(custom_engine.element_height).to eq(150) - end - end - - describe "#calculate_bounds" do - context "with no elements" do - it "returns default bounds" do - diagram_data = { elements: [], connectors: [] } - - result = engine.calculate_bounds(diagram_data) - - expect(result).to eq({ - x: 0, - y: 0, - width: 400, - height: 300, - }) - end - end - - context "with single element" do - it "calculates bounds including padding", :aggregate_failures do - diagram_data = { - elements: [ - { id: "1", x: 100, y: 50, width: 200, height: 100 }, - ], - connectors: [], - } - - result = engine.calculate_bounds(diagram_data) - - # Should include element plus padding (5% or 20px, whichever larger) - expect(result[:x]).to be < 100 - expect(result[:y]).to be < 50 - expect(result[:x] + result[:width]).to be > 300 # 100 + 200 - expect(result[:y] + result[:height]).to be > 150 # 50 + 100 - end - end - - context "with multiple elements" do - it "calculates bounds including all elements", :aggregate_failures do - diagram_data = { - elements: [ - { id: "1", x: 100, y: 50, width: 200, height: 100 }, - { id: "2", x: 400, y: 200, width: 150, height: 80 }, - ], - connectors: [], - } - - result = engine.calculate_bounds(diagram_data) - - # Should include all elements plus padding - expect(result[:x]).to be < 100 - expect(result[:y]).to be < 50 - expect(result[:x] + result[:width]).to be > 550 # 400 + 150 - expect(result[:y] + result[:height]).to be > 280 # 200 + 80 - end - end - - context "with connectors" do - it "includes connector endpoints in bounds", :aggregate_failures do - source_element = { id: "1", x: 100, y: 50, width: 200, height: 100 } - target_element = { id: "2", x: 400, y: 200, width: 150, height: 80 } - - diagram_data = { - elements: [source_element, target_element], - connectors: [ - { - id: "c1", - geometry: "SX=50;SY=25;EX=-30;EY=20;EDGE=1;", - source_element: source_element, - target_element: target_element, - }, - ], - } - - result = engine.calculate_bounds(diagram_data) - - # Connector endpoints: - # Source: (100 + 200/2, 50 + 100/2) + (50, 25) = (250, 125) - # Target: (400 + 150/2, 200 + 80/2) + (-30, 20) = (445, 260) - # Both should be within bounds - expect(result[:x]).to be <= 100 - expect(result[:y]).to be <= 50 - expect(result[:x] + result[:width]).to be >= 550 - expect(result[:y] + result[:height]).to be >= 280 - end - - it "handles connectors without geometry gracefully" do - diagram_data = { - elements: [ - { id: "1", x: 100, y: 50, width: 200, height: 100 }, - ], - connectors: [ - { id: "c1", geometry: nil }, - ], - } - - expect { engine.calculate_bounds(diagram_data) }.not_to raise_error - end - end - - context "with negative coordinates" do - it "normalizes negative coordinates before calculating bounds", - :aggregate_failures do - diagram_data = { - elements: [ - { id: "1", x: -100, y: -50, width: 200, height: 100 }, - { id: "2", x: 50, y: 30, width: 150, height: 80 }, - ], - connectors: [], - } - - result = engine.calculate_bounds(diagram_data) - - # After normalization, first element should be at (0, 0) - # Bounds should start from negative padding value - expect(result[:x]).to be < 0 - expect(result[:y]).to be < 0 - end - end - - context "with padding calculation" do - it "uses 5% padding for large diagrams", :aggregate_failures do - diagram_data = { - elements: [ - { id: "1", x: 0, y: 0, width: 1000, height: 800 }, - ], - connectors: [], - } - - result = engine.calculate_bounds(diagram_data) - - # 5% of 1000 = 50, so padding should be 50 - expect(result[:x]).to eq(-50) - expect(result[:y]).to eq(-40) # 5% of 800 = 40 - end - - it "uses minimum 20px padding for small diagrams", :aggregate_failures do - diagram_data = { - elements: [ - { id: "1", x: 0, y: 0, width: 100, height: 80 }, - ], - connectors: [], - } - - result = engine.calculate_bounds(diagram_data) - - # 5% of 100 = 5, but minimum is 20 - expect(result[:x]).to eq(-20) - expect(result[:y]).to eq(-20) - end - end - end - - describe "#apply_layout" do - context "with all positioned elements" do - it "returns elements unchanged" do - elements = [ - { id: "1", x: 100, y: 50 }, - { id: "2", x: 300, y: 150 }, - ] - - result = engine.apply_layout(elements, []) - - expect(result).to eq(elements) - end - end - - context "with unpositioned elements" do - it "calculates positions for elements without x/y", :aggregate_failures do - elements = [ - { id: "1", x: 100, y: 50 }, - { id: "2" }, # No position - ] - - result = engine.apply_layout(elements, []) - - expect(result.size).to eq(2) - expect(result[1]).to have_key(:x) - expect(result[1]).to have_key(:y) - expect(result[1][:x]).to be_a(Numeric) - expect(result[1][:y]).to be_a(Numeric) - end - - it "positions multiple unpositioned elements in a grid", - :aggregate_failures do - elements = [ - { id: "1" }, - { id: "2" }, - { id: "3" }, - { id: "4" }, - ] - - result = engine.apply_layout(elements, []) - - expect(result.size).to eq(4) - expect(result).to all(have_key(:x)) - expect(result).to all(have_key(:y)) - end - end - - context "with connectors" do - it "accepts connectors parameter without error" do - elements = [{ id: "1", x: 100, y: 50 }] - connectors = [{ id: "c1", source: "1", target: "2" }] - - expect { engine.apply_layout(elements, connectors) }.not_to raise_error - end - end - end - - describe "#calculate_element_position" do - context "with existing position" do - it "returns element unchanged" do - element = { id: "1", x: 100, y: 50 } - - result = engine.calculate_element_position(element, []) - - expect(result).to eq(element) - end - end - - context "without position and no related elements" do - it "positions at origin", :aggregate_failures do - element = { id: "1" } - - result = engine.calculate_element_position(element, []) - - expect(result[:x]).to eq(0) - expect(result[:y]).to eq(0) - end - end - - context "without position but with related elements" do - it "positions to the right of related elements", :aggregate_failures do - element = { id: "2" } - related = [{ id: "1", x: 100, y: 50, width: 120 }] - - result = engine.calculate_element_position(element, related) - - # Should be positioned: 100 + 120 + spacing (50) = 270 - expect(result[:x]).to eq(270) - expect(result[:y]).to eq(50) # Same y as related - end - - it "uses element_width_for when related element has no width", - :aggregate_failures do - element = { id: "2" } - related = [{ id: "1", x: 100, y: 50, type: "class" }] - - result = engine.calculate_element_position(element, related) - - # Should use default ELEMENT_WIDTH (120) + spacing (50) - expect(result[:x]).to be > 100 - expect(result[:y]).to eq(50) - end - end - end - - describe "private methods" do - describe "#element_width_for" do - it "returns actual width when available and positive" do - element = { width: 200 } - expect(engine.element_width_for(element)).to eq(200) - end - - it "calculates width for class type based on attributes" do - element = { type: "class", attributes: [{}, {}, {}] } - width = engine.element_width_for(element) - expect(width).to eq(150) # 3 * 10 + 120 - end - - it "calculates width for package type" do - element = { type: "package" } - width = engine.element_width_for(element) - expect(width).to eq(140) # ELEMENT_WIDTH + 20 - end - - it "returns default width for unknown types" do - element = { type: "unknown" } - width = engine.element_width_for(element) - expect(width).to eq(120) # ELEMENT_WIDTH - end - - it "uses default when width is zero" do - element = { width: 0, type: "class" } - width = engine.element_width_for(element) - expect(width).to eq(120) - end - end - - describe "#element_height_for" do - it "returns actual height when available and positive" do - element = { height: 150 } - expect(engine.element_height_for(element)).to eq(150) - end - - it "calculates height for class type based on operations" do - element = { type: "class", operations: [{}, {}] } - height = engine.element_height_for(element) - expect(height).to eq(110) # 2 * 15 + 80 - end - - it "calculates height for package type" do - element = { type: "package" } - height = engine.element_height_for(element) - expect(height).to eq(70) # ELEMENT_HEIGHT - 10 - end - - it "returns default height for unknown types" do - element = { type: "unknown" } - height = engine.element_height_for(element) - expect(height).to eq(80) # ELEMENT_HEIGHT - end - end - - describe "#calculate_connector_bounds" do - it "returns nil when connectors array is empty" do - result = engine.calculate_connector_bounds([]) - expect(result).to be_nil - end - - it "returns nil when no valid connectors" do - connectors = [ - { id: "c1", geometry: "SX=0;SY=0;EX=0;EY=0;" }, - # Missing source_element and target_element - ] - result = engine.calculate_connector_bounds(connectors) - expect(result).to be_nil - end - - it "calculates bounds for connectors with geometry", - :aggregate_failures do - source = { id: "1", x: 100, y: 50, width: 200, height: 100 } - target = { id: "2", x: 400, y: 200, width: 150, height: 80 } - - connectors = [ - { - id: "c1", - geometry: "SX=10;SY=5;EX=-10;EY=-5;", - source_element: source, - target_element: target, - }, - ] - - result = engine.calculate_connector_bounds(connectors) - - expect(result).to be_a(Hash) - expect(result).to have_key(:min_x) - expect(result).to have_key(:max_x) - expect(result).to have_key(:min_y) - expect(result).to have_key(:max_y) - end - end - - describe "#parse_geometry_offsets" do - it "parses EA geometry string correctly", :aggregate_failures do - geometry = "SX=10;SY=5;EX=-10;EY=-5;EDGE=1;" - sx, sy, ex, ey = engine.parse_geometry_offsets(geometry) - - expect(sx).to eq(10) - expect(sy).to eq(5) - expect(ex).to eq(-10) - expect(ey).to eq(-5) - end - - it "handles missing values with defaults", :aggregate_failures do - geometry = "SX=10;EDGE=1;" - sx, sy, ex, ey = engine.parse_geometry_offsets(geometry) - - expect(sx).to eq(10) - expect(sy).to eq(0) - expect(ex).to eq(0) - expect(ey).to eq(0) - end - - it "returns zeros for nil geometry" do - sx, sy, ex, ey = engine.parse_geometry_offsets(nil) - - expect([sx, sy, ex, ey]).to eq([0, 0, 0, 0]) - end - - it "returns zeros for empty geometry" do - sx, sy, ex, ey = engine.parse_geometry_offsets("") - - expect([sx, sy, ex, ey]).to eq([0, 0, 0, 0]) - end - - it "handles malformed geometry gracefully" do - geometry = "INVALID;FORMAT;" - sx, sy, ex, ey = engine.parse_geometry_offsets(geometry) - - expect([sx, sy, ex, ey]).to eq([0, 0, 0, 0]) - end - - it "handles geometry with extra whitespace", :aggregate_failures do - geometry = " SX = 10 ; SY = 5 ; " - sx, sy, _ex, _ey = engine.parse_geometry_offsets(geometry) - - expect(sx).to eq(10) - expect(sy).to eq(5) - end - end - end -end diff --git a/spec/ea/diagram/path_builder_spec.rb b/spec/ea/diagram/path_builder_spec.rb deleted file mode 100644 index 8079362..0000000 --- a/spec/ea/diagram/path_builder_spec.rb +++ /dev/null @@ -1,485 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Ea::Diagram::PathBuilder do - let(:connector) { { id: "test", type: "association" } } - let(:source_element) { { id: "1", x: 100, y: 50, width: 120, height: 80 } } - let(:target_element) { { id: "2", x: 400, y: 200, width: 150, height: 100 } } - let(:builder) do - described_class.new(connector, source_element, target_element) - end - - describe "#initialize" do - it "stores connector reference" do - expect(builder.connector).to eq(connector) - end - - it "stores source element reference" do - expect(builder.source_element).to eq(source_element) - end - - it "stores target element reference" do - expect(builder.target_element).to eq(target_element) - end - - it "accepts nil source element" do - builder_no_source = described_class.new(connector, nil, target_element) - expect(builder_no_source.source_element).to be_nil - end - - it "accepts nil target element" do - builder_no_target = described_class.new(connector, source_element, nil) - expect(builder_no_target.target_element).to be_nil - end - end - - describe "#build_path" do - context "with EA geometry" do - it "builds path from EA geometry data", :aggregate_failures do - connector[:geometry] = "SX=10;SY=5;EX=-10;EY=-5;EDGE=1;" - - path = builder.build_path - - expect(path).to start_with("M ") - expect(path).to include(" L ") - expect(path).not_to eq("M 0,0 L 0,0") - end - - it "calculates coordinates by manhattan_path with relative offsets" do - connector[:geometry] = "SX=50;SY=25;EX=0;EY=0;EDGE=1;" - - path = builder.build_path - - # Source point: (100 + 120, 50 + 80/2) = (220, 90) - # With offsets: (220 + 50, 90 + 25) = (270, 115) - expect(path).to start_with("M 270,115") - end - - it "handles negative offsets correctly" do - connector[:geometry] = "SX=0;SY=0;EX=-30;EY=20;EDGE=1;" - - path = builder.build_path - - # Target point: (400, 200 + 100/2) = (400, 250) - # With offsets: (400 - 30, 250 + 20) = (370, 270) - expect(path).to end_with("370,270") - end - - it "includes waypoints in path when present" do - connector[:geometry] = "SX=0;SY=0;EX=0;EY=0;EDGE=1;EDGE1=250,125;" - - path = builder.build_path - - # Should have start, waypoint, end - l_count = path.scan(" L ").count - expect(l_count).to be >= 2 - expect(path).to include("250,125") - end - - it "handles multiple waypoints", :aggregate_failures do - connector[:geometry] = - "SX=0;SY=0;EX=0;EY=0;EDGE=1;EDGE1=200,100;" \ - "EDGE2=300,150;EDGE3=400,200;" - - path = builder.build_path - - expect(path).to include("200,100") - expect(path).to include("300,150") - expect(path).to include("400,200") - end - end - - context "without EA geometry" do - it "falls back to straight path when coordinates available" do - connector[:geometry] = nil - connector[:source_x] = 160 - connector[:source_y] = 90 - connector[:target_x] = 475 - connector[:target_y] = 250 - - path = builder.build_path - - expect(path).to eq("M 160,90 L 475,250") - end - - it "uses manhattan routing by default", :aggregate_failures do - connector[:geometry] = nil - connector[:routing_type] = nil - - path = builder.build_path - - # Manhattan should have multiple segments - expect(path).to start_with("M ") - expect(path).to include(" L ") - end - end - - context "with orthogonal routing" do - it "creates right-angle path" do - connector[:routing_type] = "orthogonal" - connector[:geometry] = nil - - path = builder.build_path - - # Orthogonal should have multiple line segments - l_count = path.scan(" L ").count - expect(l_count).to be >= 2 - end - end - - context "with bezier routing" do - it "creates curved path with control points" do - connector[:routing_type] = "bezier" - connector[:geometry] = nil - - path = builder.build_path - - expect(path).to include(" C ") # Cubic bezier command - end - end - - context "with missing elements" do - let(:builder_no_elements) { described_class.new(connector, nil, nil) } - - it "handles missing source element gracefully", :aggregate_failures do - connector[:geometry] = "SX=10;SY=5;EX=0;EY=0;" - - expect { builder_no_elements.build_path }.not_to raise_error - end - - it "falls back to default coordinates", :aggregate_failures do - path = builder_no_elements.build_path - - expect(path).to be_a(String) - expect(path).to start_with("M ") - end - end - end - - describe "EA geometry parsing" do - describe "#parse_ea_geometry" do - it "parses SX/SY source offsets", :aggregate_failures do - connector[:geometry] = "SX=50;SY=25;EX=0;EY=0;EDGE=1;" - - geometry_data = builder.parse_ea_geometry(connector[:geometry]) - - expect(geometry_data[:source_offset_x]).to eq(50) - expect(geometry_data[:source_offset_y]).to eq(25) - end - - it "parses EX/EY target offsets", :aggregate_failures do - connector[:geometry] = "SX=0;SY=0;EX=-30;EY=20;EDGE=1;" - - geometry_data = builder.parse_ea_geometry(connector[:geometry]) - - expect(geometry_data[:target_offset_x]).to eq(-30) - expect(geometry_data[:target_offset_y]).to eq(20) - end - - it "parses EDGE identifier" do - connector[:geometry] = "SX=0;SY=0;EX=0;EY=0;EDGE=1;" - - geometry_data = builder.parse_ea_geometry(connector[:geometry]) - - expect(geometry_data[:edge]).to eq(1) - end - - it "parses waypoints from EDGE1, EDGE2, etc.", :aggregate_failures do - connector[:geometry] = - "SX=0;SY=0;EX=0;EY=0;EDGE=1;EDGE1=100,50;EDGE2=200,100;" - - geometry_data = builder.parse_ea_geometry(connector[:geometry]) - - expect(geometry_data[:waypoints]).to be_an(Array) - expect(geometry_data[:waypoints].size).to eq(2) - expect(geometry_data[:waypoints][0]).to eq({ x: 100, y: 50 }) - expect(geometry_data[:waypoints][1]).to eq({ x: 200, y: 100 }) - end - - it "sets has_relative_coords flag when offsets present" do - connector[:geometry] = "SX=10;SY=0;EX=0;EY=0;" - - geometry_data = builder.parse_ea_geometry(connector[:geometry]) - - expect(geometry_data[:has_relative_coords]).to be_truthy - end - - it "handles missing offsets with nil values", :aggregate_failures do - connector[:geometry] = "EDGE=1;" - - geometry_data = builder.parse_ea_geometry(connector[:geometry]) - - expect(geometry_data[:source_offset_x]).to be_nil - expect(geometry_data[:source_offset_y]).to be_nil - end - - it "returns nil for nil geometry" do - result = builder.parse_ea_geometry(nil) - - expect(result).to be_nil - end - - it "returns nil for empty geometry" do - result = builder.parse_ea_geometry("") - - expect(result).to be_nil - end - - it "handles malformed geometry gracefully" do - geometry_data = builder.parse_ea_geometry("INVALID;FORMAT;DATA") - - expect(geometry_data).to be_a(Hash) - end - - it "ignores EA internal variables starting with $" do - connector[:geometry] = "SX=10;$LLB=ignored;EDGE=1;" - - geometry_data = builder.parse_ea_geometry(connector[:geometry]) - - expect(geometry_data.keys).not_to include(:$LLB) - end - - it "handles geometry with extra whitespace", :aggregate_failures do - connector[:geometry] = " SX = 10 ; SY = 5 ; " - - geometry_data = builder.parse_ea_geometry(connector[:geometry]) - - expect(geometry_data[:source_offset_x]).to eq(10) - expect(geometry_data[:source_offset_y]).to eq(5) - end - end - end - - describe "routing algorithms" do - describe "#straight_path" do - it "creates direct line between source and target" do - connector[:source_x] = 100 - connector[:source_y] = 50 - connector[:target_x] = 400 - connector[:target_y] = 200 - - path = builder.straight_path() - - expect(path).to eq("M 100,50 L 400,200") - end - - it "uses defaults when coordinates missing" do - path = builder.straight_path() - - expect(path).to eq("M 0,0 L 100,100") - end - end - - describe "#manhattan_path" do - it "creates path with one bend" do - path = builder.manhattan_path() - - # Should have at least 3 line segments - l_count = path.scan(" L ").count - expect(l_count).to be >= 3 - end - - it "chooses horizontal bend for wider spans" do - # Wider horizontal distance - wide_source = { id: "1", x: 0, y: 100, width: 100, height: 80 } - wide_target = { id: "2", x: 500, y: 120, width: 100, height: 80 } - wide_builder = described_class.new(connector, wide_source, wide_target) - - path = wide_builder.manhattan_path() - - # Path should contain intermediate horizontal points - expect(path).to match(/M \d+,\d+ L \d+,\d+ L \d+,\d+ L \d+,\d+/) - end - - it "chooses vertical bend for taller spans" do - # Taller vertical distance - tall_source = { id: "1", x: 100, y: 0, width: 100, height: 80 } - tall_target = { id: "2", x: 120, y: 500, width: 100, height: 80 } - tall_builder = described_class.new(connector, tall_source, tall_target) - - path = tall_builder.manhattan_path() - - # Path should contain intermediate vertical points - expect(path).to match(/M \d+,\d+ L \d+,\d+ L \d+,\d+ L \d+,\d+/) - end - end - - describe "#bezier_path" do - it "creates smooth curved path", :aggregate_failures do - path = builder.bezier_path() - - expect(path).to start_with("M ") - expect(path).to include(" C ") # Cubic bezier - expect(path).not_to include(" L ") # No straight lines - end - - it "includes control points for curve", :aggregate_failures do - path = builder.bezier_path() - - # Format: M x1,y1 C cp1x,cp1y cp2x,cp2y x2,y2 - # Splits into: ["M", "x1,y1", "C", "cp1x,cp1y", "cp2x,cp2y", "x2,y2"] - parts = path.split - expect(parts.size).to eq(6) - expect(parts[0]).to eq("M") - expect(parts[2]).to eq("C") - end - end - - describe "#orthogonal_path" do - it "creates right-angle routing" do - path = builder.orthogonal_path() - - # Should have multiple segments - l_count = path.scan(" L ").count - expect(l_count).to be >= 2 - end - end - - describe "#calculate_orthogonal_points" do - it "generates points for horizontal-first routing", :aggregate_failures do - points = builder.calculate_orthogonal_points() - - expect(points).to be_an(Array) - expect(points.size).to eq(4) # start, 2 intermediate, end - expect(points[0]).to be_an(Array) - expect(points[0].size).to eq(2) - end - end - end - - describe "helper methods" do - describe "#path_from_points" do - it "converts points array to SVG path string" do - points = [[100, 50], [200, 100], [300, 150]] - - path = builder.path_from_points(points) - - expect(path).to eq("M 100,50 L 200,100 L 300,150") - end - - it "returns empty string for empty points array" do - path = builder.path_from_points([]) - - expect(path).to eq("") - end - - it "handles single point" do - points = [[100, 50]] - - path = builder.path_from_points(points) - - expect(path).to eq("M 100,50") - end - end - - describe "#source_point" do - it "uses connector source coordinates when available" do - connector[:source_x] = 150 - connector[:source_y] = 75 - - point = builder.source_point() - - expect(point).to eq([150, 75]) - end - - it "calculates from element when coordinates not in connector", - :aggregate_failures do - point = builder.source_point() - - # Should calculate connection point from element - expect(point).to be_an(Array) - expect(point.size).to eq(2) - end - end - - describe "#target_point" do - it "uses connector target coordinates when available" do - connector[:target_x] = 450 - connector[:target_y] = 250 - - point = builder.target_point() - - expect(point).to eq([450, 250]) - end - - it "calculates from element when coordinates not in connector", - :aggregate_failures do - point = builder.target_point() - - # Should calculate connection point from element - expect(point).to be_an(Array) - expect(point.size).to eq(2) - end - end - - describe "#calculate_element_connection_point" do - it "returns origin for nil element" do - point = builder.calculate_element_connection_point(nil, :source) - - expect(point).to eq([0, 0]) - end - - it "calculates right-side connection for source" do - point = builder.calculate_element_connection_point( - source_element, :source) - - # Right side: x + width, center height: y + height/2 - expect(point).to eq([220, 90]) # (100 + 120, 50 + 80/2) - end - - it "calculates left-side connection for target" do - point = builder.calculate_element_connection_point( - target_element, :target) - - # Left side: x, center height: y + height/2 - expect(point).to eq([400, 250]) # (400, 200 + 100/2) - end - - it "calculates center connection for other types" do - point = builder.calculate_element_connection_point( - source_element, :other) - - # Center: x + width/2, y + height/2 - expect(point).to eq([160, 90]) # (100 + 120/2, 50 + 80/2) - end - - it "uses default dimensions when missing" do - element_no_dims = { id: "1", x: 100, y: 50 } - - point = builder.calculate_element_connection_point( - element_no_dims, :source) - - # Should use defaults (120, 80) - expect(point).to eq([220, 90]) # (100 + 120, 50 + 80/2) - end - end - - describe "#simple_connector?" do - it "returns truthy when all coordinates present" do - connector[:source_x] = 100 - connector[:source_y] = 50 - connector[:target_x] = 400 - connector[:target_y] = 200 - - expect(builder.simple_connector?).to be_truthy - end - - it "returns falsy when source_x missing" do - connector[:source_y] = 50 - connector[:target_x] = 400 - connector[:target_y] = 200 - - expect(builder.simple_connector?).to be_falsy - end - - it "returns falsy when any coordinate missing" do - connector[:source_x] = 100 - connector[:source_y] = 50 - connector[:target_x] = 400 - - expect(builder.simple_connector?).to be_falsy - end - end - end -end diff --git a/spec/ea/diagram/style_resolver_spec.rb b/spec/ea/diagram/style_resolver_spec.rb deleted file mode 100644 index 49e7446..0000000 --- a/spec/ea/diagram/style_resolver_spec.rb +++ /dev/null @@ -1,516 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Ea::Diagram::StyleResolver do - let(:resolver) { described_class.new } - - describe "#initialize" do - it "creates configuration instance" do - expect(resolver.configuration).to be_a(Ea::Diagram::Configuration) - end - - it "creates style parser instance" do - expect(resolver.style_parser).to be_a(Ea::Diagram::StyleParser) - end - - it "accepts custom config path" do - custom_resolver = described_class.new("custom/path/config.yml") - expect(custom_resolver.configuration).to be_a(Ea::Diagram::Configuration) - end - end - - describe "#resolve_element_style" do - let(:element) do - double("Element", - name: "TestClass", - package_name: nil, - stereotype: nil) - end - - context "without diagram object" do - it "returns style hash with basic properties", :aggregate_failures do - style = resolver.resolve_element_style(element) - - expect(style).to be_a(Hash) - expect(style).to have_key(:fill) - expect(style).to have_key(:stroke) - expect(style).to have_key(:stroke_width) - end - - it "includes font properties", :aggregate_failures do - style = resolver.resolve_element_style(element) - - expect(style).to have_key(:font_family) - expect(style).to have_key(:font_size) - expect(style).to have_key(:font_weight) - end - - it "includes box properties", :aggregate_failures do - style = resolver.resolve_element_style(element) - - expect(style).to have_key(:stroke_linecap) - expect(style).to have_key(:stroke_linejoin) - expect(style).to have_key(:corner_radius) - end - - it "compacts nil values" do - style = resolver.resolve_element_style(element) - - expect(style.values).not_to include(nil) - end - end - - context "with diagram object containing EA style" do - let(:diagram_object) do - double("DiagramObject", - style: "BCol=16764159;LCol=0;LWth=2;") - end - - it "merges EA style with configuration defaults", :aggregate_failures do - style = resolver.resolve_element_style(element, diagram_object) - - expect(style).to have_key(:fill) - expect(style).to have_key(:stroke) - end - - it "gives priority to EA colors over config", :aggregate_failures do - style = resolver.resolve_element_style(element, diagram_object) - - # Should have colors from EA data - expect(style[:fill]).not_to be_nil - expect(style[:stroke]).not_to be_nil - end - - it "includes EA line width" do - style = resolver.resolve_element_style(element, diagram_object) - - expect(style[:stroke_width]).to eq(2) - end - end - - context "with element having stereotype" do - let(:stereotyped_element) do - Lutaml::Uml::UmlClass.new(name: "MyType", stereotype: ["DataType"]) - end - - it "applies stereotype-specific fill color" do - style = resolver.resolve_element_style(stereotyped_element) - - expect(style[:fill]).to eq("#FFCCFF") - end - end - - context "with element in specific package" do - let(:packaged_element) do - double("Element", - name: "Feature", - package_name: "CityGML::Core", - stereotype: nil) - end - - it "applies package-specific styles when configured" do - # This depends on configuration having package rules - style = resolver.resolve_element_style(packaged_element) - - expect(style).to be_a(Hash) - end - end - end - - describe "#resolve_connector_style" do - context "with generalization connector" do - let(:connector) { Lutaml::Uml::Generalization.new } - - it "returns generalization style", :aggregate_failures do - style = resolver.resolve_connector_style(connector) - - expect(style).to have_key(:arrow_type) - expect(style[:arrow_type]).to eq("hollow_triangle") - end - - it "includes line properties", :aggregate_failures do - style = resolver.resolve_connector_style(connector) - - expect(style).to have_key(:stroke) - expect(style).to have_key(:stroke_width) - end - - it "sets fill to none" do - style = resolver.resolve_connector_style(connector) - - expect(style[:fill]).to eq("none") - end - end - - context "with association connector" do - let(:connector) do - Struct.new(:class, :name).new(Struct.new(:name).new("Lutaml::Uml::Association"), - member_end: []) - end - - it "returns association style", :aggregate_failures do - style = resolver.resolve_connector_style(connector) - - expect(style).to have_key(:arrow_type) - expect(style[:arrow_type]).to eq("open_arrow") - end - end - - context "with diagram link containing EA style" do - let(:connector) do - Struct.new(:class, :name).new(Struct.new(:name).new("Lutaml::Uml::Association"), - member_end: []) - end - - let(:diagram_link) do - double("DiagramLink", - style: "LCol=255;LWth=3;LStyle=1;") - end - - it "merges EA style with defaults", :aggregate_failures do - style = resolver.resolve_connector_style(connector, diagram_link) - - expect(style[:stroke_width]).to eq(3) - expect(style[:stroke_dasharray]).to eq("5,5") - end - end - - context "with nil connector" do - it "defaults to association type", :aggregate_failures do - style = resolver.resolve_connector_style(nil) - - expect(style).to be_a(Hash) - expect(style).to have_key(:arrow_type) - end - end - end - - describe "#resolve_fill_color" do - let(:element) do - Lutaml::Uml::UmlClass.new(name: "TestClass") - end - - it "returns configuration fill color", :aggregate_failures do - color = resolver.resolve_fill_color(element) - - expect(color).to be_a(String) - expect(color).to match(/^#[0-9A-F]{6}$/i) - end - - context "with EA data" do - let(:diagram_object) do - double("DiagramObject", - style: "BCol=16764159;") - end - - it "prioritizes EA fill color over config", :aggregate_failures do - color = resolver.resolve_fill_color(element, diagram_object) - - expect(color).to be_a(String) - expect(color).to match(/^#[0-9A-F]{6}$/i) - end - end - - context "with nil diagram object" do - it "falls back to configuration" do - color = resolver.resolve_fill_color(element, nil) - - expect(color).not_to be_nil - end - end - end - - describe "#resolve_stroke_color" do - let(:element) do - Lutaml::Uml::UmlClass.new(name: "TestClass") - end - - it "returns configuration stroke color", :aggregate_failures do - color = resolver.resolve_stroke_color(element) - - expect(color).to be_a(String) - expect(color).to match(/^#[0-9A-F]{6}$/i) - end - - context "with EA data" do - let(:diagram_object) do - double("DiagramObject", - style: "LCol=255;") - end - - it "prioritizes EA stroke color over config" do - color = resolver.resolve_stroke_color(element, diagram_object) - - expect(color).to be_a(String) - end - end - end - - describe "#resolve_font" do - let(:element) do - double("Element", - name: "TestClass", - package_name: nil, - stereotype: nil) - end - - it "returns font properties hash", :aggregate_failures do - font = resolver.resolve_font(element) - - expect(font).to be_a(Hash) - expect(font).to have_key(:family) - expect(font).to have_key(:size) - end - - it "defaults to class_name context" do - font = resolver.resolve_font(element) - - expect(font[:weight]).to eq(700) # Bold for class names - end - - it "accepts different context types", :aggregate_failures do - attribute_font = resolver.resolve_font(element, :attribute) - operation_font = resolver.resolve_font(element, :operation) - stereotype_font = resolver.resolve_font(element, :stereotype) - - expect(attribute_font).to be_a(Hash) - expect(operation_font).to be_a(Hash) - expect(stereotype_font).to be_a(Hash) - end - - it "compacts nil values" do - font = resolver.resolve_font(element) - - expect(font.values).not_to include(nil) - end - end - - describe "private methods" do - describe "#parse_diagram_object_style" do - it "parses BCol (fill color)", :aggregate_failures do - style_string = "BCol=16764159;" - result = resolver.parse_diagram_object_style(style_string) - - expect(result).to have_key(:fill) - expect(result[:fill]).to match(/^#[0-9A-F]{6}$/i) - end - - it "parses LCol (stroke color)", :aggregate_failures do - style_string = "LCol=255;" - result = resolver.parse_diagram_object_style(style_string) - - expect(result).to have_key(:stroke) - expect(result[:stroke]).to match(/^#[0-9A-F]{6}$/i) - end - - it "parses LWth (line width)" do - style_string = "LWth=3;" - result = resolver.parse_diagram_object_style(style_string) - - expect(result[:stroke_width]).to eq(3) - end - - it "parses BFol (bold font)", :aggregate_failures do - bold_style = "BFol=1;" - normal_style = "BFol=0;" - - bold_result = resolver.parse_diagram_object_style(bold_style) - normal_result = resolver.parse_diagram_object_style(normal_style) - - expect(bold_result[:font_weight]).to eq(700) - expect(normal_result[:font_weight]).to eq(400) - end - - it "parses IFol (italic font)", :aggregate_failures do - italic_style = "IFol=1;" - normal_style = "IFol=0;" - - italic_result = resolver.parse_diagram_object_style(italic_style) - normal_result = resolver.parse_diagram_object_style(normal_style) - - expect(italic_result[:font_style]).to eq("italic") - expect(normal_result[:font_style]).to eq("normal") - end - - it "parses multiple properties", :aggregate_failures do - style_string = "BCol=16764159;LCol=255;LWth=2;BFol=1;IFol=1;" - result = resolver.parse_diagram_object_style(style_string) - - expect(result).to have_key(:fill) - expect(result).to have_key(:stroke) - expect(result[:stroke_width]).to eq(2) - expect(result[:font_weight]).to eq(700) - expect(result[:font_style]).to eq("italic") - end - - it "handles nil style string" do - result = resolver.parse_diagram_object_style(nil) - - expect(result).to eq({}) - end - - it "handles empty style string" do - result = resolver.parse_diagram_object_style("") - - expect(result).to eq({}) - end - - it "ignores unknown properties", :aggregate_failures do - style_string = "UNKNOWN=123;BCol=16764159;" - result = resolver.parse_diagram_object_style(style_string) - - expect(result).to have_key(:fill) - expect(result).not_to have_key(:unknown) - end - - it "handles malformed key-value pairs" do - style_string = "BCol=;=123;BCol=16764159;" - result = resolver.parse_diagram_object_style(style_string) - - expect(result).to have_key(:fill) - end - end - - describe "#parse_diagram_link_style" do - it "parses LCol (line color)", :aggregate_failures do - style_string = "LCol=255;" - result = resolver.parse_diagram_link_style(style_string) - - expect(result).to have_key(:stroke) - expect(result[:stroke]).to match(/^#[0-9A-F]{6}$/i) - end - - it "parses LWth (line width)" do - style_string = "LWth=3;" - result = resolver.parse_diagram_link_style(style_string) - - expect(result[:stroke_width]).to eq(3) - end - - it "parses LStyle for dashed line" do - dash_style = "LStyle=1;" - result = resolver.parse_diagram_link_style(dash_style) - - expect(result[:stroke_dasharray]).to eq("5,5") - end - - it "parses LStyle for dotted line" do - dot_style = "LStyle=2;" - result = resolver.parse_diagram_link_style(dot_style) - - expect(result[:stroke_dasharray]).to eq("2,2") - end - - it "handles nil style string" do - result = resolver.parse_diagram_link_style(nil) - - expect(result).to eq({}) - end - - it "handles solid line style (LStyle=0)" do - solid_style = "LStyle=0;" - result = resolver.parse_diagram_link_style(solid_style) - - # LStyle=0 should not set stroke_dasharray - expect(result).not_to have_key(:stroke_dasharray) - end - end - - describe "#determine_connector_type" do - it "returns 'generalization' for Generalization class" do - connector = Lutaml::Uml::Generalization.new - type = resolver.determine_connector_type(connector) - - expect(type).to eq("generalization") - end - - it "returns 'association' for Association class" do - connector = Lutaml::Uml::Association.new - type = resolver.determine_connector_type(connector) - - expect(type).to eq("association") - end - - it "returns 'dependency' for Dependency class" do - connector = Lutaml::Uml::Dependency.new - type = resolver.determine_connector_type(connector) - - expect(type).to eq("dependency") - end - - it "returns 'realization' for Realization class" do - connector = Lutaml::Uml::Realization.new - type = resolver.determine_connector_type(connector) - - expect(type).to eq("realization") - end - - it "defaults to 'association' for unknown types" do - connector = Struct.new(:dummy).new - type = resolver.determine_connector_type(connector) - - expect(type).to eq("association") - end - - it "returns 'association' for nil connector" do - type = resolver.determine_connector_type(nil) - - expect(type).to eq("association") - end - end - - describe "#determine_association_type" do - it "returns 'aggregation' for aggregation type" do - connector = Lutaml::Uml::Association.new(member_end_type: "aggregation") - - type = resolver.determine_association_type(connector) - - expect(type).to eq("aggregation") - end - - it "returns 'composition' for composition type" do - connector = Lutaml::Uml::Association.new(member_end_type: "composition") - - type = resolver.determine_association_type(connector) - - expect(type).to eq("composition") - end - - it "handles case-insensitive type values" do - connector = Lutaml::Uml::Association.new(owner_end_type: "AGGREGATION") - - type = resolver.determine_association_type(connector) - - expect(type).to eq("aggregation") - end - - it "returns 'association' for no aggregation type" do - connector = Lutaml::Uml::Association.new - - type = resolver.determine_association_type(connector) - - expect(type).to eq("association") - end - - it "returns 'association' for non-Association" do - connector = double("Other") - - type = resolver.determine_association_type(connector) - - expect(type).to eq("association") - end - - it "checks both owner and member end types" do - connector = Lutaml::Uml::Association.new( - owner_end_type: "association", - member_end_type: "composition", - ) - - type = resolver.determine_association_type(connector) - - expect(type).to eq("composition") - end - end - end -end diff --git a/spec/ea/diagram/svg_accuracy_spec.rb b/spec/ea/diagram/svg_accuracy_spec.rb deleted file mode 100644 index c6691e9..0000000 --- a/spec/ea/diagram/svg_accuracy_spec.rb +++ /dev/null @@ -1,332 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" -require "canon" -require "support/svg_comparison_helper" - -RSpec.describe "EA Diagram SVG Accuracy" do - # Skip all examples when the sibling lutaml-uml checkout (which carries - # EA-generated reference SVGs) isn't available. - before(:all) do - ref = File.expand_path("../../../../lutaml-uml/examples/xmi/Images", __dir__) - skip "requires sibling lutaml-uml checkout with reference SVGs" unless Dir.exist?(ref) - end - # Path to test repository (within ea gem) - lur_path = File.expand_path("../../../examples/lur/basic.lur", __dir__) - - # Diagrams to test from basic.lur - # These diagrams have complete rendering data and EA reference SVGs - diagrams_to_test = [ - { - name: "Starter Object Diagram", - xmi_id: "EAID_D14AA320_9D41_4366_8739_9C2C21F96AE1", - expected_ea_file: "EAID_D14AA320_9D41_4366_8739_9C2C21F96AE1.svg", - }, - { - name: "Basic Class Diagram with Attributes", - xmi_id: "EAID_4F421236_FCF3_4aae_B22A_C7E6A5EFBAC7", - expected_ea_file: "EAID_4F421236_FCF3_4aae_B22A_C7E6A5EFBAC7.svg", - }, - { - name: "Package Contents", - xmi_id: "EAID_F0F20BDF_C729_47f7_B6FC_25ED2C4609CA", - expected_ea_file: "EAID_F0F20BDF_C729_47f7_B6FC_25ED2C4609CA.svg", - }, - ].freeze - - include SvgComparisonHelper - - let(:qea_path) { "spec/fixtures/test.qea" } - # Load repository once for all tests - let(:repository) do - if File.exist?(lur_path) - Lutaml::UmlRepository::Repository.from_file(lur_path) - else - skip "Repository file not found: #{lur_path}" - end - end - # Get all diagrams from repository - let(:diagrams) { repository.all_diagrams } - let(:lur_path) { lur_path } - let(:reference_dir) { File.expand_path("../../../../lutaml-uml/examples/xmi/Images", __dir__) } - - # Helper to convert XMI ID to EA SVG filename - # {F4C23F9E-DD74-4fed-B75D-AD3C6448BA24} → - # EAID_F4C23F9E_DD74_4fed_B75D_AD3C6448BA24.svg - # EAID_F4C23F9E_DD74_4fed_B75D_AD3C6448BA24 → - # EAID_F4C23F9E_DD74_4fed_B75D_AD3C6448BA24.svg - def xmi_id_to_ea_filename(xmi_id) - # Handle XMI IDs that already have EAID_ prefix - return "#{xmi_id}.svg" if xmi_id.start_with?("EAID_") - - # Convert from {GUID} format - # Remove curly braces and replace dashes with underscores, preserve case - clean_id = xmi_id.gsub(/[{}]/, "").gsub("-", "_") - "EAID_#{clean_id}.svg" - end - - # Helper to find EA reference SVG by XMI ID - def find_ea_reference_svg(xmi_id) - filename = xmi_id_to_ea_filename(xmi_id) - path = File.join(reference_dir, filename) - File.exist?(path) ? path : nil - end - - describe "Reference file availability" do - it "has EA reference directory" do - expect(Dir).to exist(reference_dir) - end - - it "contains EA-generated SVG files" do - svg_files = Dir.glob(File.join(reference_dir, "EAID_*.svg")) - expect(svg_files) - .not_to be_empty, "EA reference directory should contain SVG files" - end - - it "has Canon gem available for XML equivalence testing" do - expect(defined?(Canon)) - .to be_truthy, "Canon gem should be loaded for XML equivalence testing" - end - end - - describe "Test fixture availability" do - it "has basic.lur repository" do - expect(File.exist?(lur_path)).to be true - end - - it "loads repository successfully", :aggregate_failures do - expect { repository }.not_to raise_error - end - - it "has diagrams in repository" do - skip "Repository file not found" unless File.exist?(lur_path) - - diagrams = repository.all_diagrams - expect(diagrams).not_to be_empty - end - end - - # Test each diagram in the repository - diagrams_to_test.each do |diagram_info| - describe "diagram: #{diagram_info[:name]}" do - let(:diagram_name) { diagram_info[:name] } - let(:diagram_xmi_id) { diagram_info[:xmi_id] } - let(:diagram) { repository.find_diagram(diagram_name) } - let(:ea_reference_path) { find_ea_reference_svg(diagram_xmi_id) } - - before do - unless diagram - skip "Diagram '#{diagram_name}' not found in repository" - end - end - - context "with EA reference SVG" do - before do - unless ea_reference_path - skip "EA reference SVG not found. Expected: " \ - "#{reference_dir}/#{diagram_info[:expected_ea_file]}" - end - end - - let(:ea_reference_svg) { File.read(ea_reference_path) } - - let(:generated_svg) do - extractor = Ea::Diagram::Extractor.new - result = extractor.extract_one(lur_path, diagram_xmi_id, output: nil) - - expect(result[:success]) - .to be_truthy, "Diagram extraction failed: #{result[:error]}" - - result[:svg_content] - end - - describe "XML equivalence using Canon gem" do - it "generates SVG with equivalent structure to EA export" do - if generated_svg.nil? || generated_svg.empty? - skip "Generated SVG is empty (diagram lacks rendering data)" - end - - gen_doc = Nokogiri::XML(generated_svg) - ref_doc = Nokogiri::XML(ea_reference_svg) - - # Both should be valid SVG documents - expect(gen_doc.root&.name).to eq("svg") - expect(ref_doc.root&.name).to eq("svg") - - # Both should have title and desc elements - gen_doc.remove_namespaces! - ref_doc.remove_namespaces! - expect(gen_doc.xpath("//title")).not_to be_empty - expect(gen_doc.xpath("//desc")).not_to be_empty - - # Both should contain visual elements - gen_visual = gen_doc.xpath("//rect | //text | //path").size - expect(gen_visual).to be > 0, - "Generated SVG should have visual elements" - end - end - - describe "structure comparison (fallback)" do - it "generates SVG with similar structure to EA export" do - if generated_svg.nil? || generated_svg.empty? - skip "Generated SVG is empty (diagram lacks rendering data)" - end - - gen_doc = Nokogiri::XML(generated_svg) - gen_doc.remove_namespaces! - ref_doc = Nokogiri::XML(ea_reference_svg) - ref_doc.remove_namespaces! - - # Check that key element types exist in generated SVG - expected_elements = %w[rect text] - expected_elements.each do |elem_type| - gen_count = gen_doc.xpath("//#{elem_type}").size - expect(gen_count).to be > 0, - "Generated SVG should have #{elem_type} " \ - "elements" - end - - # Both should have path elements (connectors or separators) - gen_paths = gen_doc.xpath("//path").size - expect(gen_paths).to be >= 0 - end - end - - describe "coordinate accuracy (fallback)" do - it "generates coordinates within viewBox bounds" do - if generated_svg.nil? || generated_svg.empty? - skip "Generated SVG is empty (diagram lacks rendering data)" - end - - gen_doc = Nokogiri::XML(generated_svg) - gen_doc.remove_namespaces! - - # Parse viewBox from generated SVG - view_box = gen_doc.root["viewBox"]&.split&.map(&:to_f) - expect(view_box).not_to be_nil, "SVG should have a viewBox" - - vb_x, vb_y, vb_w, vb_h = view_box - violations = [] - - # Check all rect elements are within viewBox - gen_doc.xpath("//rect").each do |rect| - rx = rect["x"].to_f - ry = rect["y"].to_f - rw = rect["width"].to_f - rh = rect["height"].to_f - if rx < vb_x || ry < vb_y || - rx + rw > vb_x + vb_w || ry + rh > vb_y + vb_h - violations << "rect at (#{rx},#{ry}) " \ - "exceeds viewBox (#{vb_x},#{vb_y}," \ - "#{vb_x + vb_w},#{vb_y + vb_h})" - end - end - - expect(violations).to be_empty, - "All elements should be within viewBox:" \ - "\n#{violations.join("\n")}" - end - end - - describe "content preservation" do - it "includes similar text content to EA export" do - if generated_svg.nil? || generated_svg.empty? - skip "Generated SVG is empty (diagram lacks rendering data)" - end - - gen_doc = Nokogiri::XML(generated_svg) - ref_doc = Nokogiri::XML(ea_reference_svg) - gen_doc.remove_namespaces! - ref_doc.remove_namespaces! - - gen_texts = gen_doc.xpath("//text") - .map { |x| x.content.strip }.reject(&:empty?).uniq - ref_texts = ref_doc.xpath("//text") - .map { |x| x.content.strip }.reject(&:empty?).uniq - - # Use substring matching: an EA text is "matched" if it appears - # as a substring in any generated text (our renderer may include - # additional info like type names) - matched = ref_texts.count do |ref_text| - gen_texts.any? { |gen_text| gen_text.include?(ref_text) } - end - overlap_ratio = matched.to_f / [ref_texts.size, 1].max - - expect(overlap_ratio) - .to be >= 0.5, "Should preserve at least 50% of text content " \ - "from EA export (#{matched}/#{ref_texts.size} " \ - "matched)" - end - end - - describe "visual validity" do - it "produces valid SVG output" do - if generated_svg.nil? || generated_svg.empty? - skip "Generated SVG is empty (diagram lacks rendering data)" - end - - doc = Nokogiri::XML(generated_svg) - errors = doc.errors - - expect(errors) - .to be_empty, "Generated SVG should be valid XML. " \ - "Errors:\n#{errors.map(&:message).join("\n")}" - - expect(doc.root&.name).to eq("svg"), - "Root element should be " - end - end - end - end - end - - describe "Helper utilities" do - describe "#xmi_id_to_ea_filename" do - it "converts XMI ID to EA filename format" do - xmi_id = "{F4C23F9E-DD74-4fed-B75D-AD3C6448BA24}" - expected = "EAID_F4C23F9E_DD74_4fed_B75D_AD3C6448BA24.svg" - - expect(xmi_id_to_ea_filename(xmi_id)).to eq(expected) - end - - it "handles lowercase XMI IDs", :aggregate_failures do - xmi_id = "{b58d1a53-e860-41a3-8352-11c274093e83}" - result = xmi_id_to_ea_filename(xmi_id) - - expect(result).to start_with("EAID_") - expect(result).to end_with(".svg") - expect(result).to include("b58d1a53") # Preserves lowercase - end - end - - describe "#find_ea_reference_svg" do - it "finds existing EA reference SVG", :aggregate_failures do - xmi_id = "{B58D1A53-E860-41a3-8352-11C274093E83}" - path = find_ea_reference_svg(xmi_id) - - expect(path).not_to be_nil - expect(File.exist?(path)).to be true - end - - it "returns nil for non-existent reference" do - xmi_id = "{00000000-0000-0000-0000-000000000000}" - path = find_ea_reference_svg(xmi_id) - - expect(path).to be_nil - end - end - - describe "Canon gem integration" do - it "has Canon matcher available" do - expect(self).to respond_to(:be_xml_equivalent_to) - end - - it "can compare simple XML equivalence" do - xml1 = '' - xml2 = '' # Different attribute order - - expect(xml1).to be_xml_equivalent_to(xml2) - end - end - end -end diff --git a/spec/ea/diagram/svg_renderer_spec.rb b/spec/ea/diagram/svg_renderer_spec.rb deleted file mode 100644 index a30aa24..0000000 --- a/spec/ea/diagram/svg_renderer_spec.rb +++ /dev/null @@ -1,710 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Ea::Diagram::SvgRenderer do - let(:diagram_data) do - { - name: "Test Diagram", - elements: [ - { - id: "1", - type: "class", - name: "TestClass", - x: 100, - y: 50, - width: 120, - height: 80, - element: double("Element", name: "TestClass", stereotype: nil, - package_name: nil), - diagram_object: nil, - }, - { - id: "2", - type: "package", - name: "TestPackage", - x: 300, - y: 150, - width: 120, - height: 80, - element: double("Element", name: "TestPackage", stereotype: nil, - package_name: nil), - diagram_object: nil, - }, - ], - connectors: [ - { - id: "c1", - type: "association", - geometry: "SX=0;SY=0;EX=0;EY=0;", - source_element: { id: "1", x: 100, y: 50, width: 120, height: 80 }, - target_element: { id: "2", x: 300, y: 150, width: 120, height: 80 }, - element: nil, - diagram_link: nil, - }, - ], - } - end - - let(:layout_engine) { Ea::Diagram::LayoutEngine.new } - let(:bounds) { layout_engine.calculate_bounds(diagram_data) } - let(:diagram_renderer) do - double("DiagramRenderer", - diagram_data: diagram_data, - bounds: bounds, - elements: diagram_data[:elements], - connectors: diagram_data[:connectors]) - end - - describe "#initialize" do - it "stores diagram renderer reference" do - renderer = described_class.new(diagram_renderer) - expect(renderer.diagram_renderer).to eq(diagram_renderer) - end - - it "merges options with defaults" do - renderer = described_class.new(diagram_renderer, padding: 30) - expect(renderer.options[:padding]).to eq(30) - end - - it "uses default padding when not specified" do - renderer = described_class.new(diagram_renderer) - expect(renderer.options[:padding]).to eq(20) - end - - it "calculates bounds from diagram renderer" do - renderer = described_class.new(diagram_renderer) - expect(renderer.bounds).to eq(bounds) - end - - it "creates style resolver with nil config path by default" do - renderer = described_class.new(diagram_renderer) - expect(renderer.style_resolver).to be_a(Ea::Diagram::StyleResolver) - end - - it "creates style resolver with custom config path" do - renderer = described_class.new(diagram_renderer, - config_path: "custom/config.yml") - expect(renderer.style_resolver).to be_a(Ea::Diagram::StyleResolver) - end - - it "accepts custom background color option" do - renderer = described_class.new(diagram_renderer, - background_color: "#f0f0f0") - expect(renderer.options[:background_color]).to eq("#f0f0f0") - end - - it "accepts grid_visible option" do - renderer = described_class.new(diagram_renderer, grid_visible: true) - expect(renderer.options[:grid_visible]).to be(true) - end - - it "accepts interactive option" do - renderer = described_class.new(diagram_renderer, interactive: true) - expect(renderer.options[:interactive]).to be(true) - end - - it "accepts custom CSS classes" do - renderer = described_class.new(diagram_renderer, - css_classes: ["custom-class"]) - expect(renderer.options[:css_classes]).to eq(["custom-class"]) - end - end - - describe "#render" do - let(:renderer) { described_class.new(diagram_renderer) } - let(:svg_output) { renderer.render } - - it "generates complete SVG document", :aggregate_failures do - expect(svg_output).to be_a(String) - expect(svg_output).not_to be_empty - end - - it "includes XML declaration" do - expect(svg_output).to include('") - end - - it "includes description element", :aggregate_failures do - expect(svg_output).to include("") - expect(svg_output).to include("Created with") - end - - it "includes defs section" do - expect(svg_output).to include("") - end - - it "includes background layer", :aggregate_failures do - expect(svg_output).to include("fill:#ffffff") - expect(svg_output).to include("fill-opacity:1.00") - end - - it "includes connectors layer" do - expect(svg_output).to include('id="connectors-layer"') - end - - it "includes elements layer" do - expect(svg_output).to include('id="elements-layer"') - end - - it "closes svg root element" do - expect(svg_output).to end_with("\n") - end - - it "does not include grid layer by default" do - expect(svg_output).not_to include('id="grid-layer"') - end - - it "does not include interactive layer by default" do - expect(svg_output).not_to include('