moxml-testsuite 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c83c47e9b732b9b23aa8923894275f0b5e9820c050b56363a1713a4305bd0d77
4
+ data.tar.gz: 56443d6aaae65d2934e47c96903551ca474270bf5682cd23214e52908c1e6350
5
+ SHA512:
6
+ metadata.gz: 9b921670e6cd7d478782efd840b11167ff9c9130a39635313fea482383569606c3a108ebe7be19f1edcd69de9930e7e2ba61ecd29460af0fb3fbe30d646bf865
7
+ data.tar.gz: cf51812479aa486ae07c7ce65fa1407833ec1ac1c294c558a59997d72cc2b6f9cf5d89bf9fe2c36ab8f25c7a9588cac105af6bda9d8fb861c96994c131c34701
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ribose Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.adoc ADDED
@@ -0,0 +1,60 @@
1
+ = moxml-testsuite
2
+
3
+ image:https://img.shields.io/gem/v/moxml-testsuite.svg["Gem Version", link="https://rubygems.org/gems/moxml-testsuite"]
4
+
5
+ The moxml conformance test suite: certify any XML engine against the moxml model.
6
+
7
+ The moxml processing model (https://github.com/lutaml/moxml — `docs/_references/processing-model.adoc`) is a set of committed answers: one object model, one document order, one serialization shape, one canonicalization, one validation verdict — for every question an XML program can ask. The moxml conformance dataset (schema 5, 177 cases across seven families) IS those answers as data, and this gem is the runner that drives any engine through them.
8
+
9
+ == The seven families
10
+
11
+ [cols="1,2,3"]
12
+ |===
13
+ |Family |Cases |Facade faces
14
+
15
+ |builtin_lexical |81 |`lexical_valid?(type_name, lexical)`
16
+ |xsd_regex_translation |21 |`regex_translates?(pattern)`
17
+ |schema_wildcards |24 |`wildcard_covers?(...)`, `wildcard_exclusion?(...)`
18
+ |entity_registry |11 |`entity_declared?(...)` and the registry faces
19
+ |serialization |20 |`parse(xml)`, `serialize(doc)`
20
+ |c14n |14 |`canonicalize(doc)`
21
+ |xsd_structural |6 |`validate_xsd(schema, instance)`
22
+ |===
23
+
24
+ == Certifying an engine
25
+
26
+ The dataset ships vendored (`testdata/moxml-conformance.json`); the runner consumes rows and never restates them. Any object implementing the certification protocol can be certified:
27
+
28
+ [source, ruby]
29
+ ----
30
+ require "moxml/testsuite"
31
+
32
+ report = Moxml::TestSuite.run(Moxml::TestSuite::Reference.new)
33
+ puts report
34
+ report.passed? # => true
35
+ ----
36
+
37
+ `Reference` implements the whole protocol on moxml itself — pin the engine when the host carries several:
38
+
39
+ [source, ruby]
40
+ ----
41
+ Moxml::TestSuite::Reference.new(adapter: :leptris)
42
+ Moxml::TestSuite::Reference.new(adapter: :nokogiri)
43
+ ----
44
+
45
+ A facade that cannot answer a family's faces sees that family SKIP — loudly, in the report — never a quiet pass. A facade that answers against the dataset FAILs with the offending case named.
46
+
47
+ == The one-directional contract
48
+
49
+ The model's degradation is one-directional: an engine that cannot evaluate a construct stays silent (under-report); it never produces a different answer. The suite pins that too — unevaluatable rows expect under-reporting, and "answered wrong" and "answered differently per engine" are both failures. Equivalence is to the model, not merely engine-to-engine.
50
+
51
+ == Provenance
52
+
53
+ The dataset is generated from the moxml suite (`rake conformance:export` in lutaml/moxml) and consumed by the cross-language runners: moxml-rs (Rust) and moxml-ts (TypeScript). schema 5 is the row-shape version; `generator` names the producing moxml release.
54
+
55
+ == Development
56
+
57
+ ----
58
+ bundle install
59
+ bundle exec rake
60
+ ----
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Moxml
6
+ module TestSuite
7
+ # The vendored dataset: the model's committed answers, seven
8
+ # families, schema-versioned. The JSON is the single source -
9
+ # runners consume rows, never restate them.
10
+ module Dataset
11
+ FAMILIES = %w[
12
+ builtin_lexical
13
+ xsd_regex_translation
14
+ schema_wildcards
15
+ entity_registry
16
+ serialization
17
+ c14n
18
+ xsd_structural
19
+ ].freeze
20
+
21
+ DATA_PATH = File.join(__dir__, "../../../testdata/moxml-conformance.json")
22
+
23
+ def self.load(path: DATA_PATH)
24
+ raw = JSON.parse(File.read(path))
25
+ Dataset::V1.new(raw)
26
+ end
27
+
28
+ class V1
29
+ attr_reader :schema, :generator, :cases
30
+
31
+ def initialize(raw)
32
+ @schema = raw["schema"]
33
+ @generator = raw["generator"]
34
+ @cases = raw["cases"]
35
+ end
36
+
37
+ def rows(family)
38
+ @cases.fetch(family)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module TestSuite
5
+ module Families
6
+ # Family: c14n (14 rows). Canonical XML 1.0, byte-identical:
7
+ # sorted attributes, expanded references, comments dropped.
8
+ # Facade: parse(xml) -> doc; canonicalize(doc) -> string.
9
+ class C14n
10
+ def initialize(facade, dataset)
11
+ @facade = facade
12
+ @rows = dataset.rows("c14n")
13
+ end
14
+
15
+ def call(report)
16
+ unless @facade.respond_to?(:canonicalize)
17
+ return report.skip("c14n", "facade does not implement canonicalize")
18
+ end
19
+
20
+ failures = []
21
+ @rows.each do |row|
22
+ doc = @facade.parse(row["input"])
23
+ out = @facade.canonicalize(doc)
24
+ if out != row["canonical"]
25
+ failures << format("%s: got %s, want %s",
26
+ row["id"], out.inspect,
27
+ row["canonical"].inspect)
28
+ end
29
+ rescue StandardError => e
30
+ failures << format("%s: raised %s: %s", row["id"], e.class,
31
+ e.message[0, 120])
32
+ end
33
+ if failures.empty?
34
+ report.pass("c14n", @rows.length)
35
+ else
36
+ report.fail("c14n", failures, @rows.length)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module TestSuite
5
+ module Families
6
+ # Families: the pure-logic decision faces (the model's
7
+ # committed answers as functions).
8
+ #
9
+ # builtin_lexical (81): lexical_valid?(type_name, lexical)
10
+ # schema_wildcards (24): the pinned covers/exclusion protocol -
11
+ # wildcard_covers?(constraint:, process:, not_namespace:,
12
+ # not_qname:, item_namespace:) -> bool
13
+ # wildcard_exclusion?(constraint:, process:, not_namespace:,
14
+ # not_qname:, item_namespace:, item_name:,
15
+ # defined:, sibling_names:) -> String | nil
16
+ # (the dataset's committed shape: a row answers covers? plus
17
+ # the negative-exclusion symbol; there is no single "admits")
18
+ # xsd_regex_translation (21): regex_translates?(pattern) ->
19
+ # regexp | nil (nil = untranslatable per the model).
20
+ class Decisions
21
+ def initialize(facade, dataset)
22
+ @facade = facade
23
+ @dataset = dataset
24
+ end
25
+
26
+ DECISION_FACES = %i[
27
+ lexical_valid? wildcard_covers? regex_translates?
28
+ ].freeze
29
+
30
+ def call(report)
31
+ if DECISION_FACES.none? { |face| @facade.respond_to?(face) }
32
+ return report.skip("decisions",
33
+ "facade does not implement the decision faces")
34
+ end
35
+
36
+ failures = []
37
+ failures += run_lexical
38
+ failures += run_wildcards
39
+ failures += run_regex
40
+ total = @dataset.rows("builtin_lexical").length +
41
+ @dataset.rows("schema_wildcards").length +
42
+ @dataset.rows("xsd_regex_translation").length
43
+ if failures.empty?
44
+ report.pass("decisions", total)
45
+ else
46
+ report.fail("decisions", failures, total)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def run_lexical
53
+ return [] unless @facade.respond_to?(:lexical_valid?)
54
+
55
+ @dataset.rows("builtin_lexical").filter_map do |row|
56
+ next if @facade.lexical_valid?(row["type"],
57
+ row["lexical"]) == row["valid"]
58
+
59
+ format("lexical %s %s: got %s, want %s",
60
+ row["type"], row["lexical"].inspect,
61
+ !row["valid"], row["valid"])
62
+ end
63
+ rescue NoMethodError, ArgumentError => e
64
+ ["lexical face unavailable: #{e.class}: #{e.message[0, 80]}"]
65
+ end
66
+
67
+ def run_wildcards
68
+ return [] unless @facade.respond_to?(:wildcard_covers?)
69
+
70
+ @dataset.rows("schema_wildcards").filter_map do |row|
71
+ common = {
72
+ constraint: row["constraint"], process: row["process"],
73
+ not_namespace: row["not_namespace"],
74
+ not_qname: Array(row["not_qname"]),
75
+ item_namespace: row["item_namespace"]
76
+ }
77
+ covers = @facade.wildcard_covers?(**common)
78
+ unless covers == row["covers"]
79
+ next format("wildcard %s/%s covers?(%s): got %s, want %s",
80
+ row["constraint"], row["process"],
81
+ row["item_qname"], covers, row["covers"])
82
+ end
83
+
84
+ exclusion = @facade.wildcard_exclusion?(
85
+ **common, item_name: row["item_qname"],
86
+ defined: row["defined"],
87
+ sibling_names: Array(row["sibling_names"])
88
+ )
89
+ want = row["exclusion"]
90
+ next if exclusion.to_s == want.to_s
91
+
92
+ format("wildcard %s/%s exclusion(%s): got %s, want %s",
93
+ row["constraint"], row["process"], row["item_qname"],
94
+ exclusion.inspect, want.inspect)
95
+ end
96
+ rescue NoMethodError, ArgumentError => e
97
+ ["wildcard face unavailable: #{e.class}: #{e.message[0, 80]}"]
98
+ end
99
+
100
+ def run_regex
101
+ return [] unless @facade.respond_to?(:regex_translates?)
102
+
103
+ @dataset.rows("xsd_regex_translation").filter_map do |row|
104
+ compiled = @facade.regex_translates?(row["pattern"])
105
+ next if compiled.nil? != row["translatable"]
106
+
107
+ next unless row["translatable"] &&
108
+ compiled.match?(row["value"]) != row["matches"]
109
+
110
+ format("regex %s %s: matched %s, want %s",
111
+ row["pattern"].inspect, row["value"].inspect,
112
+ compiled.match?(row["value"]), row["matches"])
113
+ end
114
+ rescue NoMethodError, ArgumentError => e
115
+ ["regex face unavailable: #{e.class}: #{e.message[0, 80]}"]
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module TestSuite
5
+ module Families
6
+ # Family: entity_registry (11 rows). The registry contract:
7
+ # lookup (declared?, codepoint_for_name, names_for_codepoint,
8
+ # primary_name_for_codepoint, standard_entity?) and restoration
9
+ # (should_restore? under a restoration mode). Facade faces are
10
+ # seed-scoped - every call carries the seed map so engines stay
11
+ # stateless across rows:
12
+ #
13
+ # entity_declared?(seed:, name:)
14
+ # entity_codepoint(seed:, name:)
15
+ # entity_names(seed:, codepoint:)
16
+ # entity_primary_name(seed:, codepoint:)
17
+ # entity_standard?(seed:, codepoint:)
18
+ # entity_should_restore?(seed:, codepoint:, restore_entities:,
19
+ # restoration_mode:)
20
+ class EntityRegistry
21
+ def initialize(facade, dataset)
22
+ @facade = facade
23
+ @rows = dataset.rows("entity_registry")
24
+ end
25
+
26
+ def call(report)
27
+ unless @facade.respond_to?(:entity_declared?)
28
+ return report.skip("entity_registry",
29
+ "facade does not implement the registry faces")
30
+ end
31
+
32
+ failures = []
33
+ @rows.each do |row|
34
+ failures.concat(check_row(row))
35
+ end
36
+ if failures.empty?
37
+ report.pass("entity_registry", @rows.length)
38
+ else
39
+ report.fail("entity_registry", failures,
40
+ @rows.length)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def check_row(row)
47
+ seed = row["seed"]
48
+ label = "#{row['qname'].inspect}/#{row['qcp']} seed=#{seed.inspect}"
49
+ [
50
+ [row["declared"],
51
+ @facade.entity_declared?(seed: seed, name: row["qname"])],
52
+ [row["codepoint"],
53
+ @facade.entity_codepoint(seed: seed, name: row["qname"])],
54
+ [row["names"],
55
+ @facade.entity_names(seed: seed, codepoint: row["qcp"])],
56
+ [row["primary"],
57
+ @facade.entity_primary_name(seed: seed, codepoint: row["qcp"])],
58
+ [row["standard"],
59
+ @facade.entity_standard?(seed: seed, codepoint: row["qcp"])],
60
+ [row["restore"],
61
+ @facade.entity_should_restore?(
62
+ seed: seed, codepoint: row["qcp"],
63
+ restore_entities: row["restore_entities"],
64
+ restoration_mode: row["mode"]
65
+ )],
66
+ ].filter_map do |want, got|
67
+ next if want == got
68
+
69
+ format("%s: got %s, want %s", label, got.inspect, want.inspect)
70
+ end
71
+ rescue StandardError => e
72
+ [format("%s: raised %s: %s", label, e.class, e.message[0, 120])]
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module TestSuite
5
+ module Families
6
+ # Family: serialization (20 rows). The emitter contract:
7
+ # long-form empty elements, the attribute escaping shapes
8
+ # (named + numeric references), namespace declarations
9
+ # verbatim. Facade: parse(xml) -> doc; serialize(doc) -> string.
10
+ class Serialization
11
+ def initialize(facade, dataset)
12
+ @facade = facade
13
+ @rows = dataset.rows("serialization")
14
+ end
15
+
16
+ def call(report)
17
+ failures = []
18
+ @rows.each do |row|
19
+ doc = @facade.parse(row["input"])
20
+ out = @facade.serialize(doc)
21
+ # A single trailing document newline is formatting, not a
22
+ # committed emitter shape - the dataset rows are chomped.
23
+ if out&.chomp != row["output"]
24
+ failures << format("%s: got %s, want %s",
25
+ row["id"], out.inspect, row["output"].inspect)
26
+ end
27
+ rescue StandardError => e
28
+ failures << format("%s: raised %s: %s", row["id"], e.class,
29
+ e.message[0, 120])
30
+ end
31
+ if failures.empty?
32
+ report.pass("serialization", @rows.length)
33
+ else
34
+ report.fail("serialization", failures, @rows.length)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module TestSuite
5
+ module Families
6
+ # Family: xsd_structural (6 rows). Schema + instance pairs
7
+ # with the committed verdict. Facade: parse(xml);
8
+ # validate_xsd(schema_xml, instance) -> [] (valid) | [#to_s].
9
+ class XsdStructural
10
+ def initialize(facade, dataset)
11
+ @facade = facade
12
+ @rows = dataset.rows("xsd_structural")
13
+ end
14
+
15
+ def call(report)
16
+ unless @facade.respond_to?(:validate_xsd)
17
+ return report.skip("xsd_structural",
18
+ "facade does not implement validate_xsd")
19
+ end
20
+
21
+ failures = []
22
+ @rows.each do |row|
23
+ errs = @facade.validate_xsd(row["schema"], row["instance"])
24
+ valid = errs.nil? || errs.empty?
25
+ expected = row["valid"]
26
+ if valid != expected
27
+ failures << format("%s: expected %s, engine said %s (%s)",
28
+ row["id"], expected, valid,
29
+ Array(errs).first || "no rows")
30
+ end
31
+ rescue StandardError => e
32
+ failures << format("%s: raised %s: %s", row["id"], e.class,
33
+ e.message[0, 120])
34
+ end
35
+ if failures.empty?
36
+ report.pass("xsd_structural", @rows.length)
37
+ else
38
+ report.fail("xsd_structural", failures,
39
+ @rows.length)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module TestSuite
5
+ # The moxml-backed reference facade: every certification face
6
+ # answered through moxml itself. Any moxml install certifies its
7
+ # own engine with it:
8
+ #
9
+ # report = Moxml::TestSuite.run(Moxml::TestSuite::Reference.new)
10
+ # puts report
11
+ # report.passed? # => true
12
+ #
13
+ # Pin the engine explicitly when the host carries several:
14
+ #
15
+ # Moxml::TestSuite::Reference.new(adapter: :leptris)
16
+ #
17
+ # The decision faces (lexical, wildcard, regex, entity registry)
18
+ # are engine-free - moxml answers them in pure Ruby, the model's
19
+ # committed answers. The DOM faces (parse, serialize, canonicalize,
20
+ # validate_xsd) run on the selected adapter: that is the point -
21
+ # the suite certifies the ENGINE's observable behavior against
22
+ # the model.
23
+ class Reference
24
+ def initialize(adapter: nil)
25
+ require "moxml"
26
+ @context = adapter ? Moxml.new(adapter) : Moxml.new
27
+ # The dataset pins the model's committed emitter shape:
28
+ # compact, no inter-element indentation.
29
+ @context.config.default_indent = 0
30
+ end
31
+
32
+ attr_reader :context
33
+
34
+ # -- DOM faces -------------------------------------------------
35
+
36
+ def parse(xml)
37
+ @context.parse(xml)
38
+ end
39
+
40
+ def serialize(doc)
41
+ doc.to_xml
42
+ end
43
+
44
+ def canonicalize(doc)
45
+ Moxml::C14n.canonicalize(doc)
46
+ end
47
+
48
+ def validate_xsd(schema_xml, instance)
49
+ @context.xsd(schema_xml).validate(instance)
50
+ end
51
+
52
+ # -- decision faces (engine-free) ------------------------------
53
+
54
+ def lexical_valid?(type_name, lexical)
55
+ Moxml::XSD::BuiltinLexical.valid?(type_name, lexical)
56
+ end
57
+
58
+ def regex_translates?(pattern)
59
+ Moxml::XSD::SchemaRules::XsdRegex.translate(pattern)
60
+ end
61
+
62
+ def wildcard_covers?(constraint:, process:, not_namespace:,
63
+ not_qname:, item_namespace:)
64
+ wildcard(constraint, process, not_namespace, not_qname)
65
+ .covers?(item_namespace.to_sym)
66
+ end
67
+
68
+ def wildcard_exclusion?(constraint:, process:, not_namespace:,
69
+ not_qname:, item_namespace:, item_name:,
70
+ defined:, sibling_names:)
71
+ answer = wildcard(constraint, process, not_namespace, not_qname)
72
+ .negative_exclusion(item_name, item_namespace.to_sym,
73
+ defined: defined,
74
+ sibling_names: sibling_names)
75
+ answer&.to_s
76
+ end
77
+
78
+ def entity_declared?(seed:, name:)
79
+ registry(seed).declared?(name)
80
+ end
81
+
82
+ def entity_codepoint(seed:, name:)
83
+ registry(seed).codepoint_for_name(name)
84
+ end
85
+
86
+ def entity_names(seed:, codepoint:)
87
+ registry(seed).names_for_codepoint(codepoint)
88
+ end
89
+
90
+ def entity_primary_name(seed:, codepoint:)
91
+ registry(seed).primary_name_for_codepoint(codepoint)
92
+ end
93
+
94
+ def entity_standard?(seed:, codepoint:)
95
+ registry(seed).standard_entity?(codepoint)
96
+ end
97
+
98
+ def entity_should_restore?(seed:, codepoint:, restore_entities:,
99
+ restoration_mode:)
100
+ registry(seed).should_restore?(
101
+ codepoint, config: restoration_config(restore_entities,
102
+ restoration_mode)
103
+ )
104
+ end
105
+
106
+ private
107
+
108
+ def wildcard(constraint, process, not_namespace, not_qname)
109
+ Moxml::XSD::SchemaRules::Wildcard.new(
110
+ constraint, process,
111
+ not_namespace: not_namespace,
112
+ not_qname: Array(not_qname).join(" "),
113
+ element: nil
114
+ )
115
+ end
116
+
117
+ def registry(seed)
118
+ Moxml::EntityRegistry.new(mode: :custom, entity_provider: -> { seed })
119
+ end
120
+
121
+ def restoration_config(restore_entities, restoration_mode)
122
+ config = Moxml::Config.new
123
+ config.restore_entities = restore_entities
124
+ config.entity_restoration_mode = restoration_mode.to_sym
125
+ config
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module TestSuite
5
+ # The certification outcome: per-family pass/fail/skip with the
6
+ # failing case ids. Truthy: report.passed? after run.
7
+ class Report
8
+ attr_reader :facade_class, :results
9
+
10
+ def initialize(facade_class:)
11
+ @facade_class = facade_class
12
+ @results = {}
13
+ end
14
+
15
+ def pass(family, count)
16
+ @results[family] = { status: :pass, count: count, failures: [] }
17
+ end
18
+
19
+ def fail(family, failures, count)
20
+ @results[family] = { status: :fail, count: count, failures: failures }
21
+ end
22
+
23
+ def skip(family, reason)
24
+ @results[family] = { status: :skip, reason: reason }
25
+ end
26
+
27
+ def passed?
28
+ @results.values.all? { |r| r[:status] != :fail }
29
+ end
30
+
31
+ def failures
32
+ @results.select { |_, r| r[:status] == :fail }
33
+ end
34
+
35
+ def summary
36
+ lines = ["moxml-testsuite (facade: #{@facade_class})"]
37
+ @results.each do |family, r|
38
+ case r[:status]
39
+ when :pass then lines << " PASS #{family} (#{r[:count]} cases)"
40
+ when :fail
41
+ lines << " FAIL #{family} " \
42
+ "(#{r[:failures].length}/#{r[:count]} cases):"
43
+ r[:failures].each { |f| lines << " #{f}" }
44
+ when :skip then lines << " SKIP #{family} (#{r[:reason]})"
45
+ end
46
+ end
47
+ lines.join("\n")
48
+ end
49
+
50
+ def to_s
51
+ summary
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module TestSuite
5
+ VERSION = "0.1.0"
6
+ end
7
+ end