From 4ffff61078690eb7e7954fc608d3b670a77ae6d8 Mon Sep 17 00:00:00 2001 From: HassanAkbar Date: Fri, 31 Jul 2026 12:24:41 +0500 Subject: [PATCH 1/4] stop dcc and d-si parsing from dropping data --- CHANGELOG.adoc | 23 +++ lib/dcc/base/formula.rb | 4 +- lib/dcc/base/mathml.rb | 33 ++++ lib/dcc/convert/csv.rb | 6 +- lib/dcc/diff.rb | 18 ++- lib/dcc/si/base.rb | 2 + lib/dcc/si/base/coverage_interval.rb | 36 +++++ lib/dcc/si/base/expanded_unc.rb | 34 +++++ lib/dcc/si/base/real.rb | 8 + lib/dcc/si/v1.rb | 4 +- lib/dcc/si/v1/coverage_interval.rb | 8 + lib/dcc/si/v1/expanded_unc.rb | 17 +-- lib/dcc/si/v2.rb | 3 + lib/dcc/si/v2/coverage_interval.rb | 8 + lib/dcc/si/v2/expanded_unc.rb | 8 + lib/dcc/type/decimal_xml_list.rb | 105 ++++++++++--- lib/dcc/v2.rb | 3 +- lib/dcc/v2/mathml.rb | 8 + lib/dcc/v3.rb | 11 +- lib/dcc/v3/mathml.rb | 8 + .../rules/uncertainty_consistency.rb | 42 ++++- spec/dcc/convert/additional_spec.rb | 11 ++ spec/dcc/type_spec.rb | 119 +++++++++++++++ spec/dcc/validate/schematron_spec.rb | 51 +++++++ spec/parse_fidelity_spec.rb | 143 ++++++++++++++++++ 25 files changed, 660 insertions(+), 53 deletions(-) create mode 100644 lib/dcc/base/mathml.rb create mode 100644 lib/dcc/si/base/coverage_interval.rb create mode 100644 lib/dcc/si/base/expanded_unc.rb create mode 100644 lib/dcc/si/v1/coverage_interval.rb create mode 100644 lib/dcc/si/v2/coverage_interval.rb create mode 100644 lib/dcc/si/v2/expanded_unc.rb create mode 100644 lib/dcc/v2/mathml.rb create mode 100644 lib/dcc/v3/mathml.rb create mode 100644 spec/parse_fidelity_spec.rb diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 0a3300a..e0f8d50 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -12,6 +12,29 @@ and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Version * Performance memoization for hot paths * Plugin system for custom validators and converters +=== Fixed +* `dcc:list` no longer parses as an empty `si:list`. The D-SI re-export was + overwriting the DCC `list` registration, so every list and the quantities + inside it were dropped on parse. +* `si:expandedUnc` and `si:coverageInterval` now survive a parse. Both are + members of the same `si:real` choice group and neither was mapped, so D-SI + v1 uncertainties were discarded. +* `dcc:mathml` now parses. It is a `dcc:xmlType` wrapper around `ml:math`, + not the `ml:math` element itself, so `formula.mathml` was always `nil`. +* Decimal XML lists serialize as one space-separated element again. Each + value was being written as its own element containing a Ruby array + literal, e.g. `[0.72e-1]`. +* `UncertaintyConsistency` accepts a single entry broadcast across every + value, and now checks `coverageFactorXMLList`, `coverageProbabilityXMLList` + and `distributionXMLList` rather than the uncertainty list alone. +* CSV export renders list values as plain decimals instead of `0.72e-1`. + +=== Changed +* *Breaking:* `formula.mathml` returns a `Dcc::V2::Mathml` / `Dcc::V3::Mathml` + wrapper instead of an `Mml::V3::Math`. The MathML tree moves one level + down, so callers now read `formula.mathml.math`. This matches the XSD, + where `dcc:mathml` is a `dcc:xmlType` wrapper. + == [0.1.0] — 2026-07-22 === Added diff --git a/lib/dcc/base/formula.rb b/lib/dcc/base/formula.rb index 5e504ac..87e384e 100644 --- a/lib/dcc/base/formula.rb +++ b/lib/dcc/base/formula.rb @@ -5,7 +5,7 @@ module Dcc module Base # `dcc:formulaType` — formula expression. Contains `latex` string, - # `mathml` (a typed `Mml::V3::Math` model), or legacy `siunitx`. + # `mathml` (a `dcc:xmlType` wrapper around `ml:math`), or legacy `siunitx`. module Formula def self.included(klass) klass.class_eval do @@ -13,7 +13,7 @@ def self.included(klass) attribute :ref_id, :string attribute :ref_type, :string attribute :latex, :string - attribute :mathml, ::Mml::V3::Math + attribute :mathml, :mathml attribute :siunitx, :string xml do diff --git a/lib/dcc/base/mathml.rb b/lib/dcc/base/mathml.rb new file mode 100644 index 0000000..db397d2 --- /dev/null +++ b/lib/dcc/base/mathml.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "mml" + +module Dcc + module Base + # `dcc:mathml` — a `dcc:xmlType` wrapper carrying a single foreign-namespace + # child, which for a formula is `ml:math`. The MathML tree hangs below the + # wrapper rather than replacing it. + module Mathml + # rubocop:disable Metrics/MethodLength -- one lutaml mapping block, + # split the same way every sibling module in Dcc::Base is written. + def self.included(klass) + klass.class_eval do + attribute :id, :string + attribute :ref_id, :string + attribute :ref_type, :string + attribute :math, ::Mml::V3::Math + + xml do + namespace ::Dcc::Namespace::Dcc + element "mathml" + map_attribute "id", to: :id + map_attribute "refId", to: :ref_id + map_attribute "refType", to: :ref_type + map_element "math", to: :math + end + end + end + # rubocop:enable Metrics/MethodLength + end + end +end diff --git a/lib/dcc/convert/csv.rb b/lib/dcc/convert/csv.rb index 82f86bc..1bba536 100644 --- a/lib/dcc/convert/csv.rb +++ b/lib/dcc/convert/csv.rb @@ -101,7 +101,11 @@ def value_of(q) return reals.first.value.to_s if reals.any? lists = Array(q.real_list_xml_list) - return lists.first.value_xml_list.map(&:to_s).join("; ") if lists.any? + # Serialized rather than mapped over: BigDecimal#to_s renders 0.072 + # as "0.72e-1", which is not what the document said. + if lists.any? + return ::Dcc::Type::DecimalXmlList.serialize(lists.first.value_xml_list) + end "" end diff --git a/lib/dcc/diff.rb b/lib/dcc/diff.rb index ded5eed..5664738 100644 --- a/lib/dcc/diff.rb +++ b/lib/dcc/diff.rb @@ -12,6 +12,12 @@ module Diff autoload :Result, "dcc/diff/result" autoload :Change, "dcc/diff/change" + # Leaf types compared directly rather than descended into. Numeric covers + # BigDecimal and Date covers DateTime, so neither needs its own entry. + PRIMITIVE_TYPES = [ + ::String, ::Numeric, ::Symbol, ::Time, ::Date + ].freeze + class << self # @param a [Lutaml::Model::Serializable] # @param b [Lutaml::Model::Serializable] @@ -68,13 +74,13 @@ def collect_differences(a, b, path, changes) end def primitive?(value) - return true if value.nil? - return true if value.is_a?(::String) || value.is_a?(::Numeric) || value.is_a?(::Symbol) - return true if [true, false].include?(value) - return true if value.is_a?(::Time) || value.is_a?(::Date) || value.is_a?(::DateTime) - return true if value.is_a?(::BigDecimal) + return true if value.nil? || [true, false].include?(value) + return false if value.is_a?(::Array) + return true if PRIMITIVE_TYPES.any? { |type| value.is_a?(type) } - false + # Anything we cannot descend into is compared with `==`, which covers + # value objects such as decimal XML lists. + !value.class.respond_to?(:attributes) end def summarize(node) diff --git a/lib/dcc/si/base.rb b/lib/dcc/si/base.rb index b0b209e..56e9e76 100644 --- a/lib/dcc/si/base.rb +++ b/lib/dcc/si/base.rb @@ -17,6 +17,8 @@ module Base autoload :StandardMU, "dcc/si/base/standard_mu" autoload :ExpandedMU, "dcc/si/base/expanded_mu" autoload :CoverageIntervalMU, "dcc/si/base/coverage_interval_mu" + autoload :ExpandedUnc, "dcc/si/base/expanded_unc" + autoload :CoverageInterval, "dcc/si/base/coverage_interval" autoload :ExpandedUncXmlList, "dcc/si/base/expanded_unc_xml_list" autoload :MeasurementUncertaintyUnivariate, "dcc/si/base/measurement_uncertainty_univariate" diff --git a/lib/dcc/si/base/coverage_interval.rb b/lib/dcc/si/base/coverage_interval.rb new file mode 100644 index 0000000..3f09a5d --- /dev/null +++ b/lib/dcc/si/base/coverage_interval.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Dcc + module Si + module Base + # `si:coverageIntervalType` — coverage interval stated directly on a real + # quantity, the sibling of `expandedUnc` in the same xs:choice. The v1 + # form; v2 keeps it as a deprecated alternative. + module CoverageInterval + # rubocop:disable Metrics/MethodLength -- one lutaml mapping + # block, written the same way as every sibling D-SI module. + def self.included(klass) + klass.class_eval do + attribute :standard_unc, :string + attribute :interval_min, :string + attribute :interval_max, :string + attribute :coverage_probability, :string + attribute :distribution, :string + + xml do + namespace ::Dcc::Namespace::Si + element "coverageInterval" + ordered + map_element "standardUnc", to: :standard_unc + map_element "intervalMin", to: :interval_min + map_element "intervalMax", to: :interval_max + map_element "coverageProbability", to: :coverage_probability + map_element "distribution", to: :distribution + end + end + end + # rubocop:enable Metrics/MethodLength + end + end + end +end diff --git a/lib/dcc/si/base/expanded_unc.rb b/lib/dcc/si/base/expanded_unc.rb new file mode 100644 index 0000000..3d045ae --- /dev/null +++ b/lib/dcc/si/base/expanded_unc.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Dcc + module Si + module Base + # `si:expandedUncType` — expanded uncertainty stated directly on a real + # quantity. The v1 form; v2 keeps it as a deprecated alternative to + # `measurementUncertaintyUnivariate`. + module ExpandedUnc + # rubocop:disable Metrics/MethodLength -- one lutaml mapping + # block, written the same way as every sibling D-SI module. + def self.included(klass) + klass.class_eval do + attribute :uncertainty, :string + attribute :coverage_factor, :string + attribute :coverage_probability, :string + attribute :distribution, :string + + xml do + namespace ::Dcc::Namespace::Si + element "expandedUnc" + ordered + map_element "uncertainty", to: :uncertainty + map_element "coverageFactor", to: :coverage_factor + map_element "coverageProbability", to: :coverage_probability + map_element "distribution", to: :distribution + end + end + end + # rubocop:enable Metrics/MethodLength + end + end + end +end diff --git a/lib/dcc/si/base/real.rb b/lib/dcc/si/base/real.rb index 0fa8f35..b752005 100644 --- a/lib/dcc/si/base/real.rb +++ b/lib/dcc/si/base/real.rb @@ -17,6 +17,8 @@ module Base # module Real def self.included(klass) + # rubocop:disable Metrics/BlockLength -- one lutaml mapping block, + # as in the sibling D-SI modules already listed in the todo file. klass.class_eval do attribute :id, :string attribute :ref_type, :string @@ -27,6 +29,9 @@ def self.included(klass) attribute :date_time, :date_time attribute :measurement_uncertainty_univariate, :measurementUncertaintyUnivariate + # Deprecated in v2, but the only uncertainty form v1 documents use. + attribute :expanded_unc, :expandedUnc + attribute :coverage_interval, :coverageInterval xml do namespace ::Dcc::Namespace::Si @@ -41,8 +46,11 @@ def self.included(klass) map_element "dateTime", to: :date_time map_element "measurementUncertaintyUnivariate", to: :measurement_uncertainty_univariate + map_element "expandedUnc", to: :expanded_unc + map_element "coverageInterval", to: :coverage_interval end end + # rubocop:enable Metrics/BlockLength end end end diff --git a/lib/dcc/si/v1.rb b/lib/dcc/si/v1.rb index a9c7486..38a9f98 100644 --- a/lib/dcc/si/v1.rb +++ b/lib/dcc/si/v1.rb @@ -22,6 +22,7 @@ module V1 autoload :ExpandedMU, "dcc/si/v1/expanded_mu" autoload :CoverageIntervalMU, "dcc/si/v1/coverage_interval_mu" autoload :ExpandedUnc, "dcc/si/v1/expanded_unc" + autoload :CoverageInterval, "dcc/si/v1/coverage_interval" autoload :ExpandedUncXmlList, "dcc/si/v1/expanded_unc_xml_list" autoload :MeasurementUncertaintyUnivariate, "dcc/si/v1/measurement_uncertainty_univariate" @@ -34,7 +35,8 @@ module V1 Real Complex Constant RealListXmlList ComplexListXmlList Hybrid SiList StandardMU ExpandedMU CoverageIntervalMU - ExpandedUnc ExpandedUncXmlList MeasurementUncertaintyUnivariate + ExpandedUnc CoverageInterval + ExpandedUncXmlList MeasurementUncertaintyUnivariate ].freeze def self.load_all! diff --git a/lib/dcc/si/v1/coverage_interval.rb b/lib/dcc/si/v1/coverage_interval.rb new file mode 100644 index 0000000..f730a95 --- /dev/null +++ b/lib/dcc/si/v1/coverage_interval.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Dcc::Si::V1 + class CoverageInterval < CommonElements + include ::Dcc::Si::Base::CoverageInterval + end + Configuration.register_model(CoverageInterval, id: :coverageInterval) +end diff --git a/lib/dcc/si/v1/expanded_unc.rb b/lib/dcc/si/v1/expanded_unc.rb index 3cfbf84..9cb86c5 100644 --- a/lib/dcc/si/v1/expanded_unc.rb +++ b/lib/dcc/si/v1/expanded_unc.rb @@ -1,23 +1,8 @@ # frozen_string_literal: true module Dcc::Si::V1 - # Deprecated v1 expandedUnc type — superseded by expandedMU in v2. - # Kept so old fixtures parse without losing data. class ExpandedUnc < CommonElements - attribute :uncertainty, :string - attribute :coverage_factor, :string - attribute :coverage_probability, :string - attribute :distribution, :string - - xml do - namespace ::Dcc::Namespace::Si - element "expandedUnc" - ordered - map_element "uncertainty", to: :uncertainty - map_element "coverageFactor", to: :coverage_factor - map_element "coverageProbability", to: :coverage_probability - map_element "distribution", to: :distribution - end + include ::Dcc::Si::Base::ExpandedUnc end Configuration.register_model(ExpandedUnc, id: :expandedUnc) end diff --git a/lib/dcc/si/v2.rb b/lib/dcc/si/v2.rb index 8018c4b..5ef040b 100644 --- a/lib/dcc/si/v2.rb +++ b/lib/dcc/si/v2.rb @@ -24,6 +24,8 @@ module V2 autoload :CoverageIntervalMU, "dcc/si/v2/coverage_interval_mu" autoload :CoverageIntervalMUXMLList, "dcc/si/v2/coverage_interval_mu_xml_list" + autoload :ExpandedUnc, "dcc/si/v2/expanded_unc" + autoload :CoverageInterval, "dcc/si/v2/coverage_interval" autoload :ExpandedUncXmlList, "dcc/si/v2/expanded_unc_xml_list" autoload :MeasurementUncertaintyUnivariate, "dcc/si/v2/measurement_uncertainty_univariate" @@ -38,6 +40,7 @@ module V2 StandardMU StandardMUXMLList ExpandedMU ExpandedMUXMLList CoverageIntervalMU CoverageIntervalMUXMLList + ExpandedUnc CoverageInterval ExpandedUncXmlList MeasurementUncertaintyUnivariate ].freeze diff --git a/lib/dcc/si/v2/coverage_interval.rb b/lib/dcc/si/v2/coverage_interval.rb new file mode 100644 index 0000000..1bb7635 --- /dev/null +++ b/lib/dcc/si/v2/coverage_interval.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Dcc::Si::V2 + class CoverageInterval < CommonElements + include ::Dcc::Si::Base::CoverageInterval + end + Configuration.register_model(CoverageInterval, id: :coverageInterval) +end diff --git a/lib/dcc/si/v2/expanded_unc.rb b/lib/dcc/si/v2/expanded_unc.rb new file mode 100644 index 0000000..3dfa876 --- /dev/null +++ b/lib/dcc/si/v2/expanded_unc.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Dcc::Si::V2 + class ExpandedUnc < CommonElements + include ::Dcc::Si::Base::ExpandedUnc + end + Configuration.register_model(ExpandedUnc, id: :expandedUnc) +end diff --git a/lib/dcc/type/decimal_xml_list.rb b/lib/dcc/type/decimal_xml_list.rb index 8acf34c..063301a 100644 --- a/lib/dcc/type/decimal_xml_list.rb +++ b/lib/dcc/type/decimal_xml_list.rb @@ -1,19 +1,86 @@ # frozen_string_literal: true require "bigdecimal" +require "forwardable" require "lutaml/model" module Dcc module Type # Space-separated list of decimals, as used by D-SI `*XMLList` elements # (e.g. `306 373 448`). Parses to/from - # an Array of BigDecimal values. + # a `Values` list of BigDecimal. + # + # `cast` deliberately does not return a plain Array: lutaml-model treats an + # Array-valued attribute as a collection and would emit one element per + # decimal instead of a single space-separated list. class DecimalXmlList < Lutaml::Model::Type::Value + # An ordered list of BigDecimals that renders as space-separated text and + # compares equal to the equivalent Array. + class Values + include Enumerable + extend Forwardable + + def_delegators :@decimals, :size, :length, :empty?, :last, :[] + + def initialize(decimals) + @decimals = decimals + end + + def each(&) + return to_enum(:each) unless block_given? + + @decimals.each(&) + self + end + + # Implicit conversion, so `Array#==` delegates back here instead of + # returning false and making equality asymmetric. + def to_ary + @decimals.dup + end + + # Joined over the serialized decimals, not the raw BigDecimals, so it + # cannot disagree with `to_s` by emitting "0.72e-1" for "0.072". + def join(separator = " ") + map { |decimal| DecimalXmlList.serialize_one(decimal) }.join(separator) + end + + def to_s + DecimalXmlList.serialize(self) + end + + # Dumped as its wire form, so YAML matches JSON rather than leaking a + # `!ruby/object:` tag that `safe_load` refuses to read back. + def encode_with(coder) + coder.represent_scalar(nil, to_s) + end + + def ==(other) + case other + when Values then to_a == other.to_a + when ::Array then to_a == other + else false + end + end + + # Stricter than `==`, which accepts a plain Array. Hash lookup pairs + # `eql?` with `hash`, and an Array keyed alongside us would hash the + # same while comparing equal in only one direction. + def eql?(other) + other.is_a?(Values) && to_a == other.to_a + end + + def hash + to_a.hash + end + end + def self.cast(value) - return [] if value.nil? - return value.map { |v| cast_one(v) } if value.is_a?(::Array) + return value if value.is_a?(Values) + return Values.new([]) if value.nil? - value.to_s.split(/\s+/).reject(&:empty?).map { |s| cast_one(s) } + tokens = value.is_a?(::Array) ? value : value.to_s.split + Values.new(tokens.map { |token| cast_one(token) }) end def self.serialize(value) @@ -22,6 +89,22 @@ def self.serialize(value) Array(value).map { |v| serialize_one(v) }.join(" ") end + # One decimal in the notation D-SI writes, e.g. 0.072 rather than + # BigDecimal's own "0.72e-1". + def self.serialize_one(value) + case value + when ::BigDecimal then if value.to_i.to_s == value.to_s("F") + value.to_i.to_s + else + value.to_s("F").sub( + /\.0\z/, "" + ) + end + when ::Float then format("%.15g", value) + else value.to_s + end + end + class << self private @@ -33,20 +116,6 @@ def cast_one(token) raise Lutaml::Model::Type::InvalidValueError.new(token, "invalid decimal in XML list: #{e.message}") end - - def serialize_one(value) - case value - when ::BigDecimal then if value.to_i.to_s == value.to_s("F") - value.to_i.to_s - else - value.to_s("F").sub( - /\.0\z/, "" - ) - end - when ::Float then format("%.15g", value) - else value.to_s - end - end end end end diff --git a/lib/dcc/v2.rb b/lib/dcc/v2.rb index 2ad5cb6..786a6db 100644 --- a/lib/dcc/v2.rb +++ b/lib/dcc/v2.rb @@ -14,6 +14,7 @@ module V2 autoload :Text, "dcc/v2/text" autoload :ByteData, "dcc/v2/byte_data" autoload :Formula, "dcc/v2/formula" + autoload :Mathml, "dcc/v2/mathml" autoload :RichContent, "dcc/v2/rich_content" autoload :XmlBlob, "dcc/v2/xml_blob" autoload :Comment, "dcc/v2/comment" @@ -61,7 +62,7 @@ module V2 extend ::Dcc::VersionedParser ELEMENT_CLASSES = %i[ - StringWithLang Text ByteData Formula RichContent XmlBlob Comment + StringWithLang Text ByteData Mathml Formula RichContent XmlBlob Comment EquipmentClass Location Contact ContactNotStrict HashType Identification Identifications Software SoftwareList RefTypeDefinition RefTypeDefinitionList diff --git a/lib/dcc/v2/mathml.rb b/lib/dcc/v2/mathml.rb new file mode 100644 index 0000000..9f03a72 --- /dev/null +++ b/lib/dcc/v2/mathml.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Dcc::V2 + class Mathml < CommonElements + include ::Dcc::Base::Mathml + end + Configuration.register_model(Mathml, id: :mathml) +end diff --git a/lib/dcc/v3.rb b/lib/dcc/v3.rb index d19f1eb..640e258 100644 --- a/lib/dcc/v3.rb +++ b/lib/dcc/v3.rb @@ -14,6 +14,7 @@ module V3 autoload :Text, "dcc/v3/text" autoload :ByteData, "dcc/v3/byte_data" autoload :Formula, "dcc/v3/formula" + autoload :Mathml, "dcc/v3/mathml" autoload :RichContent, "dcc/v3/rich_content" autoload :XmlBlob, "dcc/v3/xml_blob" autoload :Comment, "dcc/v3/comment" @@ -64,7 +65,7 @@ module V3 # `Configuration.populate_context!` can register them. Iterating # `constants` triggers each autoload lazily. ELEMENT_CLASSES = %i[ - StringWithLang Text ByteData Formula RichContent XmlBlob Comment + StringWithLang Text ByteData Mathml Formula RichContent XmlBlob Comment EquipmentClass Location Contact ContactNotStrict HashType Identification Identifications Software SoftwareList RefTypeDefinition RefTypeDefinitionList @@ -91,9 +92,17 @@ def self.load_all! # Re-export D-SI v2 type registrations into the DCC v3 context. Uses # only the public `register_model` API (no instance_variable access). + # + # Ids DCC already owns are skipped: `register_model` overwrites on + # duplicate, and both DCC and D-SI define a `list` element, so without this + # the D-SI one would displace `Dcc::V3::List` and every `dcc:list` would + # parse as an empty `si:list`. def self.register_dsi_types! ::Dcc::Si::V2.load_all! + own_ids = Configuration.registered_model_ids ::Dcc::Si::V2::Configuration.registered_model_ids.each do |id| + next if own_ids.include?(id) + klass = ::Dcc::Si::V2::Configuration.registered_model_class(id) Configuration.register_model(klass, id: id) end diff --git a/lib/dcc/v3/mathml.rb b/lib/dcc/v3/mathml.rb new file mode 100644 index 0000000..6b45a6f --- /dev/null +++ b/lib/dcc/v3/mathml.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Dcc::V3 + class Mathml < CommonElements + include ::Dcc::Base::Mathml + end + Configuration.register_model(Mathml, id: :mathml) +end diff --git a/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb b/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb index 8c4168b..b9c26c6 100644 --- a/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb +++ b/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb @@ -5,9 +5,16 @@ module Validate module Schematron module Rules # Validates that for every `si:realListXMLList` paired with an - # `si:expandedUncXMLList`, the count of values matches the count - # of uncertainty entries. + # `si:expandedUncXMLList`, each uncertainty list holds either one + # entry per value or a single entry broadcast across all of them. class UncertaintyConsistency < Base + UNCERTAINTY_LISTS = { + uncertainty_xml_list: "uncertaintyXMLList", + coverage_factor_xml_list: "coverageFactorXMLList", + coverage_probability_xml_list: "coverageProbabilityXMLList", + distribution_xml_list: "distributionXMLList", + }.freeze + def check_on(dcc) issues = [] return issues unless dcc.is_a?(::Lutaml::Model::Serializable) @@ -36,16 +43,37 @@ def check_node(list_node, issues) unc = list_node.expanded_unc_xml_list return unless unc && values - unc_count = Array(unc.uncertainty_xml_list).size - value_count = Array(values).size - return if unc_count == value_count || unc_count.zero? + value_count = entry_count(values) + UNCERTAINTY_LISTS.each do |attribute, xml_name| + count = entry_count(unc.public_send(attribute)) + next if consistent?(count, value_count) + + issues << count_mismatch(xml_name, count, value_count) + end + end - issues << issue( + def count_mismatch(xml_name, count, value_count) + issue( severity: :error, - message: "valueXMLList count (#{value_count}) does not match expandedUncertaintyXMLList count (#{unc_count})", + message: "valueXMLList count (#{value_count}) does not " \ + "match #{xml_name} count (#{count})", ) end + # Every list here is an xs:list, so its entries are whitespace + # separated whether the attribute holds a string or a decimal list. + def entry_count(list) + list.to_s.split.size + end + + # A list either carries one entry per value or a single entry that + # broadcasts across all of them, the way unitXMLList does. Siblings + # decide independently: PTB ships documents pairing nine + # uncertainties with one shared coverage factor. + def consistent?(count, value_count) + count.zero? || count == 1 || count == value_count + end + def descend(node, issues, visited) node.class.attributes.each_key do |attr_name| value = node.public_send(attr_name) diff --git a/spec/dcc/convert/additional_spec.rb b/spec/dcc/convert/additional_spec.rb index 3b67896..58f2cbf 100644 --- a/spec/dcc/convert/additional_spec.rb +++ b/spec/dcc/convert/additional_spec.rb @@ -32,6 +32,17 @@ expect(lines.first).to include("result_name") expect(lines.first).to include("quantity_name") end + + # BigDecimal#to_s renders 0.072 as "0.72e-1", which is not what the + # document said. + it "renders list values as plain decimals" do + expect(described_class.call(dcc).payload).not_to match(/\d+e-?\d/) + end + + it "renders list values as they appear in the document" do + expect(described_class.call(dcc).payload) + .to include("0.072 0.089 0.107 -0.009 -0.084") + end end RSpec.describe Dcc::Convert::Html do diff --git a/spec/dcc/type_spec.rb b/spec/dcc/type_spec.rb index 6c8c14f..e8dd9a3 100644 --- a/spec/dcc/type_spec.rb +++ b/spec/dcc/type_spec.rb @@ -129,6 +129,125 @@ expect(described_class.serialize(nil)).to eq("") end end + + # `to_ary` keeps equality symmetric: without it `Array#==` short-circuits to + # false and the answer depends on which side the list is written on. + describe "equality with a plain Array" do + let(:values) { described_class.cast("0.1 0.2") } + let(:array) { [BigDecimal("0.1"), BigDecimal("0.2")] } + + it "compares equal with the list on the left" do + expect(values).to eq(array) + end + + it "compares equal with the array on the left" do + expect(array).to eq(values) + end + + it "is not equal to a different list" do + expect(values).not_to eq([BigDecimal("0.1"), BigDecimal("0.3")]) + end + + it "is not equal to an unrelated object" do + expect(values).not_to eq("0.1 0.2") + end + + # `hash` and `eql?` travel with `==`, or a list stops deduplicating. + it "deduplicates equal lists" do + expect([values, described_class.cast("0.1 0.2")].uniq.size).to eq(1) + end + + it "hashes equal lists alike" do + expect(values.hash).to eq(described_class.cast("0.1 0.2").hash) + end + + # `eql?` is what Hash pairs with `hash`, so it stays stricter than `==`. + # An equal Array hashes the same, and admitting it here would make lookup + # depend on which of the two was used as the key. + it "is eql? to an equal list" do + expect(values).to eql(described_class.cast("0.1 0.2")) + end + + it "is not eql? to an equal Array" do + expect(values).not_to eql(array) + end + + it "does not answer to an Array key" do + expect({ values => 1 }[array]).to be_nil + end + end + + describe "list reading" do + let(:values) { described_class.cast("0.1 0.2 0.3") } + + it "reports its length" do + expect(values.length).to eq(3) + end + + it "knows when it is empty" do + expect(described_class.cast("")).to be_empty + end + + it "returns the last entry" do + expect(values.last).to eq(BigDecimal("0.3")) + end + + # `join` must agree with `to_s`; delegating it to the BigDecimals would + # re-emit the scientific notation the wire form exists to avoid. + it "joins the serialized decimals" do + expect(values.join(" ")).to eq("0.1 0.2 0.3") + end + + it "joins on a given separator" do + expect(values.join("; ")).to eq("0.1; 0.2; 0.3") + end + + it "enumerates without a block" do + expect(values.each).to be_a(Enumerator) + end + end + + # YAML must carry the wire form, not a Ruby object tag that `safe_load` + # refuses to read back. `dcc convert yaml` is a shipped command. + describe "YAML round-trip" do + let(:yaml) { described_class.cast("0.072 0.089").to_yaml } + + it "dumps the space-separated wire form" do + expect(YAML.safe_load(yaml)).to eq("0.072 0.089") + end + + it "casts back to an equal list" do + expect(described_class.cast(YAML.safe_load(yaml))) + .to eq(described_class.cast("0.072 0.089")) + end + end + + # A decimal XML list is one element holding space-separated values. If the + # cast result looks like a collection to lutaml-model, the list is split into + # one element per value, each rendering a Ruby array literal. + describe "round-trip through a model attribute" do + before { Dcc::Si::V2.load_all! } + + let(:xml) do + <<~XML + + 0.072 0.089 0.107 -0.009 -0.084 + \\kelvin + + XML + end + + let(:serialized) { Nokogiri::XML(Dcc::Si::V2::RealListXmlList.from_xml(xml).to_xml) } + let(:emitted) { serialized.xpath("//*[local-name()='valueXMLList']") } + + it "emits exactly one valueXMLList element" do + expect(emitted.size).to eq(1) + end + + it "emits the values as space-separated decimals" do + expect(emitted.first.text).to eq("0.072 0.089 0.107 -0.009 -0.084") + end + end end RSpec.describe Dcc::Type::SchemaVersion do diff --git a/spec/dcc/validate/schematron_spec.rb b/spec/dcc/validate/schematron_spec.rb index f60782a..8477969 100644 --- a/spec/dcc/validate/schematron_spec.rb +++ b/spec/dcc/validate/schematron_spec.rb @@ -77,3 +77,54 @@ expect(issues).to be_an(Array) end end + +RSpec.describe Dcc::Validate::Schematron::Rules::UncertaintyConsistency do + before { Dcc::Si::V2.load_all! } + + let(:rule) { described_class.new } + + def real_list(uncertainties, factors: "2") + Dcc::Si::V2::RealListXmlList.from_xml(<<~XML) + + 1 2 3 4 5 + \\kelvin + + #{uncertainties} + #{factors} + 0.95 + + + XML + end + + # D-SI broadcasts a single uncertainty across every value, the same way + # unitXMLList broadcasts a single unit. PTB's own reference documents use + # this far more often than a one-to-one list. + it "accepts a single uncertainty broadcast over many values" do + expect(rule.check_on(real_list("0.061"))).to be_empty + end + + it "accepts an uncertainty per value" do + list = real_list("0.1 0.2 0.3 0.4 0.5", factors: "2 2 2 2 2") + expect(rule.check_on(list)).to be_empty + end + + # PTB ships a document pairing nine uncertainties with one shared coverage + # factor, so each list decides broadcast independently of its siblings. + it "accepts a per-value list beside a broadcast sibling" do + list = real_list("0.1 0.2 0.3 0.4 0.5", factors: "2") + expect(rule.check_on(list)).to be_empty + end + + it "reports a sibling list that neither matches nor broadcasts" do + issues = rule.check_on(real_list("0.061", factors: "2 2 2")) + expect(issues.map(&:message)) + .to include(a_string_matching(/coverageFactorXMLList count \(3\)/)) + end + + it "reports an uncertainty count that neither matches nor broadcasts" do + issues = rule.check_on(real_list("0.1 0.2")) + expect(issues.map(&:message)) + .to include(a_string_matching(/uncertaintyXMLList count \(2\)/)) + end +end diff --git a/spec/parse_fidelity_spec.rb b/spec/parse_fidelity_spec.rb new file mode 100644 index 0000000..64220a1 --- /dev/null +++ b/spec/parse_fidelity_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "spec_helper" +require "nokogiri" + +# Parsing must not silently drop elements it claims to support. Each example +# counts an element in the source and in the re-serialized output; a mismatch +# means data was lost between parse and `to_xml`. +RSpec.describe Dcc, ".parse" do + # A DCC v2 document reaches D-SI types through the :dsi_v1 and :dsi_v2 + # fallbacks, and those contexts exist only once their version module has been + # loaded. Load both up front so every example stands on its own rather than + # relying on an earlier one having populated them. + before do + Dcc::Si::V1.load_all! + Dcc::Si::V2.load_all! + end + + def element_count(xml, name) + Nokogiri::XML(xml).xpath("//*[local-name()='#{name}']").size + end + + def round_trip(relative) + xml = File.read(fixtures_path(*relative.split("/"))) + Dcc.parser_for(Dcc.detect_version(xml)).load_all! + [xml, Dcc.parse(xml).to_xml] + end + + # Reported as a pair so one expectation covers both sides: a count that is + # right on the way out but wrong on the way in proves nothing. + def counts(relative, name) + source, serialized = round_trip(relative) + { in: element_count(source, name), out: element_count(serialized, name) } + end + + describe "dcc:list" do + { + "dcclib/valid.xml" => 1, + "dcclib/valid_formula.xml" => 2, + "dcc_excel/example.xml" => 8, + }.each do |fixture, expected| + it "round-trips every list in #{fixture}" do + expect(counts(fixture, "list")).to eq(in: expected, out: expected) + end + end + end + + describe "dcc:quantity" do + it "round-trips every quantity in dcc_excel/example.xml" do + expect(counts("dcc_excel/example.xml", "quantity")).to eq(in: 20, out: 20) + end + end + + # dcc:mathml is a dcc:xmlType wrapper holding a foreign-namespace child, so + # the MathML tree hangs one level below it rather than replacing it. + describe "dcc:mathml" do + let(:fixture) { "dcclib/valid_formula.xml" } + + it "round-trips the mathml wrapper" do + expect(counts(fixture, "mathml")).to eq(in: 1, out: 1) + end + + it "round-trips the ml:math child" do + expect(counts(fixture, "math")).to eq(in: 1, out: 1) + end + + it "keeps the MathML body" do + expect(counts(fixture, "apply")).to eq(in: 5, out: 5) + end + end + + # D-SI v1 puts expandedUnc and coverageInterval directly under si:real, and + # v2 still permits both as deprecated members of the same xs:choice. + describe "si:expandedUnc" do + { + "dcc_examples/example.xml" => 10, + "dcc_examples/siliziumkugel.xml" => 8, + }.each do |fixture, expected| + it "round-trips every expandedUnc in #{fixture}" do + expect(counts(fixture, "expandedUnc")) + .to eq(in: expected, out: expected) + end + end + + it "keeps the uncertainty value" do + _, serialized = round_trip("dcc_examples/example.xml") + expect(element_count(serialized, "coverageFactor")).to eq(10) + end + end + + describe "si:coverageInterval" do + let(:real_xml) do + <<~XML + + 20.10 + \\metre + + 0.02 + 20.05 + 20.15 + 0.95 + + + XML + end + + %i[V1 V2].each do |version| + it "round-trips through Dcc::Si::#{version}::Real" do + Dcc::Si.const_get(version).load_all! + serialized = Dcc::Si.const_get(version)::Real.from_xml(real_xml).to_xml + expect(element_count(serialized, "intervalMin")).to eq(1) + end + end + end + + # A decimal XML list is one element holding space-separated values, so each + # emitted realListXMLList must carry exactly one valueXMLList. Counting the + # two against each other tests that directly, without depending on how many + # lists survive parsing — quantities nested under a statement or metaData are + # still dropped, because dcc:data is unmapped on Dcc::Base::Statement. + describe "si:valueXMLList" do + let(:serialized) { round_trip("dcclib/valid.xml").last } + let(:texts) do + Nokogiri::XML(serialized).xpath("//*[local-name()='valueXMLList']").map(&:text) + end + + it "emits one valueXMLList per realListXMLList" do + expect(texts.size).to eq(element_count(serialized, "realListXMLList")) + end + + it "emits at least one list" do + expect(texts).not_to be_empty + end + + it "emits space-separated decimals rather than Ruby array literals" do + expect(texts).to all(match(/\A-?[\d.]+( -?[\d.]+)*\z/)) + end + + it "preserves the original values verbatim" do + expect(texts).to include("306.248 373.121 448.253 523.319 593.154") + end + end +end From f0243da38122f5075f22014f281408f3ceb212ec Mon Sep 17 00:00:00 2001 From: HassanAkbar Date: Fri, 31 Jul 2026 15:04:32 +0500 Subject: [PATCH 2/4] require date explicitly in diff --- lib/dcc/diff.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/dcc/diff.rb b/lib/dcc/diff.rb index 5664738..9831fc1 100644 --- a/lib/dcc/diff.rb +++ b/lib/dcc/diff.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "bigdecimal" +require "date" require "lutaml/model" module Dcc From a3e4ed9de918e2d47721cc671b4f6c2feb44c909 Mon Sep 17 00:00:00 2001 From: HassanAkbar Date: Fri, 31 Jul 2026 15:17:06 +0500 Subject: [PATCH 3/4] assert recovered quantity and value list counts --- spec/parse_fidelity_spec.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/parse_fidelity_spec.rb b/spec/parse_fidelity_spec.rb index 64220a1..dc29700 100644 --- a/spec/parse_fidelity_spec.rb +++ b/spec/parse_fidelity_spec.rb @@ -49,6 +49,18 @@ def counts(relative, name) it "round-trips every quantity in dcc_excel/example.xml" do expect(counts("dcc_excel/example.xml", "quantity")).to eq(in: 20, out: 20) end + + # Counted alongside the list itself: a dcc:list that parses back to an + # empty wrapper still round-trips 1 to 1, so only the children catch it. + # + # 8 of 13, not 13 of 13. The five still lost sit under + # measurementMetaData/metaData/data, and dcc:data is unmapped on + # Dcc::Base::Statement, which MeasurementMetaData includes. Pinning the + # real number locks in the fix and the known gap together: map dcc:data + # and this example fails, telling you to raise the number. + it "recovers the quantities dcc:list was hiding in dcclib/valid.xml" do + expect(counts("dcclib/valid.xml", "quantity")).to eq(in: 13, out: 8) + end end # dcc:mathml is a dcc:xmlType wrapper holding a foreign-namespace child, so @@ -128,6 +140,13 @@ def counts(relative, name) expect(texts.size).to eq(element_count(serialized, "realListXMLList")) end + # 5 of 9, capped by that same unmapped dcc:data rather than by anything + # this change controls. Asserted absolutely as well as as a ratio, so a + # regression that dropped every list could not satisfy 0 == 0. + it "recovers the value lists dcc:list was hiding" do + expect(counts("dcclib/valid.xml", "valueXMLList")).to eq(in: 9, out: 5) + end + it "emits at least one list" do expect(texts).not_to be_empty end From 74fa5a240b656fac1cd7fb289b198d23387334fc Mon Sep 17 00:00:00 2001 From: HassanAkbar Date: Wed, 12 Aug 2026 16:32:22 +0500 Subject: [PATCH 4/4] Round-trip NaN and reject infinities in decimal lists --- CHANGELOG.adoc | 9 ++- lib/dcc/diff.rb | 23 ++++++- lib/dcc/quantity_format/formatter.rb | 21 +++--- lib/dcc/type/decimal_xml_list.rb | 33 ++++++--- spec/dcc/diff_spec.rb | 80 ++++++++++++++++++++++ spec/dcc/quantity_format/formatter_spec.rb | 34 +++++++++ spec/dcc/type_spec.rb | 59 ++++++++++++++++ 7 files changed, 237 insertions(+), 22 deletions(-) create mode 100644 spec/dcc/diff_spec.rb diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index e0f8d50..5bb5df7 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -21,9 +21,16 @@ and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Version v1 uncertainties were discarded. * `dcc:mathml` now parses. It is a `dcc:xmlType` wrapper around `ml:math`, not the `ml:math` element itself, so `formula.mathml` was always `nil`. + Known gap: `ml:ci/@xref` does not survive a round-trip. `Mml::V3::Ci` + has no `xref` attribute, so the value is lost on parse and never reaches + the output. Those xrefs bind the formula variables to their + `dcc:quantity/@id`. A round-tripped formula keeps its MathML and loses + those bindings, with no warning. The fix belongs in the `mml` gem, not + here. * Decimal XML lists serialize as one space-separated element again. Each value was being written as its own element containing a Ruby array - literal, e.g. `[0.72e-1]`. + literal, e.g. `[0.72e-1]`. `NaN` + round-trips too, which every D-SI decimal type admits. * `UncertaintyConsistency` accepts a single entry broadcast across every value, and now checks `coverageFactorXMLList`, `coverageProbabilityXMLList` and `distributionXMLList` rather than the uncertainty list alone. diff --git a/lib/dcc/diff.rb b/lib/dcc/diff.rb index 9831fc1..985c782 100644 --- a/lib/dcc/diff.rb +++ b/lib/dcc/diff.rb @@ -49,7 +49,7 @@ def collect_differences(a, b, path, changes) # Both primitives - compare values if primitive?(a) || primitive?(b) - if a != b + unless equivalent?(a, b) changes << Change.new(path: path, kind: :change, before: a, after: b) end @@ -74,6 +74,27 @@ def collect_differences(a, b, path, changes) end end + # "Did this document change?" is not "are these numbers equal?". + # `BigDecimal("NaN") == BigDecimal("NaN")` is false by IEEE, and + # `Values#==` honours that, so two documents carrying the same + # not-measured entry would otherwise report a change to itself. + def equivalent?(a, b) + values = ::Dcc::Type::DecimalXmlList::Values + return a == b unless a.is_a?(values) && b.is_a?(values) + + a.size == b.size && a.to_a.zip(b.to_a).all? { |x, y| same_decimal?(x, y) } + end + + # `Values.new` takes whatever it is handed, so a list assigned in Ruby + # rather than parsed can hold anything. Only BigDecimals are asked + # whether they are NaN. + def same_decimal?(left, right) + return true if left == right + + left.is_a?(::BigDecimal) && right.is_a?(::BigDecimal) && + left.nan? && right.nan? + end + def primitive?(value) return true if value.nil? || [true, false].include?(value) return false if value.is_a?(::Array) diff --git a/lib/dcc/quantity_format/formatter.rb b/lib/dcc/quantity_format/formatter.rb index e23d848..344ccb7 100644 --- a/lib/dcc/quantity_format/formatter.rb +++ b/lib/dcc/quantity_format/formatter.rb @@ -45,28 +45,24 @@ def to_latex private def format_value - # Two decimals by default; tightened to uncertainty precision when known. - return @value.round(2).to_s("F") if uncertainty.nil? + # Two decimals by default, tightened to the uncertainty's precision + # when it has any. Neither a nil nor a NaN uncertainty has one. + return @value.round(2).to_s("F") if uncertainty.nil? || uncertainty.nan? - # Decimal places = 1 - floor(log10(uncertainty)) - decimal_places = [0, - 1 - Integer(Math.log10(uncertainty.to_f).floor)].max @value.round(decimal_places).to_s("F") end def format_uncertainty return "" if uncertainty.nil? + return "NaN" if uncertainty.nan? - decimal_places = [0, - 1 - Integer(Math.log10(uncertainty.to_f).floor)].max uncertainty.round(decimal_places).to_s("F") end def align_value_and_uncertainty return [@value.to_s("F"), uncertainty.to_s("F")] if uncertainty.nil? + return [format_value, "NaN"] if uncertainty.nan? - decimal_places = [0, - 1 - Integer(Math.log10(uncertainty.to_f).floor)].max v_str = @value.round(decimal_places).to_s("F") u_str = uncertainty.round(decimal_places).to_s("F") # Short form: digits of uncertainty after the last common digit @@ -74,6 +70,13 @@ def align_value_and_uncertainty [v_str, last_digits] end + # Decimal places = 1 - floor(log10(uncertainty)). D-SI writes `NaN` for a + # not-measured entry (SI_Format.xsd:1195) and `Math.log10` cannot answer + # for it, so every caller excludes nil and NaN first. + def decimal_places + [0, 1 - Integer(Math.log10(uncertainty.to_f).floor)].max + end + def unit_suffix return "" if unit.nil? || unit.empty? diff --git a/lib/dcc/type/decimal_xml_list.rb b/lib/dcc/type/decimal_xml_list.rb index 063301a..e3cc2f9 100644 --- a/lib/dcc/type/decimal_xml_list.rb +++ b/lib/dcc/type/decimal_xml_list.rb @@ -90,16 +90,15 @@ def self.serialize(value) end # One decimal in the notation D-SI writes, e.g. 0.072 rather than - # BigDecimal's own "0.72e-1". + # BigDecimal's own "0.72e-1". `to_s("F")` renders every BigDecimal, + # finite or not, and `sub` only trims a trailing ".0". That is what + # lets `NaN` through, which D-SI admits in every decimal type + # (SI_Format.xsd:1195). def self.serialize_one(value) + reject_infinite!(value) + case value - when ::BigDecimal then if value.to_i.to_s == value.to_s("F") - value.to_i.to_s - else - value.to_s("F").sub( - /\.0\z/, "" - ) - end + when ::BigDecimal then value.to_s("F").sub(/\.0\z/, "") when ::Float then format("%.15g", value) else value.to_s end @@ -109,13 +108,25 @@ class << self private def cast_one(token) - return token if token.is_a?(::BigDecimal) - - BigDecimal(token.to_s) + decimal = token.is_a?(::BigDecimal) ? token : BigDecimal(token.to_s) + reject_infinite!(decimal) + decimal rescue ::ArgumentError => e raise Lutaml::Model::Type::InvalidValueError.new(token, "invalid decimal in XML list: #{e.message}") end + + # Called from `cast_one` and from the public `serialize_one`, since + # `serialize` takes a raw BigDecimal and casting is not the only way in. + def reject_infinite!(value) + return unless value.is_a?(::BigDecimal) && value.infinite? + + raise Lutaml::Model::Type::InvalidValueError.new( + value, + "infinity is not a D-SI decimal: SI_Format.xsd:1195 admits " \ + "numbers and NaN only", + ) + end end end end diff --git a/spec/dcc/diff_spec.rb b/spec/dcc/diff_spec.rb new file mode 100644 index 0000000..43f9d22 --- /dev/null +++ b/spec/dcc/diff_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Dcc::Diff do + before { Dcc::Si::V2.load_all! } + + def real_list(values) + Dcc::Si::V2::RealListXmlList.from_xml(<<~XML) + + #{values} + \\kelvin + + XML + end + + # D-SI writes `NaN` for a not-measured entry, and IEEE says NaN is not equal + # to NaN. `Values#==` honours that on purpose, so the diff has to answer the + # document question — did this change? — rather than the numeric one. + describe "a document compared against an identical copy" do + it "reports no change when the list carries NaN" do + changes = described_class.call(real_list("1.5 NaN 2.5"), + real_list("1.5 NaN 2.5")).changes + expect(changes).to be_empty + end + + it "reports no change when the list is all finite" do + changes = described_class.call(real_list("1.5 2.0 2.5"), + real_list("1.5 2.0 2.5")).changes + expect(changes).to be_empty + end + end + + # The NaN handling must not make the diff blind: a list that really did + # change still has to be reported, NaN or not. + describe "a document compared against a different one" do + it "reports a change when a finite value differs" do + changes = described_class.call(real_list("1.5 2.0 2.5"), + real_list("1.5 9.9 2.5")).changes + expect(changes.size).to eq(1) + end + + it "reports a change when NaN replaces a measured value" do + changes = described_class.call(real_list("1.5 2.0 2.5"), + real_list("1.5 NaN 2.5")).changes + expect(changes.size).to eq(1) + end + + it "reports a change when a measured value replaces NaN" do + changes = described_class.call(real_list("1.5 NaN 2.5"), + real_list("1.5 2.0 2.5")).changes + expect(changes.size).to eq(1) + end + + it "reports a change when the lists are different lengths" do + changes = described_class.call(real_list("1.5 NaN"), + real_list("1.5 NaN 2.5")).changes + expect(changes.size).to eq(1) + end + end + + # `Values.new` takes whatever it is handed, so a list built in Ruby rather + # than parsed can hold entries that were never BigDecimals. Asking one of + # those whether it is NaN raises. + describe "a list assigned in Ruby rather than parsed" do + let(:values) { Dcc::Type::DecimalXmlList::Values } + + it "reports a change between unequal non-decimal entries" do + changes = described_class.call(values.new(["1"]), + values.new(["2"])).changes + expect(changes.size).to eq(1) + end + + it "reports no change between equal non-decimal entries" do + changes = described_class.call(values.new(["1"]), + values.new(["1"])).changes + expect(changes).to be_empty + end + end +end diff --git a/spec/dcc/quantity_format/formatter_spec.rb b/spec/dcc/quantity_format/formatter_spec.rb index d49cecf..0240297 100644 --- a/spec/dcc/quantity_format/formatter_spec.rb +++ b/spec/dcc/quantity_format/formatter_spec.rb @@ -35,4 +35,38 @@ expect(f.to_latex).to include("\\kelvin") end end + + # D-SI writes `NaN` for a not-measured uncertainty, and a parsed decimal list + # now carries it through. The precision of a rendered value is derived from + # `log10(uncertainty)`, which has no answer for NaN, so each form says NaN + # rather than raising. + describe "a NaN uncertainty" do + let(:formatter) do + described_class.new(value: BigDecimal("42.0"), + uncertainty: BigDecimal("NaN"), unit: "\\kelvin") + end + + it "renders the short form without raising" do + expect(formatter.to_short).to eq("42.0(NaN) kelvin") + end + + it "renders the long form without raising" do + expect(formatter.to_long).to eq("42.0 ± NaN kelvin") + end + + it "renders the LaTeX form without raising" do + expect(formatter.to_latex).to eq("\\qty{42.0 +- NaN}{\\kelvin}") + end + + # A NaN uncertainty carries no precision, so the value falls back to the + # same two-decimal default an absent uncertainty gets. Without this the + # two render the same number to different widths. + it "rounds the value the way an absent uncertainty does" do + value = BigDecimal("42.123456789") + with_nan = described_class.new(value: value, + uncertainty: BigDecimal("NaN")) + without = described_class.new(value: value, uncertainty: nil) + expect(with_nan.to_long).to eq("#{without.to_long} ± NaN") + end + end end diff --git a/spec/dcc/type_spec.rb b/spec/dcc/type_spec.rb index e8dd9a3..574ea05 100644 --- a/spec/dcc/type_spec.rb +++ b/spec/dcc/type_spec.rb @@ -116,6 +116,24 @@ arr = [BigDecimal("1.5"), BigDecimal("2.5")] expect(described_class.cast(arr)).to eq(arr) end + + # The D-SI pattern admits numbers and `NaN`, and nothing else + # (SI_Format.xsd:1195). Neither `Infinity` nor `INF` is legal, so an + # infinite value must not reach a document. + it "rejects positive infinity" do + expect { described_class.cast("Infinity") } + .to raise_error(Lutaml::Model::Type::InvalidValueError, /infinity/) + end + + it "rejects negative infinity" do + expect { described_class.cast("-Infinity") } + .to raise_error(Lutaml::Model::Type::InvalidValueError, /infinity/) + end + + it "rejects an infinite BigDecimal passed in an array" do + expect { described_class.cast([BigDecimal("Infinity")]) } + .to raise_error(Lutaml::Model::Type::InvalidValueError, /infinity/) + end end describe ".serialize" do @@ -128,6 +146,20 @@ it "returns empty string for nil" do expect(described_class.serialize(nil)).to eq("") end + + # `NaN` is an alternative in the pattern of every D-SI decimal type + # (SI_Format.xsd:1195), and D-SI writes it for a not-measured entry. + it "serializes NaN, which D-SI admits in every decimal type" do + expect(described_class.serialize(described_class.cast("1.5 NaN 2.5"))) + .to eq("1.5 NaN 2.5") + end + + # `serialize` is public and takes a raw BigDecimal, so casting is not the + # only way an infinite value can reach the wire. + it "refuses to serialize an infinity it was handed uncast" do + expect { described_class.serialize([BigDecimal("Infinity")]) } + .to raise_error(Lutaml::Model::Type::InvalidValueError, /infinity/) + end end # `to_ary` keeps equality symmetric: without it `Array#==` short-circuits to @@ -248,6 +280,33 @@ expect(emitted.first.text).to eq("0.072 0.089 0.107 -0.009 -0.084") end end + + # A schema-valid NaN has to survive the whole path, not just `serialize`. + # Asserted on the emitted text rather than on list equality, because + # `BigDecimal("NaN") == BigDecimal("NaN")` is false. + describe "NaN round-trip through a model attribute" do + before { Dcc::Si::V2.load_all! } + + let(:xml) do + <<~XML + + 1.5 NaN 2.5 + \\kelvin + + XML + end + + let(:serialized) { Nokogiri::XML(Dcc::Si::V2::RealListXmlList.from_xml(xml).to_xml) } + let(:emitted) { serialized.xpath("//*[local-name()='valueXMLList']") } + + it "emits one valueXMLList element for a list containing NaN" do + expect(emitted.size).to eq(1) + end + + it "keeps NaN in place among the decimals" do + expect(emitted.first.text).to eq("1.5 NaN 2.5") + end + end end RSpec.describe Dcc::Type::SchemaVersion do