diff --git a/lib/termium.rb b/lib/termium.rb index de57739..b8e262e 100644 --- a/lib/termium.rb +++ b/lib/termium.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +# glossarist's ManagedConceptCollection#save_to_files calls FileUtils without +# requiring it, so loading it here keeps `termium convert` from raising +# NameError. +require "fileutils" require "glossarist" module Termium diff --git a/lib/termium/core.rb b/lib/termium/core.rb index 70d5943..3af26c8 100644 --- a/lib/termium/core.rb +++ b/lib/termium/core.rb @@ -45,8 +45,12 @@ def uuid(str = identification_number) # details="Compartment - ISO/IEC JTC 1 Information Technology Vocabulary" /> def to_concept(options = {}) Glossarist::ManagedConcept.new.tap do |concept| - # The way to set the universal concept's identifier: data.identifier - concept.id = identification_number + # `data` must be assigned, not mutated: lutaml-model skips serializing + # attributes still flagged as using their default, so mutating the + # default `ManagedConceptData` in place drops the whole `data` block. + concept.data = Glossarist::ManagedConceptData.new( + id: identification_number, + ) concept.uuid = uuid @@ -55,24 +59,33 @@ def to_concept(options = {}) concept.status = "valid" if options[:date_accepted] - concept.date_accepted = options[:date_accepted] + concept.date_accepted = Glossarist::ConceptDate.new( + date: options[:date_accepted], + type: "accepted", + ) end - language_module.map do |lang_mod| - localized_concept = lang_mod.to_concept(options) + add_localizations(concept, options) + end + end + + private + + def add_localizations(concept, options) + language_module.each do |lang_mod| + localized_concept = lang_mod.to_concept(options) - # TODO: This is needed to skip the empty french entries of 10031781 and 10031778 - next if localized_concept.nil? + # TODO: This is needed to skip the empty french entries of 10031781 and 10031778 + next if localized_concept.nil? - localized_concept.id = identification_number - localized_concept.uuid = uuid("#{identification_number}-#{lang_mod.language}") + localized_concept.id = identification_number + localized_concept.uuid = uuid("#{identification_number}-#{lang_mod.language}") - universal_entry.each do |entry| - localized_concept.notes << Glossarist::DetailedDefinition.new(content: entry.value) - end - localized_concept.sources = concept_sources - concept.add_localization(localized_concept) + universal_entry.each do |entry| + localized_concept.notes << Glossarist::DetailedDefinition.new(content: entry.value) end + localized_concept.sources = concept_sources + concept.add_localization(localized_concept) end end end diff --git a/lib/termium/language_module.rb b/lib/termium/language_module.rb index 1c1691d..37afd9c 100644 --- a/lib/termium/language_module.rb +++ b/lib/termium/language_module.rb @@ -50,14 +50,15 @@ def designations def to_h # TODO: This is needed to skip the empty french entries of 10031781 and 10031778 - return nil unless definition + value = definition + return nil unless value src = { "language_code" => LANGUAGE_CODE_MAPPING[language.downcase], "terms" => designations.map(&:to_h), - "definition" => [{ content: definition }], - "notes" => notes, - "examples" => examples, + "definition" => detailed_definitions([value]), + "notes" => detailed_definitions(notes), + "examples" => detailed_definitions(examples), "entry_status" => "valid", } @@ -70,15 +71,28 @@ def to_concept(options = {}) x = to_h return nil unless x - Glossarist::LocalizedConcept.new(x).tap do |concept| + # The flat hash belongs under "data": LocalizedConcept is data-backed, and + # `.new` would silently discard every key that is not one of its own + # attributes. `of_yaml` also routes "terms" through ConceptData. + Glossarist::LocalizedConcept.of_yaml({ "data" => x }).tap do |concept| # Fill in register parameters if options[:date_accepted] - # puts options[:date_accepted].inspect - concept.date_accepted = options[:date_accepted] + # `date_accepted` is a read-only derived accessor on Concept; the + # accepted date is set by way of `data.dates`. + concept.data.dates = [ + Glossarist::ConceptDate.new( + date: options[:date_accepted], + type: "accepted", + ), + ] end - - # puts concept.inspect end end + + private + + def detailed_definitions(values) + values.map { |value| { "content" => value } } + end end end diff --git a/spec/termium_spec.rb b/spec/termium_spec.rb index 0c4b2a7..84a31ce 100644 --- a/spec/termium_spec.rb +++ b/spec/termium_spec.rb @@ -1,17 +1,131 @@ # frozen_string_literal: true +require "open3" +require "rbconfig" +require "tmpdir" +require "yaml" + RSpec.describe Termium do - # let(:concept_folder) { "concept_collection_v2" } - # let(:concept_files) { Dir.glob(File.join(fixtures_path(concept_folder), "concept", "*.{yaml,yml}")) } - # let(:localized_concepts_folder) { File.join(fixtures_path(concept_folder), "localized_concept") } - - let(:termium_extract_file) { fixtures_path("Characters.xml") } - let(:glossarist_output_file) { fixtures_path("Characters-Glossarist") } - - it "does something useful" do - termium_extract = Termium::Extract.from_xml(File.read(termium_extract_file)) - glossarist_col = termium_extract.to_concept - FileUtils.mkdir_p(glossarist_output_file) - glossarist_col.save_to_files(glossarist_output_file) + let(:extract) do + Termium::Extract.from_xml(File.read(fixtures_path("Characters.xml"))) + end + + # The TERMIUM entry used throughout: identificationNumber 2123225, which has + # both an EN and a FR languageModule. + let(:identification_number) { "2123225" } + let(:core) do + extract.core.find { |c| c.identification_number == identification_number } + end + + def save_to_tmp(options = {}) + Dir.mktmpdir do |dir| + extract.to_concept(options).save_to_files(dir) + yield( + Dir.glob("#{dir}/concept/*.yaml").map { |f| YAML.load_file(f) }, + Dir.glob("#{dir}/localized_concept/*.yaml").map { |f| YAML.load_file(f) } + ) + end + end + + describe "#to_concept" do + it "carries the TERMIUM identification number as the concept identifier" do + doc = core.to_concept.to_yaml_hash + + expect(doc.dig("data", "identifier")).to eq(identification_number) + end + + it "registers one localization per language, keyed by language code" do + expect(core.to_concept.localizations.keys).to contain_exactly("eng", "fre") + end + + it "cross-references every localization from the concept" do + doc = core.to_concept.to_yaml_hash + + expect(doc.dig("data", "localized_concepts").keys) + .to contain_exactly("eng", "fre") + end + + it "gives each localization a distinct uuid" do + map = core.to_concept.to_yaml_hash.dig("data", "localized_concepts") + + expect(map["eng"]).not_to eq(map["fre"]) + end + + it "populates the localized concept from the TERMIUM entry" do + data = core.to_concept.localization("eng").to_yaml_hash["data"] + + aggregate_failures do + expect(data["language_code"]).to eq("eng") + expect(data["terms"].map { |t| t["designation"] }) + .to include("average information rate") + expect(data.dig("definition", 0, "content")) + .to start_with("quotient of the character mean entropy") + end + end + + it "wraps notes as detailed definitions" do + data = core.to_concept.localization("eng").to_yaml_hash["data"] + + expect(data["notes"].map { |n| n["content"] }) + .to include(a_string_including("average information rate may be expressed")) + end + end + + describe "#to_concept with date_accepted" do + let(:date_accepted) { "2024-01-15" } + + it "records the accepted date on the concept" do + save_to_tmp(date_accepted: date_accepted) do |concepts, _localized| + expect(concepts) + .to all(include("date_accepted" => a_string_starting_with(date_accepted))) + end + end + + it "records the accepted date on every localized concept" do + save_to_tmp(date_accepted: date_accepted) do |_concepts, localized| + expect(localized) + .to all(include("date_accepted" => a_string_starting_with(date_accepted))) + end + end + end + + # glossarist's save_to_files calls FileUtils without requiring it, so requiring + # "termium" must be sufficient on its own. This has to run in a clean subprocess: + # in-process the spec's own `require "tmpdir"` loads fileutils and masks the bug, + # which is why it reaches production while the suite stays green. + describe "loading termium standalone" do + # Passes argv directly rather than through a shell: POSIX quoting would + # reach cmd.exe verbatim on Windows and mangle the script. + def save_in_clean_process(dir) + root = File.expand_path("..", __dir__) + script = <<~RUBY + require "termium" + xml = File.read(#{File.join(root, 'spec/fixtures/Characters.xml').inspect}) + Termium::Extract.from_xml(xml).to_concept.save_to_files(#{dir.inspect}) + RUBY + Open3.capture2e(RbConfig.ruby, "-I#{File.join(root, 'lib')}", "-e", script, + chdir: root) + end + + it "can save a dataset without the caller requiring fileutils" do + output, status = Dir.mktmpdir { |dir| save_in_clean_process(dir) } + + aggregate_failures do + expect(output).not_to include("NameError") + expect(status).to be_success + end + end + end + + describe "#save_to_files" do + it "writes one concept per entry and one localized concept per language" do + save_to_tmp do |concepts, localized| + aggregate_failures do + expect(concepts.size).to eq(extract.core.size) + expect(localized.size) + .to eq(extract.core.sum { |c| c.language_module.size }) + end + end + end end end diff --git a/termium.gemspec b/termium.gemspec index 945ac14..e2b967a 100644 --- a/termium.gemspec +++ b/termium.gemspec @@ -31,7 +31,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "glossarist", "~> 2.3.5" + spec.add_dependency "glossarist", "~> 2.11.3" spec.add_dependency "lutaml-model", "~> 0.8.0" spec.add_dependency "thor" spec.add_dependency "uuidtools"