diff --git a/lib/ea.rb b/lib/ea.rb index 32f4f78..8a6bde8 100644 --- a/lib/ea.rb +++ b/lib/ea.rb @@ -12,6 +12,7 @@ class Error < StandardError; end autoload :Model, "ea/model" autoload :Sources, "ea/sources" autoload :Spa, "ea/spa" + autoload :Svg, "ea/svg" autoload :Diagram, "ea/diagram" autoload :Transformations, "ea/transformations" autoload :Xmi, "ea/xmi" diff --git a/lib/ea/cli/app.rb b/lib/ea/cli/app.rb index d61bd4f..2f2ddf8 100644 --- a/lib/ea/cli/app.rb +++ b/lib/ea/cli/app.rb @@ -12,6 +12,11 @@ class App < Thor # search, and every output-bearing command honours the same flag. OUTPUT_OPTION = { type: :string, aliases: :o }.freeze + # Shared kwargs for any command that accepts a YAML config file. + # Matches OUTPUT_OPTION; adding config support to a command is a + # one-line addition. + CONFIG_OPTION = { type: :string, aliases: :c }.freeze + class << self def exit_on_failure? true @@ -72,12 +77,21 @@ def convert(file) desc "spa FILE", "Generate single-page app (SPA) from QEA/XMI" option :output, **OUTPUT_OPTION, desc: "Output path (default: .html or .spa/)" + option :config, **CONFIG_OPTION, + desc: "Path to SPA YAML config (overrides model metadata: title, description, etc.)" option :mode, type: :string, default: "single_file", desc: "Output mode: single_file | sharded" def spa(file) Command::Spa.new(file: file, **symbolize(options)).call end + desc "svg NAME FILE", "Render a diagram from QEA/XMI to standalone SVG" + option :output, **OUTPUT_OPTION, + desc: "Output path (default: ..svg)" + def svg(name, file) + Command::Svg.new(name: name, file: file, **symbolize(options)).call + end + private def symbolize(opts) diff --git a/lib/ea/cli/command.rb b/lib/ea/cli/command.rb index 8c83c35..7ec97c6 100644 --- a/lib/ea/cli/command.rb +++ b/lib/ea/cli/command.rb @@ -11,6 +11,7 @@ module Command autoload :Parse, "ea/cli/command/parse" autoload :Convert, "ea/cli/command/convert" autoload :Spa, "ea/cli/command/spa" + autoload :Svg, "ea/cli/command/svg" autoload :RepositoryBuilder, "ea/cli/command/repository_builder" end end diff --git a/lib/ea/cli/command/spa.rb b/lib/ea/cli/command/spa.rb index bd5ad39..aef27a9 100644 --- a/lib/ea/cli/command/spa.rb +++ b/lib/ea/cli/command/spa.rb @@ -21,7 +21,8 @@ def call Ea::Spa::Generator.new( document, output: output_path, - mode: mode + mode: mode, + configuration: spa_configuration ).generate formatter.render([[output_path]], columns: [:written_to]) @@ -45,6 +46,19 @@ def mode (options[:mode] || DEFAULT_MODE).to_sym end + def spa_configuration + path = options[:config] + return nil unless path + + expanded = File.expand_path(path) + unless File.exist?(expanded) + raise Ea::Cli::FileNotFound, + "SPA config not found: #{path}" + end + + Ea::Spa::Configuration.load(expanded) + end + def resolve_output_path options[:output] || default_output_path end diff --git a/lib/ea/cli/command/svg.rb b/lib/ea/cli/command/svg.rb new file mode 100644 index 0000000..64fb1c6 --- /dev/null +++ b/lib/ea/cli/command/svg.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "fileutils" + +module Ea + module Cli + module Command + # `ea svg NAME FILE [--output=PATH]` + # + # Renders a single diagram from a QEA or XMI file into a + # standalone SVG using the umldi content (placed element + # bounds + connector waypoints) captured in Ea::Model::Diagram. + class Svg < Base + def call + diagram = find_diagram + svg = Ea::Svg::Renderer.new(diagram, + model_index: document.index_by_id).render + + output_path = resolve_output_path(diagram) + FileUtils.mkdir_p(File.dirname(output_path)) + File.write(output_path, svg) + + formatter.render([[output_path]], columns: [:written_to]) + end + + private + + def document + @document ||= build_model_document + end + + def build_model_document + case File.extname(file_path).downcase + when ".qea" + Ea::Sources::Qea::Adapter.from_path(file_path) + when ".xmi" + Ea::Sources::Xmi::Adapter.from_path(file_path) + else + raise Ea::Cli::UnsupportedFormat, + "Unknown file format: #{File.extname(file_path)}" + end + end + + def diagram_name + options[:name] or raise Ea::Cli::Error, "missing required diagram name" + end + + def find_diagram + match = document.diagrams.find { |d| d.name == diagram_name } + return match if match + + raise Ea::Cli::Error, + "Diagram #{diagram_name.inspect} not found in #{file_path}. " \ + "Use `ea diagrams list #{file_path}` to see names." + end + + def resolve_output_path(diagram) + options[:output] || begin + base = File.basename(file_path, ".*") + dir = File.dirname(file_path) + safe_name = (diagram.name || "diagram").gsub(/[^\w.\-]+/, "_") + File.join(dir, "#{base}.#{safe_name}.svg") + end + end + end + end + end +end diff --git a/lib/ea/spa.rb b/lib/ea/spa.rb index d9d34b2..10c56bc 100644 --- a/lib/ea/spa.rb +++ b/lib/ea/spa.rb @@ -26,5 +26,6 @@ module Spa autoload :Projector, "ea/spa/projector" autoload :Output, "ea/spa/output" autoload :Generator, "ea/spa/generator" + autoload :Configuration, "ea/spa/configuration" end end diff --git a/lib/ea/spa/configuration.rb b/lib/ea/spa/configuration.rb new file mode 100644 index 0000000..80971f6 --- /dev/null +++ b/lib/ea/spa/configuration.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "yaml" + +module Ea + module Spa + # SPA-side configuration. A YAML file with the shape: + # + # metadata: + # title: ... + # description: ... + # version: ... + # author: ... + # license: ... + # homepage: ... + # repository: ... + # ui: + # title: ... + # description: ... + # + # Values under `metadata` override fields on the model's + # Ea::Model::Metadata. Values under `ui` (and other sections + # like `appearance`) are surfaced to the SPA view via the + # skeleton's metadata hash so the frontend can use them. + class Configuration + attr_reader :path, :data + + def self.load(path) + return nil if path.nil? || path.empty? + unless File.exist?(path) + raise ArgumentError, "SPA config not found: #{path}" + end + + new(path, YAML.safe_load(File.read(path)) || {}) + end + + def initialize(path, data) + @path = path + @data = data.is_a?(Hash) ? data : {} + end + + def metadata_override + (@data["metadata"] || {}).transform_keys(&:to_sym) + end + + def ui + @data["ui"] || {} + end + + def appearance + @data["appearance"] || {} + end + + # Apply overrides to a model Metadata instance, returning a new + # one. The original is left untouched. + def apply_to_metadata(model_metadata) + return model_metadata unless model_metadata + + merged = model_metadata_to_hash(model_metadata) + .merge(stringify_keys(metadata_override)) + hash_to_metadata(merged) + end + + # Surface the relevant config (ui, appearance) into a hash the + # SPA skeleton embeds in its metadata payload. + def view_extras + { "ui" => ui, "appearance" => appearance }.reject { |_, v| v.nil? || v.empty? } + end + + private + + def model_metadata_to_hash(meta) + JSON.parse(meta.to_json) + end + + def hash_to_metadata(hash) + Ea::Model::Metadata.new( + id: hash["id"] || "metadata", + title: hash["title"], + version: hash["version"], + author: hash["author"], + company: hash["company"], + created_date: hash["createdDate"], + modified_date: hash["modifiedDate"], + source_format: hash["sourceFormat"], + source_tool: hash["sourceTool"], + source_path: hash["sourcePath"] + ) + end + + def stringify_keys(hash) + hash.transform_keys(&:to_s) + end + end + end +end diff --git a/lib/ea/spa/generator.rb b/lib/ea/spa/generator.rb index 07c2a89..f9933da 100644 --- a/lib/ea/spa/generator.rb +++ b/lib/ea/spa/generator.rb @@ -14,13 +14,15 @@ class Generator DEFAULT_MODE = :single_file - attr_reader :document, :mode, :output_path, :shard_url_for + attr_reader :document, :mode, :output_path, :shard_url_for, :configuration - def initialize(document, output:, mode: nil, shard_url_for: nil) + def initialize(document, output:, mode: nil, shard_url_for: nil, + configuration: nil) @document = document @output_path = output @mode = (mode || DEFAULT_MODE).to_sym @shard_url_for = shard_url_for + @configuration = configuration end def generate @@ -28,7 +30,9 @@ def generate end def projector - @projector ||= Projector.new(document, shard_url_for: shard_url_for) + @projector ||= Projector.new(document, + shard_url_for: shard_url_for, + configuration: configuration) end def strategy_class diff --git a/lib/ea/spa/output/sharded_multi_file_strategy.rb b/lib/ea/spa/output/sharded_multi_file_strategy.rb index 47b96d7..f028a50 100644 --- a/lib/ea/spa/output/sharded_multi_file_strategy.rb +++ b/lib/ea/spa/output/sharded_multi_file_strategy.rb @@ -14,12 +14,12 @@ def render(projector) FileUtils.mkdir_p(output_path) FileUtils.mkdir_p(File.join(output_path, "data")) - write_json(File.join(output_path, "skeleton.json"), - projector.skeleton) + skeleton = projector.skeleton + write_json(File.join(output_path, "skeleton.json"), skeleton) write_json(File.join(output_path, "search.json"), projector.search_index) write_shards(projector) - write_index_html + write_index_html(skeleton) output_path end @@ -42,14 +42,17 @@ def pluralize(kind) end end - def write_index_html + def write_index_html(skeleton) + title = skeleton.view_extras&.dig("ui", "title") || + skeleton.metadata&.fetch("title", nil) || + "Ea::Spa" File.write(File.join(output_path, "index.html"), <<~HTML) - Ea::Spa + #{title}
diff --git a/lib/ea/spa/output/single_file_strategy.rb b/lib/ea/spa/output/single_file_strategy.rb index ceee9fe..dd3fdb5 100644 --- a/lib/ea/spa/output/single_file_strategy.rb +++ b/lib/ea/spa/output/single_file_strategy.rb @@ -13,13 +13,15 @@ class SingleFileStrategy < Strategy def render(projector) FileUtils.mkdir_p(File.dirname(output_path)) + skeleton = projector.skeleton payload = { - metadata: projector.skeleton.metadata, - packageTree: projector.skeleton.package_tree, - skeletonEntries: projector.skeleton.entries, + metadata: skeleton.metadata, + packageTree: skeleton.package_tree, + skeletonEntries: skeleton.entries, searchIndex: projector.search_index, shards: projector.each_shard.to_a } + payload[:viewExtras] = skeleton.view_extras unless skeleton.view_extras.nil? || skeleton.view_extras.empty? File.write(output_path, build_html(payload)) output_path @@ -28,13 +30,16 @@ def render(projector) private def build_html(payload) + title = payload.dig(:viewExtras, "ui", "title") || + payload[:metadata]&.fetch("title", nil) || + "Ea::Spa" <<~HTML - #{payload[:metadata]&.fetch("title", nil) || "Ea::Spa"} + #{title}
diff --git a/lib/ea/spa/projector.rb b/lib/ea/spa/projector.rb index fee87df..60a7b1d 100644 --- a/lib/ea/spa/projector.rb +++ b/lib/ea/spa/projector.rb @@ -13,18 +13,20 @@ module Spa # proc — by default it produces "data/s/.json" paths # matching the sharded output strategy. class Projector - attr_reader :document, :shard_url_for + attr_reader :document, :shard_url_for, :configuration - def initialize(document, shard_url_for: nil) + def initialize(document, shard_url_for: nil, configuration: nil) @document = document @shard_url_for = shard_url_for || default_shard_url + @configuration = configuration end def skeleton Skeleton.new( metadata: metadata_hash, package_tree: build_package_tree, - entries: build_entries + entries: build_entries, + view_extras: view_extras ) end @@ -67,7 +69,13 @@ def pluralize(kind) end def metadata_hash - JSON.parse(document.metadata.to_json) + base = document.metadata + merged = configuration ? configuration.apply_to_metadata(base) : base + JSON.parse(merged.to_json) + end + + def view_extras + configuration&.view_extras || {} end def build_package_tree diff --git a/lib/ea/spa/skeleton.rb b/lib/ea/spa/skeleton.rb index 644c814..69d74fe 100644 --- a/lib/ea/spa/skeleton.rb +++ b/lib/ea/spa/skeleton.rb @@ -6,14 +6,16 @@ module Spa # type) because it's a thin transport wrapper around typed # sub-objects (PackageTree, SkeletonEntry) plus a raw metadata # hash extracted from the model. - Skeleton = Struct.new(:metadata, :package_tree, :entries, + Skeleton = Struct.new(:metadata, :package_tree, :entries, :view_extras, keyword_init: true) do def to_json(*args) - JSON.generate({ - "metadata" => metadata, - "packageTree" => package_tree, - "entries" => entries - }, *args) + payload = { + "metadata" => metadata, + "packageTree" => package_tree, + "entries" => entries + } + payload["viewExtras"] = view_extras unless view_extras.nil? || view_extras.empty? + JSON.generate(payload, *args) end end end diff --git a/lib/ea/svg.rb b/lib/ea/svg.rb new file mode 100644 index 0000000..8b96a2d --- /dev/null +++ b/lib/ea/svg.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Ea + # Ea::Svg is a consumer adapter of Ea::Model::Diagram — it + # projects the umldi (UML Diagram Interchange) content of a + # diagram into standalone SVG. Coordinates are taken straight + # from the source: EA's pixel space (rectleft, recttop, + # rectright, rectbottom, t_diagramlinks.geometry) or XMI's + # uml:Diagram owned_element bounds. + # + # Distinct from Ea::Diagram::SvgRenderer which operates on the + # legacy Lutaml::Uml::Document pipeline. This module consumes + # the canonical Ea::Model types only. + module Svg + autoload :BoundsCalculator, "ea/svg/bounds_calculator" + autoload :StyleResolver, "ea/svg/style_resolver" + autoload :ElementBox, "ea/svg/element_box" + autoload :ConnectorPath, "ea/svg/connector_path" + autoload :Renderer, "ea/svg/renderer" + end +end diff --git a/lib/ea/svg/bounds_calculator.rb b/lib/ea/svg/bounds_calculator.rb new file mode 100644 index 0000000..9e5b32d --- /dev/null +++ b/lib/ea/svg/bounds_calculator.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Ea + module Svg + # Computes the actual drawing-area bounding box of an + # Ea::Model::Diagram. EA stores bounds in pixel space with two + # quirks: element rects can be inverted (rectbottom < recttop, + # producing negative height), and elements/connectors can sit + # outside the canvas bounds. The drawing area is the union of + # all element rects and connector waypoint positions. + class BoundsCalculator + attr_reader :diagram + + def initialize(diagram) + @diagram = diagram + end + + def compute + points = all_points + return fallback_bounds if points.empty? + + xs = points.map(&:first) + ys = points.map(&:last) + Bounds.new(x: xs.min, y: ys.min, width: xs.max - xs.min, + height: ys.max - ys.min) + end + + private + + def all_points + points = [] + diagram.elements.each do |elem| + next unless elem.bounds + + b = normalize_bounds(elem.bounds) + points << [b.x, b.y] + points << [b.x + b.width, b.y + b.height] + end + diagram.connectors.each do |conn| + conn.waypoints.each do |wp| + next unless wp.position + + points << [wp.position.x, wp.position.y] + end + end + points + end + + # EA occasionally stores inverted rects (rectbottom < recttop). + # Normalize so width/height are non-negative. + def normalize_bounds(bounds) + x = bounds.x + y = bounds.y + w = bounds.width + h = bounds.height + x, w = x + w, -w if w.negative? + y, h = y + h, -h if h.negative? + Bounds.new(x: x, y: y, width: w, height: h) + end + + def fallback_bounds + return Bounds.new(x: 0, y: 0, width: 1, height: 1) unless diagram.bounds + + Bounds.new(x: diagram.bounds.x, y: diagram.bounds.y, + width: diagram.bounds.width.abs, + height: diagram.bounds.height.abs) + end + + # Internal value type — keep private to avoid polluting the + # Ea::Svg namespace. + Bounds = Struct.new(:x, :y, :width, :height, keyword_init: true) + end + end +end diff --git a/lib/ea/svg/connector_path.rb b/lib/ea/svg/connector_path.rb new file mode 100644 index 0000000..af02e4e --- /dev/null +++ b/lib/ea/svg/connector_path.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Ea + module Svg + # Renders one DiagramConnector as an SVG through + # its waypoints. Arrowheads and per-direction routing are + # future work; today we emit a straight-segment polyline + # matching the source layout. + class ConnectorPath + attr_reader :connector + + def initialize(connector) + @connector = connector + end + + def render + points = waypoints + return "" if points.empty? + + <<~SVG.chomp + + SVG + end + + private + + def waypoints + connector.waypoints.filter_map do |wp| + next unless wp.position + + "#{wp.position.x},#{wp.position.y}" + end + end + + def stroke_color + StyleResolver.new(connector.style).stroke_color + end + + def stroke_width + StyleResolver.new(connector.style).stroke_width + end + end + end +end diff --git a/lib/ea/svg/element_box.rb b/lib/ea/svg/element_box.rb new file mode 100644 index 0000000..bc4d27d --- /dev/null +++ b/lib/ea/svg/element_box.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "ostruct" + +module Ea + module Svg + # Renders one DiagramElement as an SVG containing the rect + # and a name label. Class-shape rendering (with attribute / + # operation compartments) is future work; for now we emit a + # simple labeled box that mirrors EA's logical placement. + class ElementBox + attr_reader :element, :model_index + + def initialize(element, model_index:) + @element = element + @model_index = model_index + end + + def render + return "" unless element.bounds + + b = normalize_bounds(element.bounds) + style = StyleResolver.new(element.style) + label_text = model_element_name + + <<~SVG.chomp + + + #{escape(label_text)} + + SVG + end + + private + + def model_element_name + ref = element.model_element_ref + return "(unbound)" unless ref + + model = model_index[ref] + model&.name || "(missing #{ref})" + end + + def normalize_bounds(bounds) + x = bounds.x + y = bounds.y + w = bounds.width + h = bounds.height + x, w = x + w, -w if w.negative? + y, h = y + h, -h if h.negative? + OpenStruct.new(x: x, y: y, width: w, height: h) + end + + def escape(text) + return "" if text.nil? + + text.to_s + .gsub("&", "&") + .gsub("<", "<") + .gsub(">", ">") + .gsub("\"", """) + end + end + end +end diff --git a/lib/ea/svg/renderer.rb b/lib/ea/svg/renderer.rb new file mode 100644 index 0000000..7fac03c --- /dev/null +++ b/lib/ea/svg/renderer.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "ostruct" + +module Ea + module Svg + # Renders an Ea::Model::Diagram into a standalone SVG string. + # Coordinates come straight from the umldi (UML Diagram + # Interchange) content captured by the source adapter: + # DiagramElement bounds and DiagramConnector waypoints. + # + # The renderer walks the diagram once, computes the union + # bounding box, and emits SVG in the same coordinate space + # (no y-flip; SVG's +y is down, same as EA's pixel space). + class Renderer + DEFAULT_PADDING = 20 + + attr_reader :diagram, :model_index, :options + + def initialize(diagram, model_index: {}, padding: DEFAULT_PADDING) + @diagram = diagram + @model_index = model_index + @options = { padding: padding } + end + + def render + bounds = BoundsCalculator.new(diagram).compute + padded = pad(bounds) + + <<~SVG + + + + #{render_elements} + #{render_connectors} + + SVG + end + + private + + def render_elements + diagram.elements.map do |elem| + ElementBox.new(elem, model_index: model_index).render + end.join("\n ") + end + + def render_connectors + diagram.connectors.map do |conn| + ConnectorPath.new(conn).render + end.join("\n ") + end + + def pad(bounds) + p = options[:padding] + OpenStruct.new( + x: bounds.x - p, + y: bounds.y - p, + width: bounds.width + (2 * p), + height: bounds.height + (2 * p) + ) + end + end + end +end diff --git a/lib/ea/svg/style_resolver.rb b/lib/ea/svg/style_resolver.rb new file mode 100644 index 0000000..c00acb7 --- /dev/null +++ b/lib/ea/svg/style_resolver.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Ea + module Svg + # Translates the parsed style hash (from + # Ea::Sources::Qea::DiagramStyleParser, or the equivalent for + # XMI) into SVG-friendly attributes. EA packs colors as + # integers (e.g. 16777215 = white); we translate to CSS hex. + class StyleResolver + DEFAULT_FILL = "#ffffff" + DEFAULT_STROKE = "#000000" + DEFAULT_FONT_COLOR = "#000000" + + attr_reader :style + + def initialize(style_hash) + @style = style_hash || {} + end + + def fill_color + color_from_ea(style[:bcol]) || DEFAULT_FILL + end + + def stroke_color + color_from_ea(style[:lcol]) || DEFAULT_STROKE + end + + def font_color + color_from_ea(style[:fcol]) || DEFAULT_FONT_COLOR + end + + def stroke_width + raw = style[:lwd] + return 1 if raw.nil? || raw.to_s.empty? + + Integer(raw) + rescue ArgumentError + 1 + end + + def to_svg_attrs + { + fill: fill_color, + stroke: stroke_color, + "stroke-width": stroke_width + } + end + + # EA stores colors as decimal integers in BGR byte order + # (low byte = red, high byte = blue). The high byte may hold + # alpha in some variants; we treat anything above 0xFFFFFF + # as opaque. + def color_from_ea(raw) + return nil if raw.nil? || raw.to_s.empty? + + # Hex strings (e.g. from XMI): passthrough. + return "##{raw}" if raw.to_s.match?(/\A[0-9a-fA-F]{6}\z/) + + Integer(raw.to_s) + rescue ArgumentError + nil + else + # Convert BGR integer → RGB hex. + value = Integer(raw) + b = (value >> 16) & 0xff + g = (value >> 8) & 0xff + r = value & 0xff + format("#%02X%02X%02X", r, g, b) + end + end + end +end diff --git a/lib/ea/version.rb b/lib/ea/version.rb index 777a0d9..633344c 100644 --- a/lib/ea/version.rb +++ b/lib/ea/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Ea - VERSION = "0.2.5" + VERSION = "0.3.0" end diff --git a/spec/ea/cli/app_spec.rb b/spec/ea/cli/app_spec.rb index 5d3e0fb..aa5eedb 100644 --- a/spec/ea/cli/app_spec.rb +++ b/spec/ea/cli/app_spec.rb @@ -77,9 +77,9 @@ expect(output).to match(/--mode=/) end - it "does not expose a --config flag (legacy pipeline removed)" do + it "exposes -c as alias for --config on the spa command" do output = capture_stdout { described_class.start(%w[help spa]) } - expect(output).not_to match(/--config=/) + expect(output).to match(/-c,\s+\[--config=CONFIG\]/) end it "does not expose a --legacy flag" do diff --git a/spec/ea/cli/spa_spec.rb b/spec/ea/cli/spa_spec.rb index bdd4c85..1341833 100644 --- a/spec/ea/cli/spa_spec.rb +++ b/spec/ea/cli/spa_spec.rb @@ -72,3 +72,41 @@ end end end + +RSpec.describe "ea spa CLI --config flag" do + let(:app) { Ea::Cli::App.new } + let(:qea_fixture) { fixtures_path("basic.qea") } + + after { FileUtils.rm_f([@out, @config].compact) } + + it "applies metadata.title from YAML config" do + @out = "/tmp/ea_spa_config_metadata_spec.html" + @config = "/tmp/ea_spa_config_test.yml" + File.write(@config, "metadata:\n title: Unique Config Title\n") + capture_stdout do + app.invoke(:spa, [qea_fixture], output: @out, config: @config) + end + content = File.read(@out) + expect(content).to include("Unique Config Title") + end + + it "applies ui.title as the tag" do + @out = "/tmp/ea_spa_config_uititle_spec.html" + @config = "/tmp/ea_spa_config_ui.yml" + File.write(@config, "ui:\n title: Browser Tab Title\n") + capture_stdout do + app.invoke(:spa, [qea_fixture], output: @out, config: @config) + end + content = File.read(@out) + expect(content).to match(%r{<title>Browser Tab Title}) + end + + it "raises FileNotFound when config path does not exist" do + expect { + capture_stdout do + app.invoke(:spa, [qea_fixture], output: "/tmp/x.html", + config: "/tmp/ea-does-not-exist-#{Time.now.to_i}.yml") + end + }.to raise_error(Ea::Cli::FileNotFound, /SPA config not found/) + end +end diff --git a/spec/ea/cli/svg_command_spec.rb b/spec/ea/cli/svg_command_spec.rb new file mode 100644 index 0000000..88f065e --- /dev/null +++ b/spec/ea/cli/svg_command_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "spec_helper" +require "ea/cli/app" + +RSpec.describe "ea svg CLI command" do + let(:app) { Ea::Cli::App.new } + let(:xmi_fixture) { fixtures_path("basic.xmi") } + + after { FileUtils.rm_f([@out].compact) } + + it "renders a named diagram to a standalone SVG file" do + @out = "/tmp/ea_svg_command_spec.svg" + capture_stdout do + app.invoke(:svg, ["Starter Object Diagram", xmi_fixture], output: @out) + end + expect(File.exist?(@out)).to be(true) + contents = File.read(@out) + expect(contents).to start_with(" per element plus the background" do + # background + 2 elements + expect(svg.scan(/ per connector with waypoints" do + expect(svg.scan(/Building<") + expect(svg).to include(">Floor<") + end + + it "includes padding around the drawing area" do + # Elements span x=100..700, y=100..180. With padding 20: + # viewBox should be "80 80 640 120" + expect(svg).to match(/viewBox="80 80 640 120"/) + end +end