lutaml-model 0.8.2 → 0.8.4
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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +23 -23
- data/README.adoc +213 -1
- data/docs/_guides/document-validation.adoc +303 -0
- data/docs/_guides/index.adoc +1 -0
- data/docs/_guides/xml-mapping.adoc +9 -1
- data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
- data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
- data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
- data/lib/lutaml/model/attribute.rb +19 -1
- data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
- data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
- data/lib/lutaml/model/global_context.rb +1 -0
- data/lib/lutaml/model/liquefiable.rb +12 -15
- data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
- data/lib/lutaml/model/mapping_hash.rb +1 -1
- data/lib/lutaml/model/services/transformer.rb +67 -32
- data/lib/lutaml/model/transform.rb +41 -4
- data/lib/lutaml/model/uninitialized_class.rb +11 -5
- data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
- data/lib/lutaml/model/validation/context.rb +36 -0
- data/lib/lutaml/model/validation/issue.rb +62 -0
- data/lib/lutaml/model/validation/layer_result.rb +34 -0
- data/lib/lutaml/model/validation/profile.rb +66 -0
- data/lib/lutaml/model/validation/registry.rb +60 -0
- data/lib/lutaml/model/validation/remediation.rb +33 -0
- data/lib/lutaml/model/validation/remediation_result.rb +20 -0
- data/lib/lutaml/model/validation/report.rb +39 -0
- data/lib/lutaml/model/validation/rule.rb +59 -0
- data/lib/lutaml/model/validation.rb +2 -1
- data/lib/lutaml/model/validation_framework.rb +77 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +4 -0
- data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
- data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
- data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
- data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
- data/lib/lutaml/xml/adapter_element.rb +26 -2
- data/lib/lutaml/xml/data_model.rb +14 -0
- data/lib/lutaml/xml/document.rb +3 -0
- data/lib/lutaml/xml/element.rb +8 -2
- data/lib/lutaml/xml/mapping.rb +9 -0
- data/lib/lutaml/xml/model_transform.rb +42 -0
- data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
- data/lib/lutaml/xml/schema/xsd/schema_path.rb +6 -0
- data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
- data/lib/lutaml/xml/transformation.rb +40 -1
- data/lib/lutaml/xml/xml_element.rb +8 -7
- data/lutaml-model.gemspec +1 -2
- data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
- data/spec/lutaml/model/liquefiable_spec.rb +22 -6
- data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
- data/spec/lutaml/model/ordered_content_spec.rb +5 -5
- data/spec/lutaml/model/services/transformer_spec.rb +43 -0
- data/spec/lutaml/model/transform_cache_spec.rb +62 -0
- data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
- data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
- data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
- data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
- data/spec/lutaml/model/validation/context_spec.rb +60 -0
- data/spec/lutaml/model/validation/issue_spec.rb +77 -0
- data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
- data/spec/lutaml/model/validation/profile_spec.rb +134 -0
- data/spec/lutaml/model/validation/registry_spec.rb +94 -0
- data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
- data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
- data/spec/lutaml/model/validation/report_spec.rb +58 -0
- data/spec/lutaml/model/validation/rule_spec.rb +134 -0
- data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
- data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
- data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
- data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
- data/spec/lutaml/xml/mapping_spec.rb +12 -7
- data/spec/lutaml/xml/schema/xsd/glob_spec.rb +12 -0
- metadata +46 -21
- data/spec/fixtures/liquid_templates/_ceramics.liquid +0 -3
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Transform caching" do
|
|
6
|
+
after { Lutaml::Model::Transform.clear_cache! }
|
|
7
|
+
|
|
8
|
+
describe ".cached_transform" do
|
|
9
|
+
it "returns same instance for same context and register" do
|
|
10
|
+
klass = Class.new(Lutaml::Model::Serializable) do
|
|
11
|
+
attribute :name, :string
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
t1 = Lutaml::Model::Transform.cached_transform(klass, :default)
|
|
15
|
+
t2 = Lutaml::Model::Transform.cached_transform(klass, :default)
|
|
16
|
+
expect(t1).to equal(t2)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "returns different instances for different contexts" do
|
|
20
|
+
klass_a = Class.new(Lutaml::Model::Serializable) do
|
|
21
|
+
attribute :name, :string
|
|
22
|
+
end
|
|
23
|
+
klass_b = Class.new(Lutaml::Model::Serializable) do
|
|
24
|
+
attribute :title, :string
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
t1 = Lutaml::Model::Transform.cached_transform(klass_a, :default)
|
|
28
|
+
t2 = Lutaml::Model::Transform.cached_transform(klass_b, :default)
|
|
29
|
+
expect(t1).not_to equal(t2)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe ".clear_cache!" do
|
|
34
|
+
it "clears the cache" do
|
|
35
|
+
klass = Class.new(Lutaml::Model::Serializable) do
|
|
36
|
+
attribute :name, :string
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Lutaml::Model::Transform.cached_transform(klass, :default)
|
|
40
|
+
expect(Lutaml::Model::Transform.cache_size).to be > 0
|
|
41
|
+
|
|
42
|
+
Lutaml::Model::Transform.clear_cache!
|
|
43
|
+
expect(Lutaml::Model::Transform.cache_size).to eq(0)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe "cache eviction" do
|
|
48
|
+
it "evicts entries when exceeding MAX_CACHE_SIZE" do
|
|
49
|
+
stub_const("Lutaml::Model::Transform::MAX_CACHE_SIZE", 4)
|
|
50
|
+
|
|
51
|
+
classes = Array.new(6) do |i|
|
|
52
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
53
|
+
attribute :"attr_#{i}", :string
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
classes.each { |k| Lutaml::Model::Transform.cached_transform(k, :default) }
|
|
58
|
+
|
|
59
|
+
expect(Lutaml::Model::Transform.cache_size).to be <= 4
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Transform with dynamically added attributes" do
|
|
6
|
+
before do
|
|
7
|
+
Lutaml::Model::GlobalContext.clear_caches
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it "picks up attributes added after initial class definition" do
|
|
11
|
+
base_class = Class.new(Lutaml::Model::Serializable) do
|
|
12
|
+
attribute :name, :string
|
|
13
|
+
|
|
14
|
+
xml do
|
|
15
|
+
root "test"
|
|
16
|
+
map_element "name", to: :name
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.name
|
|
20
|
+
"DynamicAttributeTestClass"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Parse once to populate Transform cache
|
|
25
|
+
base_class.from_xml("<test><name>initial</name></test>")
|
|
26
|
+
|
|
27
|
+
# Dynamically add a new attribute and mapping (like xmi EaRoot.load_extension)
|
|
28
|
+
base_class.class_eval do
|
|
29
|
+
attribute :extra, :string
|
|
30
|
+
|
|
31
|
+
xml do
|
|
32
|
+
map_element "extra", to: :extra
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The Transform must see the newly added attribute
|
|
37
|
+
result = base_class.from_xml("<test><name>hello</name><extra>world</extra></test>")
|
|
38
|
+
expect(result.name).to eq("hello")
|
|
39
|
+
expect(result.extra).to eq("world")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "UninitializedClass deep_dup compatibility" do
|
|
6
|
+
let(:uninitialized) { Lutaml::Model::UninitializedClass.instance }
|
|
7
|
+
|
|
8
|
+
# Replicates the rng gem's ExternalRefResolver#deep_dup pattern
|
|
9
|
+
def deep_dup(obj)
|
|
10
|
+
case obj
|
|
11
|
+
when Array
|
|
12
|
+
obj.map { |o| deep_dup(o) }
|
|
13
|
+
when Hash
|
|
14
|
+
obj.each_with_object({}) { |(k, v), h| h[deep_dup(k)] = deep_dup(v) }
|
|
15
|
+
when NilClass, Symbol, Numeric, TrueClass, FalseClass
|
|
16
|
+
obj
|
|
17
|
+
else
|
|
18
|
+
obj.dup
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "does not raise TypeError when deep_dup encounters UninitializedClass in a hash value" do
|
|
23
|
+
data = { "key" => "value", "missing" => uninitialized }
|
|
24
|
+
result = deep_dup(data)
|
|
25
|
+
expect(result["missing"]).to equal(uninitialized)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "does not raise TypeError when deep_dup encounters UninitializedClass in an array" do
|
|
29
|
+
data = ["hello", uninitialized, "world"]
|
|
30
|
+
result = deep_dup(data)
|
|
31
|
+
expect(result[1]).to equal(uninitialized)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "does not raise TypeError when deep_dup encounters UninitializedClass as hash key" do
|
|
35
|
+
data = { uninitialized => "value" }
|
|
36
|
+
result = deep_dup(data)
|
|
37
|
+
expect(result.keys.first).to equal(uninitialized)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -77,8 +77,8 @@ RSpec.describe Lutaml::Model::UninitializedClass do
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
context "when method doesn't end with '?'" do
|
|
80
|
-
it "
|
|
81
|
-
expect
|
|
80
|
+
it "returns nil" do
|
|
81
|
+
expect(uninitialized.unknown_method).to be_nil
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
end
|
|
@@ -93,4 +93,16 @@ RSpec.describe Lutaml::Model::UninitializedClass do
|
|
|
93
93
|
expect(uninitialized.respond_to?(:unknown_method)).to be false
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
|
+
|
|
97
|
+
describe "#dup" do
|
|
98
|
+
it "returns self" do
|
|
99
|
+
expect(uninitialized.dup).to equal(uninitialized)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
describe "#clone" do
|
|
104
|
+
it "returns self" do
|
|
105
|
+
expect(uninitialized.clone).to equal(uninitialized)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
96
108
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/model/validation_framework"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Model::Validation::HasIssues do
|
|
7
|
+
let(:error_issue) do
|
|
8
|
+
Lutaml::Model::Validation::Issue.new(
|
|
9
|
+
severity: "error", code: "E-001", message: "bad",
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
let(:warning_issue) do
|
|
14
|
+
Lutaml::Model::Validation::Issue.new(
|
|
15
|
+
severity: "warning", code: "W-001", message: "meh",
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
let(:info_issue) do
|
|
20
|
+
Lutaml::Model::Validation::Issue.new(
|
|
21
|
+
severity: "info", code: "I-001", message: "fyi",
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
let(:notice_issue) do
|
|
26
|
+
Lutaml::Model::Validation::Issue.new(
|
|
27
|
+
severity: "notice", code: "N-001", message: "note",
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
let(:container) do
|
|
32
|
+
all_issues = [error_issue, warning_issue, info_issue, notice_issue]
|
|
33
|
+
klass = Class.new do
|
|
34
|
+
include Lutaml::Model::Validation::HasIssues
|
|
35
|
+
|
|
36
|
+
attr_reader :issues
|
|
37
|
+
|
|
38
|
+
def initialize(issues)
|
|
39
|
+
@issues = issues
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
klass.new(all_issues)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "filters errors" do
|
|
46
|
+
expect(container.errors).to eq([error_issue])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "filters warnings" do
|
|
50
|
+
expect(container.warnings).to eq([warning_issue])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "filters infos" do
|
|
54
|
+
expect(container.infos).to eq([info_issue])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "filters notices" do
|
|
58
|
+
expect(container.notices).to eq([notice_issue])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "returns empty arrays when no matching severity" do
|
|
62
|
+
only_errors = Class.new do
|
|
63
|
+
include Lutaml::Model::Validation::HasIssues
|
|
64
|
+
|
|
65
|
+
def issues
|
|
66
|
+
[Lutaml::Model::Validation::Issue.new(
|
|
67
|
+
severity: "error", code: "X", message: "y",
|
|
68
|
+
)]
|
|
69
|
+
end
|
|
70
|
+
end.new
|
|
71
|
+
|
|
72
|
+
expect(only_errors.warnings).to be_empty
|
|
73
|
+
expect(only_errors.infos).to be_empty
|
|
74
|
+
expect(only_errors.notices).to be_empty
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/model/validation_framework"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Model::Validation::Context do
|
|
7
|
+
subject(:context) { described_class.new }
|
|
8
|
+
|
|
9
|
+
it "starts with empty errors" do
|
|
10
|
+
expect(context.errors).to be_empty
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
describe "#add_error" do
|
|
14
|
+
it "accumulates errors" do
|
|
15
|
+
issue = Lutaml::Model::Validation::Issue.new(
|
|
16
|
+
severity: "error", code: "T-001", message: "bad",
|
|
17
|
+
)
|
|
18
|
+
context.add_error(issue)
|
|
19
|
+
expect(context.errors.length).to eq(1)
|
|
20
|
+
expect(context.errors.first).to eq(issue)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe "#add_errors" do
|
|
25
|
+
it "concatenates multiple errors" do
|
|
26
|
+
issues = Array.new(2) do |i|
|
|
27
|
+
Lutaml::Model::Validation::Issue.new(
|
|
28
|
+
severity: "error", code: "T-#{i}", message: "bad #{i}",
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
context.add_errors(issues)
|
|
32
|
+
expect(context.errors.length).to eq(2)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe "#rule_state" do
|
|
37
|
+
it "provides per-rule state hash" do
|
|
38
|
+
state = context.rule_state("R-001")
|
|
39
|
+
state[:count] = 5
|
|
40
|
+
expect(context.rule_state("R-001")[:count]).to eq(5)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "isolates state between rules" do
|
|
44
|
+
context.rule_state("R-001")[:val] = "a"
|
|
45
|
+
context.rule_state("R-002")[:val] = "b"
|
|
46
|
+
expect(context.rule_state("R-001")[:val]).to eq("a")
|
|
47
|
+
expect(context.rule_state("R-002")[:val]).to eq("b")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe "#reset!" do
|
|
52
|
+
it "clears errors and state" do
|
|
53
|
+
context.add_error(double("issue"))
|
|
54
|
+
context.rule_state("R")[:x] = 1
|
|
55
|
+
context.reset!
|
|
56
|
+
expect(context.errors).to be_empty
|
|
57
|
+
expect(context.rule_state("R")).to be_empty
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/model/validation_framework"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Model::Validation::Issue do
|
|
7
|
+
subject(:issue) do
|
|
8
|
+
described_class.new(
|
|
9
|
+
severity: "error",
|
|
10
|
+
code: "TEST-001",
|
|
11
|
+
message: "Something is wrong",
|
|
12
|
+
location: "file.xml",
|
|
13
|
+
line: 42,
|
|
14
|
+
suggestion: "Fix it",
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe "SEVERITIES constant" do
|
|
19
|
+
it "defines allowed severity levels" do
|
|
20
|
+
expect(described_class::SEVERITIES).to eq(%w[error warning info notice])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe "severity validation" do
|
|
25
|
+
it "accepts valid severities" do
|
|
26
|
+
described_class::SEVERITIES.each do |sev|
|
|
27
|
+
expect { described_class.new(severity: sev, code: "T", message: "m") }
|
|
28
|
+
.not_to raise_error
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "rejects invalid severity" do
|
|
33
|
+
expect do
|
|
34
|
+
described_class.new(severity: "critical", code: "T", message: "m")
|
|
35
|
+
end.to raise_error(ArgumentError, /Invalid severity: critical/)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "allows nil severity" do
|
|
39
|
+
expect { described_class.new(severity: nil, code: "T", message: "m") }
|
|
40
|
+
.not_to raise_error
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe "serialization" do
|
|
45
|
+
it "serializes to JSON" do
|
|
46
|
+
parsed = JSON.parse(issue.to_json)
|
|
47
|
+
expect(parsed["severity"]).to eq("error")
|
|
48
|
+
expect(parsed["code"]).to eq("TEST-001")
|
|
49
|
+
expect(parsed["message"]).to eq("Something is wrong")
|
|
50
|
+
expect(parsed["location"]).to eq("file.xml")
|
|
51
|
+
expect(parsed["line"]).to eq(42)
|
|
52
|
+
expect(parsed["suggestion"]).to eq("Fix it")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "round-trips through JSON" do
|
|
56
|
+
restored = described_class.from_json(issue.to_json)
|
|
57
|
+
expect(restored.code).to eq("TEST-001")
|
|
58
|
+
expect(restored.message).to eq("Something is wrong")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe "severity predicates" do
|
|
63
|
+
it "returns correct predicate for error" do
|
|
64
|
+
expect(issue).to be_error
|
|
65
|
+
expect(issue).not_to be_warning
|
|
66
|
+
expect(issue).not_to be_info
|
|
67
|
+
expect(issue).not_to be_notice
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "returns correct predicate for warning" do
|
|
71
|
+
warning = described_class.new(severity: "warning", code: "W",
|
|
72
|
+
message: "m")
|
|
73
|
+
expect(warning).to be_warning
|
|
74
|
+
expect(warning).not_to be_error
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/model/validation_framework"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Model::Validation::LayerResult do
|
|
7
|
+
subject(:layer) do
|
|
8
|
+
described_class.new(
|
|
9
|
+
name: "Structure",
|
|
10
|
+
status: "fail",
|
|
11
|
+
duration_ms: 15,
|
|
12
|
+
issues: [error_issue, warning_issue],
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
let(:error_issue) do
|
|
17
|
+
Lutaml::Model::Validation::Issue.new(
|
|
18
|
+
severity: "error", code: "E-001", message: "bad",
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
let(:warning_issue) do
|
|
23
|
+
Lutaml::Model::Validation::Issue.new(
|
|
24
|
+
severity: "warning", code: "W-001", message: "meh",
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "serializes to JSON" do
|
|
29
|
+
parsed = JSON.parse(layer.to_json)
|
|
30
|
+
expect(parsed["name"]).to eq("Structure")
|
|
31
|
+
expect(parsed["status"]).to eq("fail")
|
|
32
|
+
expect(parsed["issues"].length).to eq(2)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe "#pass? / #fail?" do
|
|
36
|
+
it "checks status" do
|
|
37
|
+
expect(layer).not_to be_pass
|
|
38
|
+
expect(layer).to be_fail
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "returns true for pass status" do
|
|
42
|
+
passing = described_class.new(name: "x", status: "pass")
|
|
43
|
+
expect(passing).to be_pass
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe "severity filtering via HasIssues" do
|
|
48
|
+
it "filters errors" do
|
|
49
|
+
expect(layer.errors.length).to eq(1)
|
|
50
|
+
expect(layer.errors.first.code).to eq("E-001")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "filters warnings" do
|
|
54
|
+
expect(layer.warnings.length).to eq(1)
|
|
55
|
+
expect(layer.warnings.first.code).to eq("W-001")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "returns empty for infos" do
|
|
59
|
+
expect(layer.infos).to be_empty
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "returns empty for notices" do
|
|
63
|
+
expect(layer.notices).to be_empty
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/model/validation_framework"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
RSpec.describe Lutaml::Model::Validation::Profile do
|
|
9
|
+
let(:registry) { Lutaml::Model::Validation.new_registry }
|
|
10
|
+
|
|
11
|
+
let(:profile) do
|
|
12
|
+
described_class.new(
|
|
13
|
+
name: "basic",
|
|
14
|
+
description: "Basic checks",
|
|
15
|
+
rule_names: ["TestRule"],
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "stores profile attributes" do
|
|
20
|
+
expect(profile.name).to eq("basic")
|
|
21
|
+
expect(profile.description).to eq("Basic checks")
|
|
22
|
+
expect(profile.rule_names).to eq(["TestRule"])
|
|
23
|
+
expect(profile.imports).to eq([])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe ".load" do
|
|
27
|
+
it "loads profile from YAML file" do
|
|
28
|
+
Dir.mktmpdir do |dir|
|
|
29
|
+
yaml_path = File.join(dir, "basic.yml")
|
|
30
|
+
File.write(yaml_path, YAML.dump({
|
|
31
|
+
"name" => "loaded",
|
|
32
|
+
"description" => "Loaded profile",
|
|
33
|
+
"rules" => ["RuleA", "RuleB"],
|
|
34
|
+
"import" => ["base"],
|
|
35
|
+
}))
|
|
36
|
+
loaded = described_class.load(yaml_path)
|
|
37
|
+
expect(loaded.name).to eq("loaded")
|
|
38
|
+
expect(loaded.rule_names).to eq(["RuleA", "RuleB"])
|
|
39
|
+
expect(loaded.imports).to eq(["base"])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "rejects YAML without a name key" do
|
|
44
|
+
Dir.mktmpdir do |dir|
|
|
45
|
+
yaml_path = File.join(dir, "bad.yml")
|
|
46
|
+
File.write(yaml_path, YAML.dump({ "description" => "no name" }))
|
|
47
|
+
expect { described_class.load(yaml_path) }
|
|
48
|
+
.to raise_error(ArgumentError, /must contain a 'name' string key/)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "rejects non-hash YAML content" do
|
|
53
|
+
Dir.mktmpdir do |dir|
|
|
54
|
+
yaml_path = File.join(dir, "array.yml")
|
|
55
|
+
File.write(yaml_path, YAML.dump(["just", "an", "array"]))
|
|
56
|
+
expect { described_class.load(yaml_path) }
|
|
57
|
+
.to raise_error(ArgumentError, /must contain a 'name' string key/)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe "#resolve" do
|
|
63
|
+
context "with imports" do
|
|
64
|
+
let(:base_rule_class) do
|
|
65
|
+
Class.new(Lutaml::Model::Validation::Rule) do
|
|
66
|
+
def self.name = "BaseRule"
|
|
67
|
+
def code = "BASE-001"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
let(:extra_rule_class) do
|
|
72
|
+
Class.new(Lutaml::Model::Validation::Rule) do
|
|
73
|
+
def self.name = "ExtraRule"
|
|
74
|
+
def code = "EXTRA-001"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
let(:base_profile) do
|
|
79
|
+
described_class.new(name: "base", rule_names: ["BaseRule"])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
let(:extended_profile) do
|
|
83
|
+
described_class.new(
|
|
84
|
+
name: "extended",
|
|
85
|
+
rule_names: ["ExtraRule"],
|
|
86
|
+
imports: ["base"],
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "resolves imports" do
|
|
91
|
+
registry.register(base_rule_class)
|
|
92
|
+
registry.register(extra_rule_class)
|
|
93
|
+
profiles = { "base" => base_profile, "extended" => extended_profile }
|
|
94
|
+
|
|
95
|
+
rules = extended_profile.resolve(registry, profiles)
|
|
96
|
+
codes = rules.map(&:code)
|
|
97
|
+
expect(codes).to include("BASE-001", "EXTRA-001")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "ignores missing imports" do
|
|
101
|
+
registry.register(extra_rule_class)
|
|
102
|
+
orphan = described_class.new(
|
|
103
|
+
name: "orphan",
|
|
104
|
+
rule_names: ["ExtraRule"],
|
|
105
|
+
imports: ["nonexistent"],
|
|
106
|
+
)
|
|
107
|
+
rules = orphan.resolve(registry, {})
|
|
108
|
+
expect(rules.map(&:code)).to eq(["EXTRA-001"])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "detects circular imports" do
|
|
112
|
+
a = described_class.new(name: "a", imports: ["b"])
|
|
113
|
+
b = described_class.new(name: "b", imports: ["a"])
|
|
114
|
+
profiles = { "a" => a, "b" => b }
|
|
115
|
+
expect { a.resolve(registry, profiles) }
|
|
116
|
+
.to raise_error(ArgumentError, /Circular profile import detected: a/)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "detects deep circular imports" do
|
|
120
|
+
a = described_class.new(name: "a", imports: ["b"])
|
|
121
|
+
b = described_class.new(name: "b", imports: ["c"])
|
|
122
|
+
c = described_class.new(name: "c", imports: ["a"])
|
|
123
|
+
profiles = { "a" => a, "b" => b, "c" => c }
|
|
124
|
+
expect { a.resolve(registry, profiles) }
|
|
125
|
+
.to raise_error(ArgumentError, /Circular profile import detected/)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "skips rules with unknown class names" do
|
|
130
|
+
result = profile.resolve(registry)
|
|
131
|
+
expect(result).to be_empty
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "lutaml/model/validation_framework"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Model::Validation::Registry do
|
|
7
|
+
subject(:registry) { described_class.new }
|
|
8
|
+
|
|
9
|
+
let(:rule_class) do
|
|
10
|
+
Class.new(Lutaml::Model::Validation::Rule) do
|
|
11
|
+
def code = "REG-001"
|
|
12
|
+
def category = :test
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
before { registry.register(rule_class) }
|
|
17
|
+
|
|
18
|
+
it "registers and returns rule instances" do
|
|
19
|
+
rules = registry.all
|
|
20
|
+
expect(rules.length).to eq(1)
|
|
21
|
+
expect(rules.first).to be_a(rule_class)
|
|
22
|
+
expect(rules.first.code).to eq("REG-001")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "caches rule instances" do
|
|
26
|
+
first_call = registry.all
|
|
27
|
+
second_call = registry.all
|
|
28
|
+
expect(first_call).to equal(second_call)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "invalidates cache on new registration" do
|
|
32
|
+
first = registry.all
|
|
33
|
+
new_rule = Class.new(Lutaml::Model::Validation::Rule) do
|
|
34
|
+
def code = "REG-002"
|
|
35
|
+
end
|
|
36
|
+
registry.register(new_rule)
|
|
37
|
+
expect(registry.all).not_to equal(first)
|
|
38
|
+
expect(registry.all.length).to eq(2)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "prevents duplicate registration" do
|
|
42
|
+
registry.register(rule_class)
|
|
43
|
+
expect(registry.size).to eq(1)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "filters by category" do
|
|
47
|
+
rules = registry.for_category(:test)
|
|
48
|
+
expect(rules.length).to eq(1)
|
|
49
|
+
expect(registry.for_category(:other)).to be_empty
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "finds by code" do
|
|
53
|
+
rule = registry.find("REG-001")
|
|
54
|
+
expect(rule).not_to be_nil
|
|
55
|
+
expect(rule.code).to eq("REG-001")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "returns nil for unknown code" do
|
|
59
|
+
expect(registry.find("UNKNOWN")).to be_nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "resets" do
|
|
63
|
+
registry.reset!
|
|
64
|
+
expect(registry.size).to eq(0)
|
|
65
|
+
expect(registry.all).to be_empty
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "returns rule classes" do
|
|
69
|
+
expect(registry.rule_classes).to eq([rule_class])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "returns a defensive copy of rule_classes" do
|
|
73
|
+
classes = registry.rule_classes
|
|
74
|
+
classes << String
|
|
75
|
+
expect(registry.rule_classes.length).to eq(1)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe "#auto_discover" do
|
|
79
|
+
it "requires rule files from a directory" do
|
|
80
|
+
Dir.mktmpdir do |dir|
|
|
81
|
+
rule_file = File.join(dir, "discovered_rule.rb")
|
|
82
|
+
File.write(rule_file, <<~RUBY)
|
|
83
|
+
class DiscoveredTestRule < Lutaml::Model::Validation::Rule
|
|
84
|
+
def code = "DISC-001"
|
|
85
|
+
end
|
|
86
|
+
RUBY
|
|
87
|
+
|
|
88
|
+
registry.auto_discover(dir, pattern: "*_rule.rb")
|
|
89
|
+
registry.register(DiscoveredTestRule)
|
|
90
|
+
expect(registry.find("DISC-001")).not_to be_nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|