diff --git a/lib/lutaml/lml.rb b/lib/lutaml/lml.rb index 6b55804..cd713c3 100644 --- a/lib/lutaml/lml.rb +++ b/lib/lutaml/lml.rb @@ -35,6 +35,9 @@ def self.compile(input, namespace: nil) autoload :HasAttributes, "lutaml/lml/has_attributes" autoload :VERSION, "lutaml/lml/version" + # Type classes (Lutaml::Model::Type::Value subclasses, not models) + autoload :LiteralValue, "lutaml/lml/literal_value" + # Model classes (in Lutaml::Lml namespace, files in models/ directory) autoload :Action, "lutaml/lml/models/action" autoload :Association, "lutaml/lml/models/association" diff --git a/lib/lutaml/lml/data_processor/attribute_processing.rb b/lib/lutaml/lml/data_processor/attribute_processing.rb index 65c54e7..09a4a6e 100644 --- a/lib/lutaml/lml/data_processor/attribute_processing.rb +++ b/lib/lutaml/lml/data_processor/attribute_processing.rb @@ -14,6 +14,7 @@ def process_attributes(obj) end def process_attributes_array(obj) + return [] if obj.empty? return obj.map { |item| process_attributes(item) } unless single_key_hashes?(obj) obj.each_with_object({}) do |item, hash| diff --git a/lib/lutaml/lml/data_processor/instance_processing.rb b/lib/lutaml/lml/data_processor/instance_processing.rb index 0b98fb8..293300f 100644 --- a/lib/lutaml/lml/data_processor/instance_processing.rb +++ b/lib/lutaml/lml/data_processor/instance_processing.rb @@ -26,8 +26,7 @@ def process_instances(obj) key = INSTANCE_KEY_HANDLERS.keys.find { |k| instance.key?(k) } next unless key - result = public_send(INSTANCE_KEY_HANDLERS[key], instance[key]) - key == :instance ? (acc[:instances] << result) : (acc[key] = result) + append_result(acc, key, public_send(INSTANCE_KEY_HANDLERS[key], instance[key])) end end @@ -57,6 +56,16 @@ def handle_instance_attributes(value, result) def handle_instance_template(value, result) result[:template] = process_attributes(value[:attributes]) end + + private + + def append_result(acc, key, result) + case key + when :instance then acc[:instances] << result + when :collections then (acc[:collections] ||= []) << result + else (acc[key] ||= []).concat(result) + end + end end end end diff --git a/lib/lutaml/lml/executor.rb b/lib/lutaml/lml/executor.rb index 305e3b9..9186504 100644 --- a/lib/lutaml/lml/executor.rb +++ b/lib/lutaml/lml/executor.rb @@ -73,10 +73,9 @@ def import_one(imp) def validate_collections(doc, instances) return [] unless doc.instances&.collections - collection = doc.instances.collections - return [] unless collection.is_a?(Collection) - - ConditionEvaluator.evaluate(collection, instances) + Array(doc.instances.collections).flat_map do |collection| + ConditionEvaluator.evaluate(collection, instances) + end end # --- Export --- diff --git a/lib/lutaml/lml/executor/condition_evaluator.rb b/lib/lutaml/lml/executor/condition_evaluator.rb index b766fc3..cd5e9cc 100644 --- a/lib/lutaml/lml/executor/condition_evaluator.rb +++ b/lib/lutaml/lml/executor/condition_evaluator.rb @@ -30,8 +30,8 @@ class ConditionEvaluator # Evaluate all validation conditions against a collection of instances. # Returns an array of error strings (empty if all pass). def self.evaluate(collection, instances) - return [] unless collection.validations&.any? return [] unless collection.is_a?(Collection) + return [] unless collection.validations&.any? new(instances).evaluate_all(collection.validations) end diff --git a/lib/lutaml/lml/grammar/concerns/primitives.rb b/lib/lutaml/lml/grammar/concerns/primitives.rb index 8574807..eddb1be 100644 --- a/lib/lutaml/lml/grammar/concerns/primitives.rb +++ b/lib/lutaml/lml/grammar/concerns/primitives.rb @@ -19,9 +19,18 @@ module Primitives rule(:whitespace?) { whitespace.maybe } rule(:newline) { match('[\r\n]') } - rule(:quoted_string) do - str('"') >> (str('"').absent? >> any).repeat.as(:string) >> str('"') + # Body of a string delimited by `char`, captured as `label`. + def quoted(char, label) + str(char) >> (str(char).absent? >> any).repeat.as(label) >> str(char) end + + # A string in either quote style. Delimiters must match, so `"a'` + # stays a parse error. + def any_quoted(label) + quoted('"', label) | quoted("'", label) + end + + rule(:quoted_string) { any_quoted(:string) } rule(:boolean) { (str("true") | str("false")).as(:boolean) } rule(:number) { (match("[0-9]").repeat(1) >> str(".") >> match("[0-9]").repeat(1)).as(:float) | match("[0-9]").repeat(1).as(:number) } rule(:variable) { (quoted_string | match("[a-zA-Z0-9_]").repeat(1)) } diff --git a/lib/lutaml/lml/grammar/concerns/view_rules.rb b/lib/lutaml/lml/grammar/concerns/view_rules.rb index c8e4a13..680ee40 100644 --- a/lib/lutaml/lml/grammar/concerns/view_rules.rb +++ b/lib/lutaml/lml/grammar/concerns/view_rules.rb @@ -10,9 +10,7 @@ module ViewRules rule(:view_import_keyword) { str("import") >> spaces } rule(:view_import) do - view_import_keyword >> - str('"') >> (str('"').absent? >> any).repeat.as(:path) >> str('"') >> - whitespace? + view_import_keyword >> any_quoted(:path) >> whitespace? end rule(:entity_name_list) do diff --git a/lib/lutaml/lml/literal_value.rb b/lib/lutaml/lml/literal_value.rb new file mode 100644 index 0000000..f6fe1cf --- /dev/null +++ b/lib/lutaml/lml/literal_value.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "lutaml/model" + +module Lutaml + module Lml + # The type of an LML attribute literal: a scalar, a list, or a map. + # + # This exists as a subclass rather than using Lutaml::Model::Type::Value + # directly because lutaml-model's key-value serializer tests the declared + # attribute type with a strict `attribute_type < Type::Value`. Type::Value + # is not a strict subclass of itself, so declaring it hands the serializer + # the raw wrapper object: JSON calls to_json(state) on it and raises + # ArgumentError, and Psych emits a !ruby/object tag that from_yaml then + # refuses to load. A subclass puts the attribute on the working path, where + # the serializer asks the wrapper for its value instead. + # + # Deleting this class needs two upstream changes, not one: that `<` widened + # to `<=`, and the serializer no longer re-wrapping a value it already + # wrapped. Widening `<` alone still leaves the nested wrapper below. + class LiteralValue < Lutaml::Model::Type::Value + # The serializer re-wraps an already-wrapped value before asking it for a + # format, so #value has to see through that extra layer. Every to_ + # method lutaml-model generates reads through here, which is why this is + # one override rather than one per format. + def value + wrapped = super + wrapped.is_a?(LiteralValue) ? wrapped.value : wrapped + end + end + end +end diff --git a/lib/lutaml/lml/models/instance_collection.rb b/lib/lutaml/lml/models/instance_collection.rb index e17e8d3..0f75544 100644 --- a/lib/lutaml/lml/models/instance_collection.rb +++ b/lib/lutaml/lml/models/instance_collection.rb @@ -6,7 +6,7 @@ class InstanceCollection < Lutaml::Model::Serializable attribute :instances, "Lutaml::Lml::Instance", collection: true, default: [] attribute :imports, "Lutaml::Lml::InstancesImport", collection: true, default: [] attribute :exports, "Lutaml::Lml::InstancesExport", collection: true, default: [] - attribute :collections, "Lutaml::Lml::Collection", default: [] + attribute :collections, "Lutaml::Lml::Collection", collection: true, default: [] end end end diff --git a/lib/lutaml/lml/models/top_element_attribute.rb b/lib/lutaml/lml/models/top_element_attribute.rb index 46231a9..1243906 100644 --- a/lib/lutaml/lml/models/top_element_attribute.rb +++ b/lib/lutaml/lml/models/top_element_attribute.rb @@ -22,10 +22,73 @@ class TopElementAttribute < Lutaml::Model::Serializable # LML-specific attributes attribute :properties, "Lutaml::Lml::TopElementAttribute", collection: true, default: [] - attribute :value, "Lutaml::Lml::TopElementAttribute", collection: true + attribute :value, LiteralValue attribute :attributes, "Lutaml::Lml::TopElementAttribute", collection: true, default: [] attribute :extended, :boolean attribute :instances, "Lutaml::Lml::Instance", collection: true, default: [] + + # Declared in full because a mapping block replaces the defaults. The + # rules below are the defaults, 1:1, except for `value`. + # + # `value` needs custom methods because a list literal reaches the model + # as a bare Array, and lutaml-model refuses one on a non-collection + # attribute: Attribute#cast splits it, then valid_collection! raises + # CollectionTrueMissingError. A custom `from` returns before that check, + # so a non-empty list survives a round trip without changing the + # serialized form. An empty list still does not: the transform skips a + # blank value before reaching the custom method, so `[]` reloads as nil. + key_value do + map "name", to: :name + map "visibility", to: :visibility + map "type", to: :type + map "id", to: :id + map "contain", to: :contain + map "static", to: :static + map "cardinality", to: :cardinality + map "keyword", to: :keyword + map "is_derived", to: :is_derived + map "is_static", to: :is_static + map "is_read_only", to: :is_read_only + map "stereotype", to: :stereotype + map "definition", to: :definition + map "association", to: :association + map "default", to: :default + map "properties", to: :properties + map "value", to: :value, with: { to: :value_to, from: :value_from } + map "attributes", to: :attributes + map "extended", to: :extended + map "instances", to: :instances + end + + # Public because lutaml-model invokes mapping methods via public_send. + # + # Skipping nil keeps the key out of the output, which is what the default + # mapping does. `false` is a real literal, so the guard tests for nil + # rather than truthiness. + def value_to(model, doc) + literal = model.value + return if literal.nil? + + doc["value"] = unwrap_literal(literal) + end + + # lutaml-model runs this on a throwaway mapper instance, so `self` is not + # the object being deserialized - `model` is. Writing to `self.value` + # here would update an object that is discarded a moment later. + def value_from(model, value) + model.value = value + end + + private + + # A LiteralValue sits at the top level for a map literal, and inside the + # array for a list of references. Writing one through unwrapped raises + # ArgumentError in the adapter. + def unwrap_literal(value) + return value.map { |item| unwrap_literal(item) } if value.is_a?(::Array) + + value.is_a?(LiteralValue) ? value.value : value + end end end end diff --git a/spec/fixtures/mixed_lml/instances.lml b/spec/fixtures/mixed_lml/instances.lml index cf48a0d..94d4805 100644 --- a/spec/fixtures/mixed_lml/instances.lml +++ b/spec/fixtures/mixed_lml/instances.lml @@ -12,6 +12,12 @@ instances { } } + collection "test_suite_2" { + includes [ + "gaming_pc" + ] + } + import { xml "test_data/products.xml" { map_to Product diff --git a/spec/lutaml/lml/cli_spec.rb b/spec/lutaml/lml/cli_spec.rb new file mode 100644 index 0000000..676059a --- /dev/null +++ b/spec/lutaml/lml/cli_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tmpdir" +require "lutaml/lml/cli" + +# The gem writes YAML with Document#to_yaml and reads it back through the +# `-i yaml` input path. A document the gem produced itself has to survive that +# trip, so this drives the real Thor command rather than the parser underneath. +# +# `generate` is the command under test because it is the one that sets +# @input_format. `validate` never calls setup_options, so it cannot read any +# format at all - a separate pre-existing defect, not touched here. +RSpec.describe Lutaml::Cli::LmlCommands do + def write_yaml(fixture, dir) + doc = Lutaml::Lml::Parser.parse(File.new(fixtures_path(fixture))) + path = File.join(dir, "#{File.basename(fixture, '.lml')}.yaml") + File.write(path, doc.to_yaml) + path + end + + def find_attribute(instance, name) + return nil unless instance + + Array(instance.attributes).each do |attr| + return attr if attr.name == name + + Array(attr.instances).each do |nested| + found = find_attribute(nested, name) + return found if found + end + end + find_attribute(instance.instance, name) + end + + def generate(path, dir) + described_class.start( + ["generate", "-i", "yaml", "-o", File.join(dir, "out.dot"), path], + ) + end + + # The defect is CollectionTrueMissingError raised out of the YAML reload, + # before anything is rendered. Reaching Graphviz therefore proves the reload + # worked, and Graphviz shells out to `dot`, which CI runners do not have. + # So a missing binary at the render step is a pass, and any other error + # still fails the example. + def reload_through_cli(path, dir) + generate(path, dir) + :rendered + rescue Errno::ENOENT => e + raise unless e.message.include?("dot") + + :reached_renderer + end + + describe "generate -i yaml" do + # data_s102_check.lml carries `prerequisites = [ "S102_Dev1009" ]`, a list + # literal. Before this change the reload raised CollectionTrueMissingError + # straight out of the command. + it "reads back YAML it wrote from a document with a list-valued attribute" do + Dir.mktmpdir do |dir| + path = write_yaml("lml/data_s102_check.lml", dir) + + expect(%i[rendered reached_renderer]) + .to include(reload_through_cli(path, dir)) + end + end + + it "reads back YAML it wrote from a document with a map-valued attribute" do + Dir.mktmpdir do |dir| + path = write_yaml("mixed_lml/instances.lml", dir) + + expect(%i[rendered reached_renderer]) + .to include(reload_through_cli(path, dir)) + end + end + + # The two examples above prove the command completes. They cannot prove the + # list came back, because these fixtures are instance data and the graphviz + # formatter only draws classes - the .dot it writes is empty boilerplate + # either way. So assert the values through the handler the command actually + # dispatches to for `-i yaml`, which is where the crash used to happen. + it "recovers the list values through the handler generate dispatches to" do + Dir.mktmpdir do |dir| + path = write_yaml("lml/data_s102_check.lml", dir) + + doc = described_class::PARSE_HANDLERS["yaml"].call(Pathname.new(path)) + + expect(find_attribute(doc.instance, "prerequisites").value) + .to eq(["S102_Dev1009"]) + end + end + end +end diff --git a/spec/lutaml/lml/data_processor_spec.rb b/spec/lutaml/lml/data_processor_spec.rb index 4b25db2..a8bda1c 100644 --- a/spec/lutaml/lml/data_processor_spec.rb +++ b/spec/lutaml/lml/data_processor_spec.rb @@ -185,6 +185,10 @@ expect(result).to have_key(:instances) expect(result[:instances].length).to eq(1) end + + it "returns an empty array for an empty attributes body" do + expect(processor.process_attributes_array([])).to eq([]) + end end describe "#process_requires" do @@ -227,6 +231,25 @@ end end + describe "#process_instances" do + it "accumulates repeated collection, import, and export blocks" do + input = [ + { collections: { name: { string: "c1" }, includes: [{ string: "x" }] } }, + { collections: { name: { string: "c2" }, includes: [{ string: "y" }] } }, + { imports: [{ format_type: "xml", file: "a.xml" }] }, + { imports: [{ format_type: "csv", file: "b.csv" }] }, + { exports: [{ format_type: "xml" }] }, + { exports: [{ format_type: "step" }] } + ] + + result = processor.process_instances(input) + + expect(result[:collections].map { |c| c[:name] }).to eq(%w[c1 c2]) + expect(result[:imports].map { |i| i[:file] }).to eq(%w[a.xml b.csv]) + expect(result[:exports].map { |e| e[:format_type] }).to eq(%w[xml step]) + end + end + describe "ViewProcessing" do describe "#process_show_list" do it "extracts entity names from array of hashes" do diff --git a/spec/lutaml/lml/executor/condition_evaluator_spec.rb b/spec/lutaml/lml/executor/condition_evaluator_spec.rb index e7760e9..d2d37f8 100644 --- a/spec/lutaml/lml/executor/condition_evaluator_spec.rb +++ b/spec/lutaml/lml/executor/condition_evaluator_spec.rb @@ -373,6 +373,12 @@ expect(errors.first).to include("must reference i") end + # Object.new specifically: the type guard has to run before the + # validations call, so the argument must not respond to `validations`. + it "returns no errors for an argument that is not a Collection" do + expect(described_class.evaluate(Object.new, [])).to eq([]) + end + it "rescues NoMethodError on undefined instance attributes" do collection = Lutaml::Lml::Collection.new( name: "test", diff --git a/spec/lutaml/lml/executor_spec.rb b/spec/lutaml/lml/executor_spec.rb index a797c6e..14c0df2 100644 --- a/spec/lutaml/lml/executor_spec.rb +++ b/spec/lutaml/lml/executor_spec.rb @@ -55,19 +55,21 @@ def self.export(_exp, _instances, compiled:) .to raise_error(Lutaml::Lml::Executor::FormatAdapter::AdapterNotFoundError) end - it "collects validation errors from collection validations" do - collection = Lutaml::Lml::Collection.new( - name: "test", - validations: ["count >= 1"] - ) + it "collects validation errors from every collection block" do instances = Lutaml::Lml::InstanceCollection.new( - collections: collection + collections: [ + Lutaml::Lml::Collection.new(name: "a", validations: ["count >= 1"]), + Lutaml::Lml::Collection.new(name: "b", validations: ["count >= 2"]) + ] ) doc = Lutaml::Lml::Document.new(instances: instances) result = described_class.run(doc, compiled: {}) expect(result.instances).to eq([]) - expect(result.errors).to include(a_string_matching(/count >= 1/)) + expect(result.errors).to include( + a_string_matching(/count >= 1/), + a_string_matching(/count >= 2/) + ) end end end diff --git a/spec/lutaml/lml/format_adapter_spec.rb b/spec/lutaml/lml/format_adapter_spec.rb index b6ea1b2..675c39a 100644 --- a/spec/lutaml/lml/format_adapter_spec.rb +++ b/spec/lutaml/lml/format_adapter_spec.rb @@ -121,6 +121,21 @@ expect(lml).to include("name = second") end + # StandardAdapter.instance_to_hash branches on `value.is_a?(Array)` to map + # a list element-wise. `value_from` decides the in-memory shape after a + # reload, so this pins that a reloaded list still arrives as a bare Array + # and does not get stringified into a single scalar. + it "maps a list-valued attribute element-wise after a YAML reload" do + lml = "instance Checklist {\n type AuditList\n items = [\"verify\", \"validate\"]\n}\n" + doc = Lutaml::Lml::Document.from_yaml( + Lutaml::Lml::Parser.parse(StringIO.new(lml)).to_yaml, + ) + + hash = Lutaml::Lml::Format::Adapter::StandardAdapter.instance_to_hash(doc.instance) + + expect(hash["items"]).to eq(%w[verify validate]) + end + it "handles nested instances without __type__" do data = { "name" => "Top", diff --git a/spec/lutaml/lml/grammar_spec.rb b/spec/lutaml/lml/grammar_spec.rb index 54942d9..4a6553c 100644 --- a/spec/lutaml/lml/grammar_spec.rb +++ b/spec/lutaml/lml/grammar_spec.rb @@ -195,6 +195,18 @@ class ValidationCheck { expect(doc.requires).to include("deps.lml") file.close! end + + it "parses single-quoted require statements" do + file = Tempfile.new(%w[test .lml]) + file.write <<~LML + require 'deps.lml' + models Test { class Foo {} } + LML + file.rewind + doc = parser.parse(file) + expect(doc.requires).to include("deps.lml") + file.close! + end end describe "Grammar composition" do @@ -282,6 +294,15 @@ class ValidationCheck { file.close! end + it "parses view with single-quoted import directive" do + file = Tempfile.new(%w[test .lutaml]) + file.write("view MyView { import 'models/foo.lutaml' }") + file.rewind + doc = parser.parse(file) + expect(doc.view_imports.map(&:path)).to eq(["models/foo.lutaml"]) + file.close! + end + it "parses view with multiple imports" do file = Tempfile.new(%w[test .lutaml]) file.write("view MyView {\n import \"models/a.lutaml\"\n import \"models/b.lutaml\"\n}") diff --git a/spec/lutaml/lml/models/top_element_attribute_spec.rb b/spec/lutaml/lml/models/top_element_attribute_spec.rb index 09ad861..cf6df4c 100644 --- a/spec/lutaml/lml/models/top_element_attribute_spec.rb +++ b/spec/lutaml/lml/models/top_element_attribute_spec.rb @@ -45,4 +45,108 @@ expect(attr.properties.first.name).to eq("description") end end + + describe "key_value mapping" do + # Declaring a mapping block replaces the defaults, so an attribute added + # later is silently dropped from every key-value format unless it is also + # mapped. This is the guard for that. + # Each format builds its own mapping object from the one block, so every + # format is checked rather than trusting YAML to stand for the rest. + Lutaml::Model::FormatRegistry.key_value_formats.each do |format| + it "maps every declared attribute for #{format}" do + mapped = described_class.mappings_for(format).mappings.map(&:to) + + expect(mapped.sort).to eq(described_class.attributes.keys.sort) + end + + it "maps each attribute under its own name for #{format}" do + described_class.mappings_for(format).mappings.each do |rule| + expect(rule.name).to eq(rule.to.to_s) + end + end + end + end + + describe "attribute value round trip" do + def unwrap(value) + return value.map { |item| unwrap(item) } if value.is_a?(Array) + + value.is_a?(Lutaml::Lml::LiteralValue) ? value.value : value + end + + def round_trip(name, body, format) + lml = "instances {\n Product \"p\" {\n #{body}\n }\n}\n" + doc = Lutaml::Lml::Parser.parse(StringIO.new(lml)) + reloaded = Lutaml::Lml::Document.public_send( + :"from_#{format}", doc.public_send(:"to_#{format}") + ) + attr = reloaded.instances.instances.first.attributes.find { |a| a.name == name } + raise "no attribute named #{name.inspect}" unless attr + + unwrap(attr.value) + end + + # A map literal's value can only be boolean | reference | range | number | + # quoted_string (grammar `key_value_pair` -> `value`). One row per shape, + # because a corpus that stops one shape short is how the list case survived. + # + # Note the range row expects string bounds while the integer row expects a + # real Integer: range endpoints come off the parser as text and are never + # coerced. That asymmetry is the parser's, and these rows pin it. + { + "string" => [%q(id = "component_id"), { id: "component_id" }], + "single-quoted" => [%q(id = 'component_id'), { id: "component_id" }], + "boolean" => [%q(flag = true), { flag: true }], + "integer" => [%q(n = 42), { n: 42 }], + "float" => [%q(f = 1.5), { f: 1.5 }], + "reference" => [%q(r = reference:(Product.id)), { r: { reference: "Product.id" } }], + "range" => [%q(r = 1..9), { r: { range: { start: "1", end: "9" } } }], + "no-equals pair" => [%q(id "component_id"), { id: "component_id" }], + }.each do |shape, (pair, expected)| + it "reloads a map value with a #{shape} through YAML" do + body = "columns {\n #{pair}\n }" + expect(round_trip("columns", body, :yaml)).to eq(expected) + end + end + + # The defect this change fixes: these raised CollectionTrueMissingError out + # of Document.from_yaml, which is the CLI's `-i yaml` input path. + { + "strings" => [%q(tags = ["a", "b"]), ["a", "b"]], + "mixed scalar types" => [%q(tags = ["a", 1, true]), ["a", 1, true]], + "one element" => [%q(tags = ["only"]), ["only"]], + }.each do |shape, (body, expected)| + %i[yaml json].each do |format| + it "reloads a list of #{shape} through #{format.upcase}" do + expect(round_trip("tags", body, format)).to eq(expected) + end + end + end + + it "reloads a list of references through YAML" do + expect(round_trip("tags", %q(tags = [reference:(A.b)]), :yaml)) + .to eq([{ reference: "A.b" }]) + end + + # Known limitation, and pre-existing rather than introduced here: + # lutaml-model's key-value transform returns early on a blank value, so an + # empty sequence does not survive. Pinned as a limitation, not asserted as + # a round trip. + it "does not preserve an empty list" do + expect(round_trip("tags", %q(tags = []), :yaml)).to be_nil + end + + it "keeps a false literal, which is not the same as an absent value" do + expect(round_trip("flag", %q(flag = false), :yaml)).to eq(false) + end + + # Asserts the absence of the key, not the absence of the text. An attribute + # whose name merely contains "value" would defeat a substring check while + # the guard is working perfectly. + it "omits the value key entirely when there is no value" do + attr = described_class.new(name: "value_kind", type: "ValidationCheck") + + expect(YAML.safe_load(attr.to_yaml)).not_to have_key("value") + end + end end diff --git a/spec/lutaml/lml/parser_spec.rb b/spec/lutaml/lml/parser_spec.rb index 11dca0c..6db1059 100644 --- a/spec/lutaml/lml/parser_spec.rb +++ b/spec/lutaml/lml/parser_spec.rb @@ -235,12 +235,13 @@ def parse_lml(fname) expect(doc.instances).to be_a(Lutaml::Lml::InstanceCollection) end - it "maps collections correctly" do + it "maps every collection block, in order" do collections = doc.instances.collections - expect(collections).to be_a(Lutaml::Lml::Collection) - expect(collections.name).to eq("test_suite_1") - expect(collections.includes).to eq(["laptop_123", "desktop_1", "desktop_2"]) - expect(collections.validations).to eq(["count >= 3", "all? { |i| i.components.count > 0 }"]) + expect(collections.map(&:name)).to eq(%w[test_suite_1 test_suite_2]) + suite = collections.first + expect(suite.includes).to eq(["laptop_123", "desktop_1", "desktop_2"]) + expect(suite.validations).to eq(["count >= 3", "all? { |i| i.components.count > 0 }"]) + expect(collections.last.includes).to eq(["gaming_pc"]) end it "maps imports correctly" do @@ -256,6 +257,39 @@ def parse_lml(fname) expect(csv_import.attributes.map(&:name)).to include("map_to", "columns") end + it "preserves a map-valued attribute without dropping keys" do + csv_import = doc.instances.imports.find { |imp| imp.format_type == "csv" } + columns = csv_import.attributes.find { |a| a.name == "columns" } + expect(columns.value.value).to eq( + id: "component_id", type: "component_type", quantity: "count", + ) + end + + # JSON has no symbol type, so the keys come back as strings. + it "reloads a map-valued attribute through Document.from_json" do + reloaded = Lutaml::Lml::Document.from_json(doc.to_json) + + csv_import = reloaded.instances.imports.find { |imp| imp.format_type == "csv" } + columns = csv_import.attributes.find { |a| a.name == "columns" } + expect(columns.value.value).to eq( + "id" => "component_id", "type" => "component_type", "quantity" => "count", + ) + end + + it "serializes a map-valued attribute to YAML without a ruby object tag" do + expect(doc.to_yaml).not_to include("!ruby/object") + end + + it "reloads a map-valued attribute through Document.from_yaml" do + reloaded = Lutaml::Lml::Document.from_yaml(doc.to_yaml) + + csv_import = reloaded.instances.imports.find { |imp| imp.format_type == "csv" } + columns = csv_import.attributes.find { |a| a.name == "columns" } + expect(columns.value.value).to eq( + id: "component_id", type: "component_type", quantity: "count", + ) + end + it "maps exports correctly" do exports = doc.instances.exports expect(exports.size).to eq(2)