lutaml-model 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +5 -0
  3. data/.rubocop.yml +18 -0
  4. data/.rubocop_todo.yml +91 -22
  5. data/Gemfile +2 -0
  6. data/README.adoc +114 -2
  7. data/docs/_guides/index.adoc +18 -0
  8. data/docs/_guides/jsonld-serialization.adoc +217 -0
  9. data/docs/_guides/rdf-serialization.adoc +344 -0
  10. data/docs/_guides/turtle-serialization.adoc +224 -0
  11. data/docs/_migrations/0-8-0-namespace-restructuring.adoc +90 -0
  12. data/docs/_pages/serialization_adapters.adoc +31 -0
  13. data/docs/_references/index.adoc +1 -0
  14. data/docs/_references/rdf-namespaces.adoc +243 -0
  15. data/docs/index.adoc +3 -2
  16. data/lib/lutaml/jsonld/adapter.rb +23 -0
  17. data/lib/lutaml/jsonld/context.rb +69 -0
  18. data/lib/lutaml/jsonld/term_definition.rb +39 -0
  19. data/lib/lutaml/jsonld/transform.rb +174 -0
  20. data/lib/lutaml/jsonld.rb +23 -0
  21. data/lib/lutaml/model/format_registry.rb +10 -1
  22. data/lib/lutaml/model/serialize/format_conversion.rb +17 -1
  23. data/lib/lutaml/model/version.rb +1 -1
  24. data/lib/lutaml/model.rb +6 -0
  25. data/lib/lutaml/rdf/error.rb +7 -0
  26. data/lib/lutaml/rdf/iri.rb +44 -0
  27. data/lib/lutaml/rdf/language_tagged.rb +11 -0
  28. data/lib/lutaml/rdf/literal.rb +62 -0
  29. data/lib/lutaml/rdf/mapping.rb +71 -0
  30. data/lib/lutaml/rdf/mapping_rule.rb +35 -0
  31. data/lib/lutaml/rdf/member_rule.rb +13 -0
  32. data/lib/lutaml/rdf/namespace.rb +58 -0
  33. data/lib/lutaml/rdf/namespace_set.rb +69 -0
  34. data/lib/lutaml/rdf/namespaces/dcterms_namespace.rb +12 -0
  35. data/lib/lutaml/rdf/namespaces/owl_namespace.rb +12 -0
  36. data/lib/lutaml/rdf/namespaces/rdf_namespace.rb +14 -0
  37. data/lib/lutaml/rdf/namespaces/rdfs_namespace.rb +12 -0
  38. data/lib/lutaml/rdf/namespaces/skos_namespace.rb +12 -0
  39. data/lib/lutaml/rdf/namespaces/xsd_namespace.rb +12 -0
  40. data/lib/lutaml/rdf/namespaces.rb +14 -0
  41. data/lib/lutaml/rdf/transform.rb +36 -0
  42. data/lib/lutaml/rdf.rb +19 -0
  43. data/lib/lutaml/turtle/adapter.rb +35 -0
  44. data/lib/lutaml/turtle/mapping.rb +7 -0
  45. data/lib/lutaml/turtle/transform.rb +158 -0
  46. data/lib/lutaml/turtle.rb +22 -0
  47. data/lib/lutaml/xml/adapter/adapter_helpers.rb +1 -42
  48. data/lib/lutaml/xml/adapter/base_adapter.rb +48 -458
  49. data/lib/lutaml/xml/adapter/namespace_data.rb +0 -17
  50. data/lib/lutaml/xml/adapter/namespace_uri_collector.rb +71 -0
  51. data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +5 -1110
  52. data/lib/lutaml/xml/adapter/oga_adapter.rb +6 -846
  53. data/lib/lutaml/xml/adapter/ox_adapter.rb +7 -884
  54. data/lib/lutaml/xml/adapter/plan_based_builder.rb +929 -0
  55. data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -864
  56. data/lib/lutaml/xml/adapter/xml_parser.rb +86 -0
  57. data/lib/lutaml/xml/adapter/xml_serializer.rb +291 -0
  58. data/lib/lutaml/xml/adapter.rb +0 -1
  59. data/lib/lutaml/xml/adapter_element.rb +7 -1
  60. data/lib/lutaml/xml/builder/base.rb +0 -1
  61. data/lib/lutaml/xml/data_model.rb +9 -1
  62. data/lib/lutaml/xml/document.rb +3 -1
  63. data/lib/lutaml/xml/element.rb +13 -10
  64. data/lib/lutaml/xml/serialization/format_conversion.rb +19 -42
  65. data/lib/lutaml/xml/serialization/instance_methods.rb +26 -35
  66. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +34 -55
  67. data/lib/lutaml/xml/transformation/rule_applier.rb +1 -1
  68. data/lib/lutaml/xml/xml_element.rb +24 -20
  69. data/spec/lutaml/integration/edge_cases_spec.rb +109 -0
  70. data/spec/lutaml/integration/multi_format_spec.rb +106 -0
  71. data/spec/lutaml/integration/round_trip_spec.rb +170 -0
  72. data/spec/lutaml/jsonld/adapter_spec.rb +46 -0
  73. data/spec/lutaml/jsonld/context_spec.rb +114 -0
  74. data/spec/lutaml/jsonld/term_definition_spec.rb +55 -0
  75. data/spec/lutaml/jsonld/transform_spec.rb +211 -0
  76. data/spec/lutaml/rdf/graph_serialization_spec.rb +137 -0
  77. data/spec/lutaml/rdf/iri_spec.rb +73 -0
  78. data/spec/lutaml/rdf/literal_spec.rb +98 -0
  79. data/spec/lutaml/rdf/mapping_spec.rb +164 -0
  80. data/spec/lutaml/rdf/member_rule_spec.rb +17 -0
  81. data/spec/lutaml/rdf/namespace_set_spec.rb +115 -0
  82. data/spec/lutaml/rdf/namespace_spec.rb +241 -0
  83. data/spec/lutaml/rdf/rdf_transform_spec.rb +82 -0
  84. data/spec/lutaml/turtle/adapter_spec.rb +47 -0
  85. data/spec/lutaml/turtle/mapping_spec.rb +123 -0
  86. data/spec/lutaml/turtle/transform_spec.rb +273 -0
  87. data/spec/lutaml/xml/adapter/base_adapter_regression_spec.rb +151 -0
  88. data/spec/lutaml/xml/adapter/order_spec.rb +150 -0
  89. data/spec/lutaml/xml/clear_parse_state_spec.rb +139 -0
  90. data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +0 -2
  91. data/spec/lutaml/xml/schema/compiler_spec.rb +75 -69
  92. data/spec/lutaml/xml/transformation/custom_method_wrapper_spec.rb +213 -14
  93. metadata +58 -3
  94. data/lib/lutaml/xml/adapter/xml_serialization.rb +0 -145
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/turtle"
5
+ require "lutaml/jsonld"
6
+
7
+ RSpec.describe "Round-trip fidelity" do
8
+ before do
9
+ stub_const("TestSkosNs", Class.new(Lutaml::Rdf::Namespace) do
10
+ uri "http://www.w3.org/2004/02/skos/core#"
11
+ prefix "skos"
12
+ end)
13
+
14
+ stub_const("TestExNs", Class.new(Lutaml::Rdf::Namespace) do
15
+ uri "http://example.org/"
16
+ prefix "ex"
17
+ end)
18
+ end
19
+
20
+ describe "Turtle round-trip" do
21
+ before do
22
+ stub_const("RtTurtleModel", Class.new(Lutaml::Model::Serializable) do
23
+ attribute :label, :string
24
+ attribute :note, :string
25
+ attribute :code, :integer
26
+
27
+ turtle do
28
+ namespace TestSkosNs
29
+
30
+ subject { |m| "http://example.org/item/#{m.code}" } # rubocop:disable RSpec/NamedSubject
31
+
32
+ type "skos:Concept"
33
+ predicate :prefLabel,
34
+ namespace: TestSkosNs,
35
+ to: :label
36
+ predicate :note,
37
+ namespace: TestSkosNs,
38
+ to: :note
39
+ predicate :notation,
40
+ namespace: TestSkosNs,
41
+ to: :code
42
+ end
43
+ end)
44
+ end
45
+
46
+ it "preserves string and integer attributes" do
47
+ original = RtTurtleModel.new(label: "hello", note: "world", code: 99)
48
+ turtle = original.to_turtle
49
+ restored = RtTurtleModel.from_turtle(turtle)
50
+ expect(restored.label).to eq("hello")
51
+ expect(restored.note).to eq("world")
52
+ expect(restored.code).to eq(99)
53
+ end
54
+
55
+ it "handles special characters in string values" do
56
+ original = RtTurtleModel.new(label: 'say "hi"', note: "line1\nline2",
57
+ code: 1)
58
+ turtle = original.to_turtle
59
+ restored = RtTurtleModel.from_turtle(turtle)
60
+ expect(restored.label).to eq('say "hi"')
61
+ expect(restored.note).to eq("line1\nline2")
62
+ end
63
+
64
+ it "handles nil optional attributes" do
65
+ original = RtTurtleModel.new(label: "test", note: nil, code: 1)
66
+ turtle = original.to_turtle
67
+ restored = RtTurtleModel.from_turtle(turtle)
68
+ expect(restored.label).to eq("test")
69
+ expect(restored.note).to be_nil
70
+ expect(restored.code).to eq(1)
71
+ end
72
+ end
73
+
74
+ describe "JSON-LD round-trip" do
75
+ before do
76
+ stub_const("RtJsonLdModel", Class.new(Lutaml::Model::Serializable) do
77
+ attribute :title, :string
78
+ attribute :body, :string
79
+ attribute :priority, :integer
80
+
81
+ rdf do
82
+ namespace TestExNs
83
+
84
+ subject { |m| "http://example.org/articles/#{m.priority}" } # rubocop:disable RSpec/NamedSubject
85
+
86
+ type "http://example.org/Article"
87
+
88
+ predicate :title, namespace: TestExNs, to: :title
89
+ predicate :body, namespace: TestExNs, to: :body
90
+ predicate :priority, namespace: TestExNs, to: :priority
91
+ end
92
+ end)
93
+ end
94
+
95
+ it "preserves all attributes through serialize → deserialize" do
96
+ original = RtJsonLdModel.new(title: "Test", body: "Content", priority: 5)
97
+ json = original.to_jsonld
98
+ restored = RtJsonLdModel.from_jsonld(json)
99
+ expect(restored.title).to eq("Test")
100
+ expect(restored.body).to eq("Content")
101
+ expect(restored.priority).to eq(5)
102
+ end
103
+
104
+ it "preserves @context structure across round-trip" do
105
+ original = RtJsonLdModel.new(title: "Test", body: "Content", priority: 1)
106
+ json1 = original.to_jsonld
107
+ restored = RtJsonLdModel.from_jsonld(json1)
108
+ json2 = restored.to_jsonld
109
+
110
+ ctx1 = JSON.parse(json1)["@context"]
111
+ ctx2 = JSON.parse(json2)["@context"]
112
+ expect(ctx1).to eq(ctx2)
113
+ end
114
+
115
+ it "handles nil optional attributes" do
116
+ original = RtJsonLdModel.new(title: "Test", body: nil, priority: 1)
117
+ json = original.to_jsonld
118
+ restored = RtJsonLdModel.from_jsonld(json)
119
+ expect(restored.title).to eq("Test")
120
+ expect(restored.body).to be_nil
121
+ end
122
+ end
123
+
124
+ describe "Error handling" do
125
+ it "Turtle raises MissingSubjectError without subject" do
126
+ stub_const("NoSubjModel", Class.new(Lutaml::Model::Serializable) do
127
+ attribute :name, :string
128
+
129
+ turtle do
130
+ namespace TestSkosNs
131
+ type "skos:Concept"
132
+ predicate :prefLabel,
133
+ namespace: TestSkosNs,
134
+ to: :name
135
+ end
136
+ end)
137
+
138
+ expect { NoSubjModel.new(name: "test").to_turtle }
139
+ .to raise_error(Lutaml::Turtle::MissingSubjectError, /subject/)
140
+ end
141
+
142
+ it "JSON-LD handles invalid JSON gracefully" do
143
+ stub_const("SimpleJsonLdModel", Class.new(Lutaml::Model::Serializable) do
144
+ attribute :name, :string
145
+
146
+ rdf do
147
+ namespace TestExNs
148
+ predicate :name, namespace: TestExNs, to: :name
149
+ end
150
+ end)
151
+
152
+ expect { SimpleJsonLdModel.from_jsonld("not valid json!!!") }
153
+ .to raise_error(Lutaml::Model::InvalidFormatError)
154
+ end
155
+
156
+ it "Rdf::MappingRule validates namespace type" do
157
+ mapping = Lutaml::Rdf::Mapping.new
158
+ expect do
159
+ mapping.predicate(:foo, namespace: String, to: :bar)
160
+ end.to raise_error(ArgumentError, /Rdf::Namespace/)
161
+ end
162
+
163
+ it "Rdf::MappingRule requires :to parameter" do
164
+ mapping = Lutaml::Rdf::Mapping.new
165
+ expect do
166
+ mapping.predicate(:foo, namespace: TestSkosNs, to: nil)
167
+ end.to raise_error(ArgumentError, /required/)
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/jsonld"
5
+
6
+ RSpec.describe Lutaml::JsonLd::Adapter do
7
+ let(:jsonld_hash) do
8
+ {
9
+ "@context" => { "name" => "http://example.org/name" },
10
+ "@type" => "Thing",
11
+ "name" => "test",
12
+ }
13
+ end
14
+
15
+ describe ".parse" do
16
+ it "parses valid JSON-LD string to hash" do
17
+ json = JSON.generate(jsonld_hash)
18
+ result = described_class.parse(json)
19
+ expect(result).to eq(jsonld_hash.transform_keys(&:to_s))
20
+ end
21
+ end
22
+
23
+ describe "#to_jsonld" do
24
+ it "generates JSON-LD string from hash" do
25
+ adapter = described_class.new(jsonld_hash)
26
+ result = adapter.to_jsonld
27
+ parsed = JSON.parse(result)
28
+ expect(parsed["@context"]).to eq({ "name" => "http://example.org/name" })
29
+ end
30
+
31
+ it "supports pretty generation" do
32
+ adapter = described_class.new(jsonld_hash)
33
+ result = adapter.to_jsonld(pretty: true)
34
+ expect(result).to include("\n")
35
+ end
36
+ end
37
+
38
+ it "round-trips parse → generate" do
39
+ json = JSON.generate(jsonld_hash)
40
+ parsed = described_class.parse(json)
41
+ adapter = described_class.new(parsed)
42
+ result = adapter.to_jsonld
43
+ round_tripped = JSON.parse(result)
44
+ expect(round_tripped).to eq(jsonld_hash.transform_keys(&:to_s))
45
+ end
46
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/jsonld"
5
+
6
+ RSpec.describe Lutaml::JsonLd::Context do
7
+ subject(:ctx) { described_class.new }
8
+
9
+ describe "empty context" do
10
+ it "serializes to empty hash" do
11
+ expect(ctx.to_hash).to eq({})
12
+ end
13
+ end
14
+
15
+ describe "#prefix" do
16
+ it "adds namespace prefix from Rdf::Namespace class" do
17
+ ctx.prefix(Lutaml::Rdf::Namespaces::SkosNamespace)
18
+ expect(ctx.to_hash).to include("skos" => "http://www.w3.org/2004/02/skos/core#")
19
+ end
20
+
21
+ it "adds multiple prefixes" do
22
+ ctx.prefix(Lutaml::Rdf::Namespaces::SkosNamespace)
23
+ ctx.prefix(Lutaml::Rdf::Namespaces::DctermsNamespace)
24
+ hash = ctx.to_hash
25
+ expect(hash).to include("skos" => "http://www.w3.org/2004/02/skos/core#")
26
+ expect(hash).to include("dcterms" => "http://purl.org/dc/terms/")
27
+ end
28
+ end
29
+
30
+ describe "#vocab" do
31
+ it "sets @vocab" do
32
+ ctx.vocab("http://example.org/ns/")
33
+ expect(ctx.to_hash).to include("@vocab" => "http://example.org/ns/")
34
+ end
35
+ end
36
+
37
+ describe "#language" do
38
+ it "sets @language" do
39
+ ctx.language("en")
40
+ expect(ctx.to_hash).to include("@language" => "en")
41
+ end
42
+ end
43
+
44
+ describe "#base" do
45
+ it "sets @base" do
46
+ ctx.base("http://example.org/")
47
+ expect(ctx.to_hash).to include("@base" => "http://example.org/")
48
+ end
49
+ end
50
+
51
+ describe "#term" do
52
+ it "adds simple term as name => id" do
53
+ ctx.term("name", id: "http://example.org/name")
54
+ expect(ctx.to_hash).to include("name" => "http://example.org/name")
55
+ end
56
+
57
+ it "adds term with type" do
58
+ ctx.term("date", id: "http://example.org/date", type: "xsd:date")
59
+ expect(ctx.to_hash).to include("date" => {
60
+ "@id" => "http://example.org/date", "@type" => "xsd:date"
61
+ })
62
+ end
63
+
64
+ it "adds term with container" do
65
+ ctx.term("labels", id: "http://example.org/labels", container: :language)
66
+ expect(ctx.to_hash).to include("labels" => {
67
+ "@id" => "http://example.org/labels", "@container" => "@language"
68
+ })
69
+ end
70
+
71
+ it "adds term with reverse" do
72
+ ctx.term("parent", id: "http://example.org/parent", reverse: true)
73
+ expect(ctx.to_hash).to include("parent" => {
74
+ "@id" => "http://example.org/parent", "@reverse" => true
75
+ })
76
+ end
77
+ end
78
+
79
+ describe "#to_hash" do
80
+ it "serializes complete context" do
81
+ ctx.prefix(Lutaml::Rdf::Namespaces::SkosNamespace)
82
+ ctx.vocab("http://example.org/ns/")
83
+ ctx.term("name", id: "http://example.org/name")
84
+ hash = ctx.to_hash
85
+ expect(hash["@vocab"]).to eq("http://example.org/ns/")
86
+ expect(hash["skos"]).to eq("http://www.w3.org/2004/02/skos/core#")
87
+ expect(hash["name"]).to eq("http://example.org/name")
88
+ end
89
+ end
90
+
91
+ describe "#resolve" do
92
+ before do
93
+ ctx.prefix(Lutaml::Rdf::Namespaces::SkosNamespace)
94
+ ctx.vocab("http://example.org/ns/")
95
+ ctx.term("status", id: "http://example.org/status")
96
+ end
97
+
98
+ it "resolves compact IRI via prefixes" do
99
+ expect(ctx.resolve("skos:prefLabel")).to eq("http://www.w3.org/2004/02/skos/core#prefLabel")
100
+ end
101
+
102
+ it "resolves via term definitions" do
103
+ expect(ctx.resolve("status")).to eq("http://example.org/status")
104
+ end
105
+
106
+ it "resolves unprefixed name via @vocab" do
107
+ expect(ctx.resolve("unknown")).to eq("http://example.org/ns/unknown")
108
+ end
109
+
110
+ it "returns nil for unknown prefix" do
111
+ expect(ctx.resolve("unknown:something")).to be_nil
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/jsonld"
5
+
6
+ RSpec.describe Lutaml::JsonLd::TermDefinition do
7
+ it "simple term maps to name => id" do
8
+ td = described_class.new(name: "name", id: "http://example.org/name")
9
+ expect(td.to_context_hash).to eq("name" => "http://example.org/name")
10
+ end
11
+
12
+ it "term with type includes @type" do
13
+ td = described_class.new(name: "date", id: "http://example.org/date",
14
+ type: "xsd:date")
15
+ expect(td.to_context_hash).to eq("date" => {
16
+ "@id" => "http://example.org/date", "@type" => "xsd:date"
17
+ })
18
+ end
19
+
20
+ it "term with container includes @container" do
21
+ td = described_class.new(name: "labels", id: "http://example.org/labels",
22
+ container: :language)
23
+ expect(td.to_context_hash).to eq("labels" => {
24
+ "@id" => "http://example.org/labels", "@container" => "@language"
25
+ })
26
+ end
27
+
28
+ it "term with language includes @language" do
29
+ td = described_class.new(name: "title", id: "http://example.org/title",
30
+ language: "en")
31
+ expect(td.to_context_hash).to eq("title" => {
32
+ "@id" => "http://example.org/title", "@language" => "en"
33
+ })
34
+ end
35
+
36
+ it "term with reverse includes @reverse" do
37
+ td = described_class.new(name: "parent", id: "http://example.org/parent",
38
+ reverse: true)
39
+ expect(td.to_context_hash).to eq("parent" => {
40
+ "@id" => "http://example.org/parent", "@reverse" => true
41
+ })
42
+ end
43
+
44
+ it "complex term includes all fields" do
45
+ td = described_class.new(
46
+ name: "date",
47
+ id: "http://example.org/date",
48
+ type: "xsd:date",
49
+ container: :set,
50
+ )
51
+ hash = td.to_context_hash
52
+ expect(hash["date"]).to eq({ "@id" => "http://example.org/date",
53
+ "@type" => "xsd:date", "@container" => "@set" })
54
+ end
55
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/jsonld"
5
+
6
+ RSpec.describe Lutaml::JsonLd::Transform do
7
+ before do
8
+ stub_const("TestSkosNs", Class.new(Lutaml::Rdf::Namespace) do
9
+ uri "http://www.w3.org/2004/02/skos/core#"
10
+ prefix "skos"
11
+ end)
12
+
13
+ stub_const("TestExNs", Class.new(Lutaml::Rdf::Namespace) do
14
+ uri "http://example.org/"
15
+ prefix "ex"
16
+ end)
17
+ stub_const("JsonLdTestModel", Class.new(Lutaml::Model::Serializable) do
18
+ attribute :name, :string
19
+ attribute :description, :string
20
+
21
+ rdf do
22
+ namespace TestSkosNs, TestExNs
23
+
24
+ subject { |m| "http://example.org/#{m.name}" } # rubocop:disable RSpec/NamedSubject
25
+
26
+ type "skos:Concept"
27
+
28
+ predicate :name, namespace: TestExNs, to: :name
29
+ predicate :description, namespace: TestExNs, to: :description
30
+ end
31
+ end)
32
+ end
33
+
34
+ let(:instance) do
35
+ JsonLdTestModel.new(name: "test", description: "A test concept")
36
+ end
37
+
38
+ describe "model_to_data" do
39
+ let(:result) { instance.to_jsonld }
40
+
41
+ it "generates @context with namespace prefixes" do
42
+ parsed = JSON.parse(result)
43
+ expect(parsed).to have_key("@context")
44
+ expect(parsed["@context"]["skos"]).to eq("http://www.w3.org/2004/02/skos/core#")
45
+ expect(parsed["@context"]["ex"]).to eq("http://example.org/")
46
+ end
47
+
48
+ it "generates @type as compact IRI" do
49
+ parsed = JSON.parse(result)
50
+ expect(parsed["@type"]).to eq("skos:Concept")
51
+ end
52
+
53
+ it "generates @id from subject block" do
54
+ parsed = JSON.parse(result)
55
+ expect(parsed["@id"]).to eq("http://example.org/test")
56
+ end
57
+
58
+ it "includes predicate data" do
59
+ parsed = JSON.parse(result)
60
+ expect(parsed["name"]).to eq("test")
61
+ expect(parsed["description"]).to eq("A test concept")
62
+ end
63
+ end
64
+
65
+ describe "model_to_data without type and subject" do
66
+ before do
67
+ stub_const("MinimalJsonLdModel", Class.new(Lutaml::Model::Serializable) do
68
+ attribute :value, :string
69
+
70
+ rdf do
71
+ namespace TestSkosNs
72
+
73
+ predicate :notation, namespace: TestSkosNs, to: :value
74
+ end
75
+ end)
76
+ end
77
+
78
+ it "omits @type and @id when not defined" do
79
+ instance = MinimalJsonLdModel.new(value: "x")
80
+ parsed = JSON.parse(instance.to_jsonld)
81
+ expect(parsed).not_to have_key("@type")
82
+ expect(parsed).not_to have_key("@id")
83
+ expect(parsed["notation"]).to eq("x")
84
+ end
85
+ end
86
+
87
+ describe "data_to_model" do
88
+ let(:jsonld_input) do
89
+ {
90
+ "@context" => { "ex" => "http://example.org/" },
91
+ "@type" => "skos:Concept",
92
+ "@id" => "http://example.org/test",
93
+ "name" => "from_jsonld",
94
+ "description" => "Loaded from JSON-LD",
95
+ }
96
+ end
97
+
98
+ it "parses JSON-LD back to model" do
99
+ model = JsonLdTestModel.from_jsonld(JSON.generate(jsonld_input))
100
+ expect(model.name).to eq("from_jsonld")
101
+ expect(model.description).to eq("Loaded from JSON-LD")
102
+ end
103
+
104
+ it "strips JSON-LD keywords before attribute mapping" do
105
+ model = JsonLdTestModel.from_jsonld(JSON.generate(jsonld_input))
106
+ expect(model).to be_a(JsonLdTestModel)
107
+ end
108
+ end
109
+
110
+ describe "deserialization ignores JSON-LD keywords" do
111
+ let(:jsonld_input) do
112
+ {
113
+ "@context" => { "ex" => "http://example.org/" },
114
+ "@type" => "skos:Concept",
115
+ "@id" => "http://example.org/test",
116
+ "@graph" => [],
117
+ "name" => "value_only",
118
+ }
119
+ end
120
+
121
+ it "strips all @-prefixed keys" do
122
+ model = JsonLdTestModel.from_jsonld(JSON.generate(jsonld_input))
123
+ expect(model.name).to eq("value_only")
124
+ end
125
+ end
126
+
127
+ describe "round-trip" do
128
+ it "preserves data through model → JSON-LD → model" do
129
+ json = instance.to_jsonld
130
+ restored = JsonLdTestModel.from_jsonld(json)
131
+ expect(restored.name).to eq("test")
132
+ expect(restored.description).to eq("A test concept")
133
+ end
134
+
135
+ it "produces consistent @context through round-trip" do
136
+ json = instance.to_jsonld
137
+ parsed = JSON.parse(json)
138
+ expect(parsed["@context"]["skos"]).to eq("http://www.w3.org/2004/02/skos/core#")
139
+ expect(parsed["@context"]["ex"]).to eq("http://example.org/")
140
+ end
141
+ end
142
+
143
+ describe "nil attribute values" do
144
+ let(:instance) { JsonLdTestModel.new(name: "test", description: nil) }
145
+
146
+ it "omits nil values from output" do
147
+ parsed = JSON.parse(instance.to_jsonld)
148
+ expect(parsed["name"]).to eq("test")
149
+ expect(parsed).not_to have_key("description")
150
+ end
151
+ end
152
+
153
+ describe "model with integer and boolean attributes" do
154
+ before do
155
+ stub_const("TypedJsonLdModel", Class.new(Lutaml::Model::Serializable) do
156
+ attribute :label, :string
157
+ attribute :count, :integer
158
+ attribute :active, :boolean
159
+
160
+ rdf do
161
+ namespace TestSkosNs
162
+
163
+ subject { |_| "http://example.org/1" } # rubocop:disable RSpec/NamedSubject
164
+
165
+ type "skos:Concept"
166
+
167
+ predicate :prefLabel, namespace: TestSkosNs, to: :label
168
+ predicate :notation, namespace: TestSkosNs, to: :count
169
+ predicate :note, namespace: TestSkosNs, to: :active
170
+ end
171
+ end)
172
+ end
173
+
174
+ it "serializes and deserializes typed values" do
175
+ instance = TypedJsonLdModel.new(label: "test", count: 42, active: true)
176
+ json = instance.to_jsonld
177
+ parsed = JSON.parse(json)
178
+ expect(parsed["notation"]).to eq(42)
179
+ expect(parsed["note"]).to be(true)
180
+
181
+ restored = TypedJsonLdModel.from_jsonld(json)
182
+ expect(restored.count).to eq(42)
183
+ expect(restored.active).to be(true)
184
+ end
185
+ end
186
+
187
+ describe "collection attributes" do
188
+ before do
189
+ stub_const("CollectionJsonLdModel", Class.new(Lutaml::Model::Serializable) do
190
+ attribute :tags, :string, collection: true
191
+
192
+ rdf do
193
+ namespace TestSkosNs
194
+
195
+ subject { |_| "http://example.org/1" } # rubocop:disable RSpec/NamedSubject
196
+
197
+ type "skos:Concept"
198
+
199
+ predicate :notation, namespace: TestSkosNs, to: :tags
200
+ end
201
+ end)
202
+ end
203
+
204
+ it "round-trips collection values" do
205
+ instance = CollectionJsonLdModel.new(tags: ["en", "fr"])
206
+ json = instance.to_jsonld
207
+ restored = CollectionJsonLdModel.from_jsonld(json)
208
+ expect(restored.tags).to eq(["en", "fr"])
209
+ end
210
+ end
211
+ end