From 5c28a8dea276719ddbd01a1c9c299207ecdf1bb3 Mon Sep 17 00:00:00 2001 From: Nick Nicholas Date: Thu, 7 May 2026 12:21:10 +1000 Subject: [PATCH] ics: persist Isoics description to backing ivar so XML round-trip preserves Under lutaml-model 0.8, child elements parsed from XML and re-serialized via a parent collection skip attributes whose backing ivar holds no explicit value (the user-defined `#text` accessor is not consulted in that path). Push the Isoics fallback through the public `text=` writer when `code` is assigned, and refuse the post-parse `using_default_for` mark, so the description is emitted on subsequent `to_xml`. Adds spec coverage for the XML round-trip behaviour: `ICS.new(code:)`, `ICS.from_xml`, `Ext.from_xml` nesting an ICS with no ``, and the explicit-text-wins case. Closes #112. --- lib/relaton/bib/model/ics.rb | 32 +++++++++++++-- spec/relaton/bib/model/ics_spec.rb | 66 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/lib/relaton/bib/model/ics.rb b/lib/relaton/bib/model/ics.rb index e77e61f..eef530c 100644 --- a/lib/relaton/bib/model/ics.rb +++ b/lib/relaton/bib/model/ics.rb @@ -12,11 +12,37 @@ class ICS < Lutaml::Model::Serializable map_element "text", to: :text end + # Returns the explicit text if set, else the Isoics description for + # `code`. Kept for consumers that read `.text` directly. def text - val = @text - return val if val && !val.empty? + return @text if @text.is_a?(String) && !@text.empty? - Isoics.fetch(code)&.description if code + Isoics.fetch(code)&.description if code.is_a?(String) && !code.empty? + end + + # When code is assigned, eagerly populate text from Isoics if no + # explicit text has been set. Going through the public writer + # registers the value with the lutaml-model `value_set_for` tracker + # so the attribute is emitted on serialization. + def code=(val) + super + return unless val.is_a?(String) && !val.empty? + return if @text.is_a?(String) && !@text.empty? + + description = Isoics.fetch(val)&.description + self.text = description if description + end + + # When the deserializer reaches the end of the XML element and + # records that was absent, it calls `using_default_for(:text)` + # to mark the attribute as default-valued (suppressing serialization). + # Refuse that mark if we've already populated text from Isoics so the + # value survives round-trip. See #112. + def using_default_for(attribute_name) + return if attribute_name == :text && + @text.is_a?(String) && !@text.empty? + + super end end end diff --git a/spec/relaton/bib/model/ics_spec.rb b/spec/relaton/bib/model/ics_spec.rb index 8ef3675..9dee0c7 100644 --- a/spec/relaton/bib/model/ics_spec.rb +++ b/spec/relaton/bib/model/ics_spec.rb @@ -37,4 +37,70 @@ end end end + + describe "XML serialization with Isoics fallback" do + # These tests guard the round-trip behaviour that lets X + # (with absent from the source) re-serialize with the Isoics + # description filled in. This relies on the Isoics fallback being + # written through the public setter so lutaml-model marks the value as + # "set" and emits it. + + context "ICS.new with a known code and no text" do + it "emits the Isoics description as " do + ics = Relaton::Bib::ICS.new(code: "67.060") + expect(ics.to_xml).to include( + "Cereals, pulses and derived products", + ) + end + end + + context "ICS.from_xml standalone with no " do + it "fills in from Isoics on re-serialization" do + ics = Relaton::Bib::ICS.from_xml("67.060") + expect(ics.to_xml).to include("67.060") + expect(ics.to_xml).to include( + "Cereals, pulses and derived products", + ) + end + end + + context "Ext.from_xml nesting an ICS with no " do + # This is the canonical metanorma collection round-trip path: the + # ICS is reached as a nested-collection child of , not via + # ICS.from_xml directly. + it "fills in from Isoics on re-serialization of the parent" do + ext = Relaton::Bib::Ext.from_xml( + "67.060", + ) + expect(ext.to_xml).to include( + "Cereals, pulses and derived products", + ) + end + end + + context "explicit text wins over Isoics" do + it "emits the explicit text in standalone to_xml" do + ics = Relaton::Bib::ICS.new( + code: "67.060", text: "Custom override text", + ) + expect(ics.to_xml).to include("Custom override text") + expect(ics.to_xml).not_to include("Cereals") + end + + it "preserves explicit text through Ext round-trip" do + ext = Relaton::Bib::Ext.from_xml( + "67.060Custom", + ) + expect(ext.to_xml).to include("Custom") + expect(ext.to_xml).not_to include("Cereals") + end + end + + context "invalid ICS code" do + it "does not emit " do + ics = Relaton::Bib::ICS.new(code: "not-a-real-code") + expect(ics.to_xml).not_to include("") + end + end + end end