diff --git a/CHANGELOG.md b/CHANGELOG.md index 2637740..1ea700e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,12 @@ The repository ships two NuGet packages that version independently: **ReqIFSharp ### Changed - A `REQ-IF` root element in a namespace other than the ReqIF namespace (`http://www.omg.org/spec/ReqIF/20110401/reqif.xsd`) is no longer read as a ReqIF document; it is skipped and a warning is logged. Before, such an element was read when it had no prefix. A root in no namespace is still read ([#87](https://github.com/STARIONGROUP/ReqIFSharp/issues/87)). +- An element other than `SPEC-RELATION-REF` inside the `SPEC-RELATIONS` of a relation group is now skipped with a warning, and the references after it are read. Before, it caused an `XmlException` ([#94](https://github.com/STARIONGROUP/ReqIFSharp/issues/94)). ### Fixed - A document that binds the ReqIF namespace to a prefix (``) was read as zero documents, and type, source/target and relation-group references in it were not read. Elements are now matched on their local name and namespace ([#87](https://github.com/STARIONGROUP/ReqIFSharp/issues/87)). +- A document without whitespace between its elements (minified XML) lost data without an error: every other header field (such as `CREATION-TIME` and `REQ-IF-TOOL-ID`) and every other enumeration value of an attribute value was skipped, and the spec relations of a relation group were not read or caused an `XmlException` ([#94](https://github.com/STARIONGROUP/ReqIFSharp/issues/94)). ## [10.1.2] - 2026-10-18 diff --git a/ReqIFSharp.Tests/MinifiedDocumentTestFixture.cs b/ReqIFSharp.Tests/MinifiedDocumentTestFixture.cs new file mode 100644 index 0000000..4ee5856 --- /dev/null +++ b/ReqIFSharp.Tests/MinifiedDocumentTestFixture.cs @@ -0,0 +1,373 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2017-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ReqIFSharp.Tests +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using System.Xml; + using System.Xml.Linq; + + using Microsoft.Extensions.Logging; + + using NUnit.Framework; + + using ReqIFSharp; + + using Serilog; + using Serilog.Events; + + /// + /// Verifies that a document without whitespace between its elements (minified XML) is read + /// the same as the indented original, see GH-94. + /// + [TestFixture] + public class MinifiedDocumentTestFixture + { + private const string FirstSpecRelationIdentifier = "specobject_1-specobject_2"; + + private const string SecondSpecRelationIdentifier = "second-spec-relation"; + + private const string MinifiedHeaderDocument = + "" + + "" + + "a comment" + + "2017-03-13T10:15:09.017+01:00" + + "a repository" + + "a tool" + + "1.0" + + "a source tool" + + "a title" + + "" + + "" + + ""; + + private const string MinifiedHeaderDocumentWithEmptyElements = + "" + + "" + + "" + + "2017-03-13T10:15:09.017+01:00" + + "" + + "a tool" + + "1.0" + + "a source tool" + + "a title" + + "" + + "" + + ""; + + [Test] + public void Verify_that_every_header_field_of_a_minified_document_is_read() + { + var reqif = new ReqIFDeserializer().Deserialize(TestDocuments.ToStream(MinifiedHeaderDocument, SupportedFileExtensionKind.Reqif), SupportedFileExtensionKind.Reqif).Single(); + + AssertHeader(reqif.TheHeader); + } + + [Test] + public async Task Verify_that_every_header_field_of_a_minified_document_is_read_async() + { + var reqif = (await new ReqIFDeserializer().DeserializeAsync(TestDocuments.ToStream(MinifiedHeaderDocument, SupportedFileExtensionKind.Reqif), SupportedFileExtensionKind.Reqif, CancellationToken.None)).Single(); + + AssertHeader(reqif.TheHeader); + } + + [TestCaseSource(typeof(TestDocuments), nameof(TestDocuments.ComparableFiles))] + public void Verify_that_a_minified_document_is_read_the_same_as_the_original(string fileName) + { + var original = TestDocuments.Read(fileName); + var minified = CreateMinifiedCopy(original); + + foreach (var kind in TestDocuments.Kinds) + { + var deserializer = new ReqIFDeserializer(); + + var expected = deserializer.Deserialize(TestDocuments.ToStream(original, kind), kind).ToList(); + var actual = deserializer.Deserialize(TestDocuments.ToStream(minified, kind), kind).ToList(); + + TestDocuments.AssertSameGraph(expected, actual); + } + } + + [TestCaseSource(typeof(TestDocuments), nameof(TestDocuments.ComparableFiles))] + public async Task Verify_that_a_minified_document_is_read_the_same_as_the_original_async(string fileName) + { + var original = TestDocuments.Read(fileName); + var minified = CreateMinifiedCopy(original); + + foreach (var kind in TestDocuments.Kinds) + { + var deserializer = new ReqIFDeserializer(); + + var expected = (await deserializer.DeserializeAsync(TestDocuments.ToStream(original, kind), kind, CancellationToken.None)).ToList(); + var actual = (await deserializer.DeserializeAsync(TestDocuments.ToStream(minified, kind), kind, CancellationToken.None)).ToList(); + + TestDocuments.AssertSameGraph(expected, actual); + } + } + + [Test] + public void Verify_that_the_spec_relations_of_a_relation_group_in_a_minified_document_are_read() + { + var original = CreateDocumentWithSpecRelationsInRelationGroup(); + var minified = CreateMinifiedCopy(original); + + foreach (var kind in TestDocuments.Kinds) + { + var deserializer = new ReqIFDeserializer(); + + var expected = deserializer.Deserialize(TestDocuments.ToStream(original, kind), kind).ToList(); + var actual = deserializer.Deserialize(TestDocuments.ToStream(minified, kind), kind).ToList(); + + Assert.That(expected[0].CoreContent.SpecRelationGroups.Single().SpecRelations, Has.Count.EqualTo(2), "the original must be read with both spec relations"); + + TestDocuments.AssertSameGraph(expected, actual); + } + } + + [Test] + public async Task Verify_that_the_spec_relations_of_a_relation_group_in_a_minified_document_are_read_async() + { + var original = CreateDocumentWithSpecRelationsInRelationGroup(); + var minified = CreateMinifiedCopy(original); + + foreach (var kind in TestDocuments.Kinds) + { + var deserializer = new ReqIFDeserializer(); + + var expected = (await deserializer.DeserializeAsync(TestDocuments.ToStream(original, kind), kind, CancellationToken.None)).ToList(); + var actual = (await deserializer.DeserializeAsync(TestDocuments.ToStream(minified, kind), kind, CancellationToken.None)).ToList(); + + Assert.That(expected[0].CoreContent.SpecRelationGroups.Single().SpecRelations, Has.Count.EqualTo(2), "the original must be read with both spec relations"); + + TestDocuments.AssertSameGraph(expected, actual); + } + } + + [TestCase(false)] + [TestCase(true)] + public async Task Verify_that_the_header_fields_after_an_empty_header_field_are_read(bool async) + { + var header = (await Deserialize(MinifiedHeaderDocumentWithEmptyElements, SupportedFileExtensionKind.Reqif, async)).Single().TheHeader; + + Assert.Multiple(() => + { + Assert.That(header.Comment, Is.Empty); + Assert.That(header.CreationTime, Is.EqualTo(XmlConvert.ToDateTime("2017-03-13T10:15:09.017+01:00", XmlDateTimeSerializationMode.RoundtripKind))); + Assert.That(header.RepositoryId, Is.Empty); + Assert.That(header.ReqIFToolId, Is.EqualTo("a tool")); + Assert.That(header.ReqIFVersion, Is.EqualTo("1.0")); + Assert.That(header.SourceToolId, Is.EqualTo("a source tool")); + Assert.That(header.Title, Is.EqualTo("a title")); + }); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Verify_that_the_elements_after_the_spec_relations_of_a_relation_group_are_read(bool async) + { + var original = CreateDocumentWithSpecRelationsInRelationGroup(insertBefore: "TYPE"); + + foreach (var xml in new[] { original, CreateMinifiedCopy(original) }) + { + foreach (var kind in TestDocuments.Kinds) + { + var relationGroup = (await Deserialize(xml, kind, async)).Single().CoreContent.SpecRelationGroups.Single(); + + Assert.Multiple(() => + { + Assert.That(relationGroup.SpecRelations.Select(x => x.Identifier), Is.EqualTo(new[] { FirstSpecRelationIdentifier, SecondSpecRelationIdentifier })); + Assert.That(relationGroup.Type?.Identifier, Is.EqualTo("relationgrouptype")); + Assert.That(relationGroup.SourceSpecification?.Identifier, Is.EqualTo("specification-1")); + Assert.That(relationGroup.TargetSpecification?.Identifier, Is.EqualTo("specification-2")); + }); + } + } + } + + [TestCase(false)] + [TestCase(true)] + public async Task Verify_that_an_empty_spec_relations_element_of_a_relation_group_is_read(bool async) + { + var original = CreateDocumentWithSpecRelationsInRelationGroup(insertBefore: "TARGET-SPECIFICATION", empty: true); + + Assert.That(original, Does.Contain(""), "the document must contain an empty SPEC-RELATIONS element"); + + foreach (var xml in new[] { original, CreateMinifiedCopy(original) }) + { + foreach (var kind in TestDocuments.Kinds) + { + var relationGroup = (await Deserialize(xml, kind, async)).Single().CoreContent.SpecRelationGroups.Single(); + + Assert.Multiple(() => + { + Assert.That(relationGroup.SpecRelations, Is.Empty); + Assert.That(relationGroup.SourceSpecification?.Identifier, Is.EqualTo("specification-1")); + Assert.That(relationGroup.TargetSpecification?.Identifier, Is.EqualTo("specification-2")); + }); + } + } + } + + [TestCase(false)] + [TestCase(true)] + public async Task Verify_that_an_unsupported_child_of_spec_relations_is_skipped_with_a_warning(bool async) + { + var ns = TestDocuments.ReqIFNamespace; + + // the SPEC-RELATION-REF inside the unsupported element is not a direct child of SPEC-RELATIONS and must not be read + var unsupported = new XElement(ns + "SPEC-OBJECT-REF", new XElement(ns + "SPEC-RELATION-REF", FirstSpecRelationIdentifier)); + + var original = CreateDocumentWithSpecRelationsInRelationGroup(insertBefore: "TYPE", unsupportedChild: unsupported); + + foreach (var xml in new[] { original, CreateMinifiedCopy(original) }) + { + foreach (var kind in TestDocuments.Kinds) + { + var testLogEventSink = new TestLogEventSink(); + var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(testLogEventSink).CreateLogger(); + + using var loggerFactory = LoggerFactory.Create(builder => builder.AddSerilog(logger, dispose: true)); + + var relationGroup = (await Deserialize(xml, kind, async, loggerFactory)).Single().CoreContent.SpecRelationGroups.Single(); + + var warnings = testLogEventSink.Events + .Where(e => e.Level == LogEventLevel.Warning && e.MessageTemplate.Text.Contains("is not supported in SPEC-RELATIONS")) + .Select(e => e.RenderMessage()) + .ToList(); + + Assert.Multiple(() => + { + Assert.That(relationGroup.SpecRelations.Select(x => x.Identifier), Is.EqualTo(new[] { FirstSpecRelationIdentifier, SecondSpecRelationIdentifier })); + Assert.That(relationGroup.Type?.Identifier, Is.EqualTo("relationgrouptype")); + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings.FirstOrDefault(), Does.Contain("SPEC-OBJECT-REF").And.Contain("relationgroup-1")); + }); + } + } + } + + private static async Task> Deserialize(string xml, SupportedFileExtensionKind kind, bool async, ILoggerFactory loggerFactory = null) + { + var deserializer = new ReqIFDeserializer(loggerFactory); + var stream = TestDocuments.ToStream(xml, kind); + + return async + ? (await deserializer.DeserializeAsync(stream, kind, CancellationToken.None)).ToList() + : deserializer.Deserialize(stream, kind).ToList(); + } + + /// + /// None of the test documents has spec relations in a relation group: this returns reqifsharpgenerated.reqif + /// with a second spec relation and a relation group that refers to both, indented. + /// + /// + /// The local name of the relation group child before which SPEC-RELATIONS is inserted; null to add it as the last child + /// + /// + /// true to write an empty SPEC-RELATIONS element, without references + /// + /// + /// An element that is placed between the two references, or null + /// + private static string CreateDocumentWithSpecRelationsInRelationGroup(string insertBefore = null, bool empty = false, XElement unsupportedChild = null) + { + var ns = TestDocuments.ReqIFNamespace; + + var document = XDocument.Parse(TestDocuments.Read("reqifsharpgenerated.reqif")); + + var specRelation = document.Descendants(ns + "SPEC-RELATION").Single(); + Assert.That(specRelation.Attribute("IDENTIFIER")!.Value, Is.EqualTo(FirstSpecRelationIdentifier)); + + var copy = new XElement(specRelation); + copy.SetAttributeValue("IDENTIFIER", SecondSpecRelationIdentifier); + specRelation.AddAfterSelf(copy); + + var specRelations = new XElement(ns + "SPEC-RELATIONS"); + + if (!empty) + { + specRelations.Add( + new XElement(ns + "SPEC-RELATION-REF", FirstSpecRelationIdentifier), + unsupportedChild, + new XElement(ns + "SPEC-RELATION-REF", SecondSpecRelationIdentifier)); + } + + var relationGroup = document.Descendants(ns + "RELATION-GROUP").Single(); + + if (insertBefore == null) + { + relationGroup.Add(specRelations); + } + else + { + relationGroup.Element(ns + insertBefore)!.AddBeforeSelf(specRelations); + } + + return document.ToString(); + } + + private static void AssertHeader(ReqIFHeader header) + { + Assert.Multiple(() => + { + Assert.That(header.Identifier, Is.EqualTo("header")); + Assert.That(header.Comment, Is.EqualTo("a comment")); + Assert.That(header.CreationTime, Is.EqualTo(XmlConvert.ToDateTime("2017-03-13T10:15:09.017+01:00", XmlDateTimeSerializationMode.RoundtripKind))); + Assert.That(header.RepositoryId, Is.EqualTo("a repository")); + Assert.That(header.ReqIFToolId, Is.EqualTo("a tool")); + Assert.That(header.ReqIFVersion, Is.EqualTo("1.0")); + Assert.That(header.SourceToolId, Is.EqualTo("a source tool")); + Assert.That(header.Title, Is.EqualTo("a title")); + }); + } + + /// + /// Returns a copy of the document without the whitespace between its ReqIF elements. Whitespace inside + /// XHTML values and tool extensions is kept: it is content, read as raw XML. + /// + private static string CreateMinifiedCopy(string original) + { + var document = XDocument.Parse(original, LoadOptions.PreserveWhitespace); + + var whitespace = document.DescendantNodes() + .OfType() + .Where(t => string.IsNullOrWhiteSpace(t.Value) && !IsRawXmlContent(t)) + .ToList(); + + Assert.That(whitespace, Is.Not.Empty, "the original must contain whitespace between elements"); + + whitespace.ForEach(t => t.Remove()); + + return document.ToString(SaveOptions.DisableFormatting); + } + + private static bool IsRawXmlContent(XText text) + { + return text.Ancestors().Any(a => + a.Name.Namespace != TestDocuments.ReqIFNamespace + || a.Name.LocalName == "THE-VALUE" + || a.Name.LocalName == "REQ-IF-TOOL-EXTENSION"); + } + } +} diff --git a/ReqIFSharp.Tests/PrefixedNamespaceTestFixture.cs b/ReqIFSharp.Tests/PrefixedNamespaceTestFixture.cs index cf2c594..6bf0da7 100644 --- a/ReqIFSharp.Tests/PrefixedNamespaceTestFixture.cs +++ b/ReqIFSharp.Tests/PrefixedNamespaceTestFixture.cs @@ -20,11 +20,7 @@ namespace ReqIFSharp.Tests { - using System.Collections.Generic; - using System.IO; - using System.IO.Compression; using System.Linq; - using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; @@ -46,26 +42,12 @@ namespace ReqIFSharp.Tests [TestFixture] public class PrefixedNamespaceTestFixture { - private static readonly XNamespace ReqIFNamespace = DefaultXmlAttributeFactory.ReqIFSchemaUri; - - // output.reqif and testreqif.reqif are left out: they contain unresolved type references, - // so their object graphs cannot be serialized for the comparison - private static readonly string[] TestFiles = - { - "Datatype-Demo.reqif", - "DefaultValueDemo.reqif", - "ProR_Traceability-Template-v1.0.reqif", - "reqifsharpgenerated.reqif" - }; - private static readonly string[] ForeignNamespaceDocuments = { "", "" }; - private static readonly SupportedFileExtensionKind[] Kinds = { SupportedFileExtensionKind.Reqif, SupportedFileExtensionKind.Reqifz }; - private TestLogEventSink testLogEventSink; private ILoggerFactory loggerFactory; @@ -89,71 +71,71 @@ public void TearDown() this.loggerFactory.Dispose(); } - [TestCaseSource(nameof(TestFiles))] + [TestCaseSource(typeof(TestDocuments), nameof(TestDocuments.ComparableFiles))] public void Verify_that_a_prefixed_document_is_read_the_same_as_the_original(string fileName) { - var original = ReadTestFile(fileName); + var original = TestDocuments.Read(fileName); var prefixed = CreatePrefixedCopy(original); - foreach (var kind in Kinds) + foreach (var kind in TestDocuments.Kinds) { var deserializer = new ReqIFDeserializer(); - var expected = deserializer.Deserialize(ToStream(original, kind), kind).ToList(); - var actual = deserializer.Deserialize(ToStream(prefixed, kind), kind).ToList(); + var expected = deserializer.Deserialize(TestDocuments.ToStream(original, kind), kind).ToList(); + var actual = deserializer.Deserialize(TestDocuments.ToStream(prefixed, kind), kind).ToList(); - AssertSameGraph(expected, actual); + TestDocuments.AssertSameGraph(expected, actual); } } - [TestCaseSource(nameof(TestFiles))] + [TestCaseSource(typeof(TestDocuments), nameof(TestDocuments.ComparableFiles))] public async Task Verify_that_a_prefixed_document_is_read_the_same_as_the_original_async(string fileName) { - var original = ReadTestFile(fileName); + var original = TestDocuments.Read(fileName); var prefixed = CreatePrefixedCopy(original); - foreach (var kind in Kinds) + foreach (var kind in TestDocuments.Kinds) { var deserializer = new ReqIFDeserializer(); - var expected = (await deserializer.DeserializeAsync(ToStream(original, kind), kind, CancellationToken.None)).ToList(); - var actual = (await deserializer.DeserializeAsync(ToStream(prefixed, kind), kind, CancellationToken.None)).ToList(); + var expected = (await deserializer.DeserializeAsync(TestDocuments.ToStream(original, kind), kind, CancellationToken.None)).ToList(); + var actual = (await deserializer.DeserializeAsync(TestDocuments.ToStream(prefixed, kind), kind, CancellationToken.None)).ToList(); - AssertSameGraph(expected, actual); + TestDocuments.AssertSameGraph(expected, actual); } } [Test] public void Verify_that_a_document_without_namespace_is_read_the_same_as_the_original() { - var original = ReadTestFile("reqifsharpgenerated.reqif"); + var original = TestDocuments.Read("reqifsharpgenerated.reqif"); var withoutNamespace = CreateCopyWithoutNamespace(original); - foreach (var kind in Kinds) + foreach (var kind in TestDocuments.Kinds) { var deserializer = new ReqIFDeserializer(); - var expected = deserializer.Deserialize(ToStream(original, kind), kind).ToList(); - var actual = deserializer.Deserialize(ToStream(withoutNamespace, kind), kind).ToList(); + var expected = deserializer.Deserialize(TestDocuments.ToStream(original, kind), kind).ToList(); + var actual = deserializer.Deserialize(TestDocuments.ToStream(withoutNamespace, kind), kind).ToList(); - AssertSameGraph(expected, actual); + TestDocuments.AssertSameGraph(expected, actual); } } [Test] public async Task Verify_that_a_document_without_namespace_is_read_the_same_as_the_original_async() { - var original = ReadTestFile("reqifsharpgenerated.reqif"); + var original = TestDocuments.Read("reqifsharpgenerated.reqif"); var withoutNamespace = CreateCopyWithoutNamespace(original); - foreach (var kind in Kinds) + foreach (var kind in TestDocuments.Kinds) { var deserializer = new ReqIFDeserializer(); - var expected = (await deserializer.DeserializeAsync(ToStream(original, kind), kind, CancellationToken.None)).ToList(); - var actual = (await deserializer.DeserializeAsync(ToStream(withoutNamespace, kind), kind, CancellationToken.None)).ToList(); + var expected = (await deserializer.DeserializeAsync(TestDocuments.ToStream(original, kind), kind, CancellationToken.None)).ToList(); + var actual = (await deserializer.DeserializeAsync(TestDocuments.ToStream(withoutNamespace, kind), kind, CancellationToken.None)).ToList(); - AssertSameGraph(expected, actual); + TestDocuments.AssertSameGraph(expected, actual); } } @@ -162,7 +144,7 @@ public void Verify_that_a_root_element_in_another_namespace_is_skipped_with_a_wa { var deserializer = new ReqIFDeserializer(this.loggerFactory); - var reqifs = deserializer.Deserialize(ToStream(xml, SupportedFileExtensionKind.Reqif), SupportedFileExtensionKind.Reqif); + var reqifs = deserializer.Deserialize(TestDocuments.ToStream(xml, SupportedFileExtensionKind.Reqif), SupportedFileExtensionKind.Reqif); Assert.That(reqifs, Is.Empty); this.AssertSkippedWarningLogged(); @@ -173,7 +155,7 @@ public async Task Verify_that_a_root_element_in_another_namespace_is_skipped_wit { var deserializer = new ReqIFDeserializer(this.loggerFactory); - var reqifs = await deserializer.DeserializeAsync(ToStream(xml, SupportedFileExtensionKind.Reqif), SupportedFileExtensionKind.Reqif, CancellationToken.None); + var reqifs = await deserializer.DeserializeAsync(TestDocuments.ToStream(xml, SupportedFileExtensionKind.Reqif), SupportedFileExtensionKind.Reqif, CancellationToken.None); Assert.That(reqifs, Is.Empty); this.AssertSkippedWarningLogged(); @@ -189,11 +171,6 @@ private void AssertSkippedWarningLogged() Assert.That(warnings[0].RenderMessage(), Does.Contain("http://example.com/not-reqif")); } - private static string ReadTestFile(string fileName) - { - return File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", fileName)); - } - /// /// Returns a copy of the document in which every ReqIF element is written as reqif:... /// @@ -204,7 +181,7 @@ private static string CreatePrefixedCopy(string original) RemoveReqIFNamespaceDeclarations(document); - document.Root!.SetAttributeValue(XNamespace.Xmlns + "reqif", ReqIFNamespace.NamespaceName); + document.Root!.SetAttributeValue(XNamespace.Xmlns + "reqif", TestDocuments.ReqIFNamespace.NamespaceName); var prefixed = document.ToString(SaveOptions.DisableFormatting); @@ -223,7 +200,7 @@ private static string CreateCopyWithoutNamespace(string original) RemoveReqIFNamespaceDeclarations(document); - foreach (var element in document.Descendants().Where(e => e.Name.Namespace == ReqIFNamespace)) + foreach (var element in document.Descendants().Where(e => e.Name.Namespace == TestDocuments.ReqIFNamespace)) { element.Name = XNamespace.None + element.Name.LocalName; } @@ -231,7 +208,7 @@ private static string CreateCopyWithoutNamespace(string original) var withoutNamespace = document.ToString(SaveOptions.DisableFormatting); Assert.That(withoutNamespace, Does.Contain(" a.IsNamespaceDeclaration && a.Value == ReqIFNamespace.NamespaceName) + .Where(a => a.IsNamespaceDeclaration && a.Value == TestDocuments.ReqIFNamespace.NamespaceName) .Remove(); } } - - private static MemoryStream ToStream(string xml, SupportedFileExtensionKind kind) - { - var bytes = Encoding.UTF8.GetBytes(xml); - - if (kind == SupportedFileExtensionKind.Reqif) - { - return new MemoryStream(bytes); - } - - var zipStream = new MemoryStream(); - - using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) - { - using var entryStream = archive.CreateEntry("document.reqif").Open(); - entryStream.Write(bytes, 0, bytes.Length); - } - - zipStream.Position = 0; - return zipStream; - } - - /// - /// Compares two object graphs by serializing both and comparing the XML, ignoring namespace declarations - /// (which differ by design between the original and its copy). - /// - private static void AssertSameGraph(IReadOnlyList expected, IReadOnlyList actual) - { - Assert.That(expected, Has.Count.EqualTo(1)); - Assert.That(actual, Has.Count.EqualTo(expected.Count), "the copy must be read as the same number of documents"); - Assert.That(actual[0].CoreContent.SpecObjects, Has.Count.EqualTo(expected[0].CoreContent.SpecObjects.Count)); - - var expectedXml = Normalize(expected); - var actualXml = Normalize(actual); - - Assert.That(actualXml.ToString(), Is.EqualTo(expectedXml.ToString())); - } - - private static XDocument Normalize(IEnumerable reqifs) - { - using var stream = new MemoryStream(); - - new ReqIFSerializer().Serialize(reqifs, stream, SupportedFileExtensionKind.Reqif); - - stream.Position = 0; - - var document = XDocument.Load(stream); - - foreach (var element in document.Descendants()) - { - element.Attributes().Where(a => a.IsNamespaceDeclaration).Remove(); - } - - return document; - } } } diff --git a/ReqIFSharp.Tests/TestDocuments.cs b/ReqIFSharp.Tests/TestDocuments.cs new file mode 100644 index 0000000..3bd814a --- /dev/null +++ b/ReqIFSharp.Tests/TestDocuments.cs @@ -0,0 +1,127 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2017-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ReqIFSharp.Tests +{ + using System.Collections.Generic; + using System.IO; + using System.IO.Compression; + using System.Linq; + using System.Text; + using System.Xml.Linq; + + using NUnit.Framework; + + using ReqIFSharp; + + /// + /// Helpers for tests that read a modified copy of a test document and compare it with the original. + /// + internal static class TestDocuments + { + /// + /// The ReqIF namespace + /// + public static readonly XNamespace ReqIFNamespace = DefaultXmlAttributeFactory.ReqIFSchemaUri; + + /// + /// Test documents whose object graphs can be serialized again, so they can be compared. + /// output.reqif and testreqif.reqif are left out: they contain unresolved type references. + /// + public static readonly string[] ComparableFiles = + { + "Datatype-Demo.reqif", + "DefaultValueDemo.reqif", + "ProR_Traceability-Template-v1.0.reqif", + "reqifsharpgenerated.reqif" + }; + + /// + /// Both file kinds the deserializer reads + /// + public static readonly SupportedFileExtensionKind[] Kinds = { SupportedFileExtensionKind.Reqif, SupportedFileExtensionKind.Reqifz }; + + /// + /// Reads a file from the TestData folder + /// + public static string Read(string fileName) + { + return File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", fileName)); + } + + /// + /// Returns the XML as a stream, either as is or as the only entry of a reqifz archive + /// + public static MemoryStream ToStream(string xml, SupportedFileExtensionKind kind) + { + var bytes = Encoding.UTF8.GetBytes(xml); + + if (kind == SupportedFileExtensionKind.Reqif) + { + return new MemoryStream(bytes); + } + + var zipStream = new MemoryStream(); + + using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) + { + using var entryStream = archive.CreateEntry("document.reqif").Open(); + entryStream.Write(bytes, 0, bytes.Length); + } + + zipStream.Position = 0; + return zipStream; + } + + /// + /// Compares two object graphs by serializing both and comparing the XML, ignoring namespace declarations + /// (which differ by design between an original and some of its copies). + /// + public static void AssertSameGraph(IReadOnlyList expected, IReadOnlyList actual) + { + Assert.That(expected, Has.Count.EqualTo(1)); + Assert.That(actual, Has.Count.EqualTo(expected.Count), "the copy must be read as the same number of documents"); + Assert.That(actual[0].CoreContent.SpecObjects, Has.Count.EqualTo(expected[0].CoreContent.SpecObjects.Count)); + + var expectedXml = Normalize(expected); + var actualXml = Normalize(actual); + + Assert.That(actualXml.ToString(), Is.EqualTo(expectedXml.ToString())); + } + + private static XDocument Normalize(IEnumerable reqifs) + { + using var stream = new MemoryStream(); + + new ReqIFSerializer().Serialize(reqifs, stream, SupportedFileExtensionKind.Reqif); + + stream.Position = 0; + + var document = XDocument.Load(stream); + + foreach (var element in document.Descendants()) + { + element.Attributes().Where(a => a.IsNamespaceDeclaration).Remove(); + } + + return document; + } + } +} diff --git a/ReqIFSharp.Tests/XmlReaderExtensionsTestFixture.cs b/ReqIFSharp.Tests/XmlReaderExtensionsTestFixture.cs new file mode 100644 index 0000000..d0f5b8f --- /dev/null +++ b/ReqIFSharp.Tests/XmlReaderExtensionsTestFixture.cs @@ -0,0 +1,201 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2017-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ReqIFSharp.Tests +{ + using System.IO; + using System.Threading.Tasks; + using System.Xml; + + using NUnit.Framework; + + using ReqIFSharp; + + /// + /// Suite of tests for the class + /// + [TestFixture] + public class XmlReaderExtensionsTestFixture + { + [TestCase("text", "text")] + [TestCase("one three", "one two three")] + [TestCase("", "")] + public void Verify_that_the_content_is_read_and_the_reader_is_left_on_the_end_tag(string element, string expected) + { + using var reader = CreateReaderOnFirstChild($"{element}"); + + Assert.That(reader.ReadElementContentAsStringToEndElement(), Is.EqualTo(expected)); + Assert.That(reader.NodeType, Is.EqualTo(XmlNodeType.EndElement)); + Assert.That(reader.LocalName, Is.EqualTo("A")); + + reader.Read(); + Assert.That(reader.LocalName, Is.EqualTo("B"), "the next Read must land on the next sibling"); + } + + [TestCase("text", "text")] + [TestCase("one three", "one two three")] + [TestCase("", "")] + public async Task Verify_that_the_content_is_read_and_the_reader_is_left_on_the_end_tag_async(string element, string expected) + { + using var reader = CreateReaderOnFirstChild($"{element}", true); + + Assert.That(await reader.ReadElementContentAsStringToEndElementAsync(), Is.EqualTo(expected)); + Assert.That(reader.NodeType, Is.EqualTo(XmlNodeType.EndElement)); + Assert.That(reader.LocalName, Is.EqualTo("A")); + + await reader.ReadAsync(); + Assert.That(reader.LocalName, Is.EqualTo("B"), "the next Read must land on the next sibling"); + } + + [Test] + public async Task Verify_that_an_empty_element_gives_an_empty_string_and_the_reader_stays_on_it() + { + using (var reader = CreateReaderOnFirstChild("")) + { + Assert.That(reader.ReadElementContentAsStringToEndElement(), Is.Empty); + Assert.That(reader.NodeType, Is.EqualTo(XmlNodeType.Element)); + Assert.That(reader.LocalName, Is.EqualTo("A")); + } + + using (var reader = CreateReaderOnFirstChild("", true)) + { + Assert.That(await reader.ReadElementContentAsStringToEndElementAsync(), Is.Empty); + Assert.That(reader.NodeType, Is.EqualTo(XmlNodeType.Element)); + Assert.That(reader.LocalName, Is.EqualTo("A")); + } + } + + [TestCase("")] + [TestCase("text")] + public void Verify_that_an_element_with_child_elements_throws(string xml) + { + using (var reader = CreateReaderOnFirstChild(xml)) + { + Assert.That(() => reader.ReadElementContentAsStringToEndElement(), + Throws.TypeOf().With.Message.Contains("The A element may only contain text")); + } + + using (var reader = CreateReaderOnFirstChild(xml, true)) + { + Assert.That(async () => await reader.ReadElementContentAsStringToEndElementAsync(), + Throws.TypeOf().With.Message.Contains("The A element may only contain text")); + } + } + + [Test] + public void Verify_that_a_reader_that_is_not_on_an_element_throws() + { + using (var reader = CreateReaderOnFirstChild("text")) + { + reader.Read(); + + Assert.That(() => reader.ReadElementContentAsStringToEndElement(), + Throws.TypeOf().With.Message.Contains("must be positioned on an element")); + } + + using (var reader = CreateReaderOnFirstChild("text", true)) + { + reader.Read(); + + Assert.That(async () => await reader.ReadElementContentAsStringToEndElementAsync(), + Throws.TypeOf().With.Message.Contains("must be positioned on an element")); + } + } + + [Test] + public async Task Verify_that_a_reader_on_whitespace_before_the_element_moves_to_the_element() + { + using (var reader = CreateReaderOnFirstChild(" text")) + { + Assert.That(reader.NodeType, Is.EqualTo(XmlNodeType.Whitespace), "the reader must start on the whitespace"); + + Assert.That(reader.ReadElementContentAsStringToEndElement(), Is.EqualTo("text")); + Assert.That(reader.NodeType, Is.EqualTo(XmlNodeType.EndElement)); + Assert.That(reader.LocalName, Is.EqualTo("A")); + } + + using (var reader = CreateReaderOnFirstChild(" text", true)) + { + Assert.That(reader.NodeType, Is.EqualTo(XmlNodeType.Whitespace), "the reader must start on the whitespace"); + + Assert.That(await reader.ReadElementContentAsStringToEndElementAsync(), Is.EqualTo("text")); + Assert.That(reader.NodeType, Is.EqualTo(XmlNodeType.EndElement)); + Assert.That(reader.LocalName, Is.EqualTo("A")); + } + } + + [Test] + public async Task Verify_that_the_exception_has_the_line_and_position_of_the_offending_node() + { + const string xml = "\n\ntext"; + + using (var reader = CreateReaderOnFirstChild(xml)) + { + reader.MoveToContent(); + + var exception = Assert.Throws(() => reader.ReadElementContentAsStringToEndElement()); + + Assert.That(exception.LineNumber, Is.EqualTo(3)); + Assert.That(exception.LinePosition, Is.EqualTo(6), "the position of the C element name"); + } + + using (var reader = CreateReaderOnFirstChild(xml, true)) + { + reader.MoveToContent(); + + var exception = await Assert.ThrowsAsync(async () => await reader.ReadElementContentAsStringToEndElementAsync()); + + Assert.That(exception.LineNumber, Is.EqualTo(3)); + Assert.That(exception.LinePosition, Is.EqualTo(6), "the position of the C element name"); + } + } + + [Test] + public void Verify_that_the_exception_has_no_line_and_position_when_the_reader_has_no_line_information() + { + var document = new XmlDocument(); + document.LoadXml("text"); + + using var reader = new XmlNodeReader(document.DocumentElement!.FirstChild!); + reader.Read(); + + Assert.That(reader, Is.Not.InstanceOf(), "the reader must not have line information"); + + var exception = Assert.Throws(() => reader.ReadElementContentAsStringToEndElement()); + + Assert.That(exception.Message, Does.Contain("The A element may only contain text")); + Assert.That(exception.LineNumber, Is.Zero); + Assert.That(exception.LinePosition, Is.Zero); + } + + /// + /// Creates a reader positioned on the first child element of the root + /// + private static XmlReader CreateReaderOnFirstChild(string xml, bool async = false) + { + var reader = XmlReader.Create(new StringReader(xml), new XmlReaderSettings { Async = async }); + + reader.MoveToContent(); + reader.Read(); + + return reader; + } + } +} diff --git a/ReqIFSharp/AttributeValue/AttributeValueEnumeration.cs b/ReqIFSharp/AttributeValue/AttributeValueEnumeration.cs index 48154cf..5905962 100644 --- a/ReqIFSharp/AttributeValue/AttributeValueEnumeration.cs +++ b/ReqIFSharp/AttributeValue/AttributeValueEnumeration.cs @@ -199,7 +199,7 @@ internal override void ReadXml(XmlReader reader) } break; case "ENUM-VALUE-REF": - reference = subtree.ReadElementContentAsString(); + reference = subtree.ReadElementContentAsStringToEndElement(); var enumValue = this.ReqIFContent.DataTypes.OfType().SelectMany(x => x.SpecifiedValues).SingleOrDefault(x => x.Identifier == reference); if (enumValue != null) @@ -251,7 +251,7 @@ internal override async Task ReadXmlAsync(XmlReader reader, CancellationToken to } break; case "ENUM-VALUE-REF": - reference = await subtree.ReadElementContentAsStringAsync(); + reference = await subtree.ReadElementContentAsStringToEndElementAsync(); var enumValue = this.ReqIFContent.DataTypes.OfType().SelectMany(x => x.SpecifiedValues).SingleOrDefault(x => x.Identifier == reference); if (enumValue != null) diff --git a/ReqIFSharp/ReqIFHeader.cs b/ReqIFSharp/ReqIFHeader.cs index a533dfa..cf8834b 100644 --- a/ReqIFSharp/ReqIFHeader.cs +++ b/ReqIFSharp/ReqIFHeader.cs @@ -146,10 +146,10 @@ internal void ReadXml(XmlReader reader) this.Identifier = reader.GetAttribute("IDENTIFIER"); break; case "COMMENT": - this.Comment = reader.ReadElementContentAsString(); + this.Comment = reader.ReadElementContentAsStringToEndElement(); break; case "CREATION-TIME": - var creationTimeValue = reader.ReadElementContentAsString(); + var creationTimeValue = reader.ReadElementContentAsStringToEndElement(); if (!string.IsNullOrWhiteSpace(creationTimeValue)) { try @@ -163,19 +163,19 @@ internal void ReadXml(XmlReader reader) } break; case "REPOSITORY-ID": - this.RepositoryId = reader.ReadElementContentAsString(); + this.RepositoryId = reader.ReadElementContentAsStringToEndElement(); break; case "REQ-IF-TOOL-ID": - this.ReqIFToolId = reader.ReadElementContentAsString(); + this.ReqIFToolId = reader.ReadElementContentAsStringToEndElement(); break; case "REQ-IF-VERSION": - this.ReqIFVersion = reader.ReadElementContentAsString(); + this.ReqIFVersion = reader.ReadElementContentAsStringToEndElement(); break; case "SOURCE-TOOL-ID": - this.SourceToolId = reader.ReadElementContentAsString(); + this.SourceToolId = reader.ReadElementContentAsStringToEndElement(); break; case "TITLE": - this.Title = reader.ReadElementContentAsString(); + this.Title = reader.ReadElementContentAsStringToEndElement(); break; default: this.logger.LogWarning("The {LocalName} element at line:position {LineNumber}:{LinePosition} is not supported", reader.LocalName, xmlLineInfo?.LineNumber, xmlLineInfo?.LinePosition); @@ -210,10 +210,10 @@ internal async Task ReadXmlAsync(XmlReader reader, CancellationToken token) this.Identifier = reader.GetAttribute("IDENTIFIER"); break; case "COMMENT": - this.Comment = await reader.ReadElementContentAsStringAsync(); + this.Comment = await reader.ReadElementContentAsStringToEndElementAsync(); break; case "CREATION-TIME": - var creationTimeValue = await reader.ReadElementContentAsStringAsync(); + var creationTimeValue = await reader.ReadElementContentAsStringToEndElementAsync(); if (!string.IsNullOrWhiteSpace(creationTimeValue)) { try @@ -227,19 +227,19 @@ internal async Task ReadXmlAsync(XmlReader reader, CancellationToken token) } break; case "REPOSITORY-ID": - this.RepositoryId = await reader.ReadElementContentAsStringAsync(); + this.RepositoryId = await reader.ReadElementContentAsStringToEndElementAsync(); break; case "REQ-IF-TOOL-ID": - this.ReqIFToolId = await reader.ReadElementContentAsStringAsync(); + this.ReqIFToolId = await reader.ReadElementContentAsStringToEndElementAsync(); break; case "REQ-IF-VERSION": - this.ReqIFVersion = await reader.ReadElementContentAsStringAsync(); + this.ReqIFVersion = await reader.ReadElementContentAsStringToEndElementAsync(); break; case "SOURCE-TOOL-ID": - this.SourceToolId = await reader.ReadElementContentAsStringAsync(); + this.SourceToolId = await reader.ReadElementContentAsStringToEndElementAsync(); break; case "TITLE": - this.Title = await reader.ReadElementContentAsStringAsync(); + this.Title = await reader.ReadElementContentAsStringToEndElementAsync(); break; default: this.logger.LogWarning("The {LocalName} element at line:position {LineNumber}:{LinePosition} is not supported", reader.LocalName, xmlLineInfo?.LineNumber, xmlLineInfo?.LinePosition); diff --git a/ReqIFSharp/SpecElementWithAttributes/RelationGroup.cs b/ReqIFSharp/SpecElementWithAttributes/RelationGroup.cs index 7105df9..2aaf086 100644 --- a/ReqIFSharp/SpecElementWithAttributes/RelationGroup.cs +++ b/ReqIFSharp/SpecElementWithAttributes/RelationGroup.cs @@ -209,11 +209,10 @@ protected override void ReadObjectSpecificElements(XmlReader reader) } break; case "SPEC-RELATIONS": - reader.ReadStartElement(); - - this.DeserializeSpecRelations(reader); - - reader.ReadEndElement(); + using (var specRelationsSubtree = reader.ReadSubtree()) + { + this.DeserializeSpecRelations(specRelationsSubtree); + } break; default: this.logger.LogWarning("The {LocalName} element at line:position {LineNumber}:{LinePosition} is not supported", reader.LocalName, xmlLineInfo?.LineNumber, xmlLineInfo?.LinePosition); @@ -297,11 +296,10 @@ private async Task ReadObjectSpecificElementsInternalAsync(XmlReader reader, Can } break; case "SPEC-RELATIONS": - reader.ReadStartElement(); - - await this.DeserializeSpecRelationsAsync(reader, token); - - reader.ReadEndElement(); + using (var specRelationsSubtree = reader.ReadSubtree()) + { + await this.DeserializeSpecRelationsAsync(specRelationsSubtree, token); + } break; default: this.logger.LogWarning("The {LocalName} element at line:position {LineNumber}:{LinePosition} is not supported", reader.LocalName, xmlLineInfo?.LineNumber, xmlLineInfo?.LinePosition); @@ -407,13 +405,18 @@ protected override Task ReadHierarchyAsync(XmlReader reader, CancellationToken t /// Deserialize the s contained by the SPEC-RELATIONS element. /// /// - /// an instance of + /// an over the subtree of the SPEC-RELATIONS element /// private void DeserializeSpecRelations(XmlReader reader) { - while (reader.Read() && reader.MoveToContent() == XmlNodeType.Element && reader.LocalName.StartsWith("SPEC-RELATION-REF", StringComparison.Ordinal)) + while (reader.Read()) { - var reference = reader.ReadElementContentAsString(); + if (!this.IsSpecRelationRef(reader)) + { + continue; + } + + var reference = reader.ReadElementContentAsStringToEndElement(); var specRelation = this.CoreContent.SpecRelations.SingleOrDefault(x => x.Identifier == reference); if (specRelation != null) { @@ -430,18 +433,23 @@ private void DeserializeSpecRelations(XmlReader reader) /// Asynchronously deserialize the s contained by the SPEC-RELATIONS element. /// /// - /// an instance of + /// an over the subtree of the SPEC-RELATIONS element /// /// /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// private async Task DeserializeSpecRelationsAsync(XmlReader reader, CancellationToken token) { - while (await reader.ReadAsync() && await reader.MoveToContentAsync() == XmlNodeType.Element && reader.LocalName.StartsWith("SPEC-RELATION-REF", StringComparison.Ordinal)) + while (await reader.ReadAsync()) { token.ThrowIfCancellationRequested(); - var reference = await reader.ReadElementContentAsStringAsync(); + if (!this.IsSpecRelationRef(reader)) + { + continue; + } + + var reference = await reader.ReadElementContentAsStringToEndElementAsync(); var specRelation = this.CoreContent.SpecRelations.SingleOrDefault(x => x.Identifier == reference); if (specRelation != null) { @@ -454,6 +462,34 @@ private async Task DeserializeSpecRelationsAsync(XmlReader reader, CancellationT } } + /// + /// Checks whether the reader is on a SPEC-RELATION-REF child of the SPEC-RELATIONS element. + /// Any other child element is not supported: a warning is logged and the element, with its content, is skipped. + /// + /// + /// an over the subtree of the SPEC-RELATIONS element + /// + /// + /// true when the reader is on a SPEC-RELATION-REF element that is a direct child of SPEC-RELATIONS + /// + private bool IsSpecRelationRef(XmlReader reader) + { + if (reader.NodeType != XmlNodeType.Element || reader.Depth != 1) + { + return false; + } + + if (reader.LocalName.StartsWith("SPEC-RELATION-REF", StringComparison.Ordinal)) + { + return true; + } + + var xmlLineInfo = reader as IXmlLineInfo; + this.logger.LogWarning("The {LocalName} element at line:position {LineNumber}:{LinePosition} is not supported in SPEC-RELATIONS of RelationGroup:{Identifier} and has been skipped", reader.LocalName, xmlLineInfo?.LineNumber, xmlLineInfo?.LinePosition, this.Identifier); + + return false; + } + /// /// Converts a object into its XML representation. /// diff --git a/ReqIFSharp/XmlReaderExtensions.cs b/ReqIFSharp/XmlReaderExtensions.cs new file mode 100644 index 0000000..bb547a2 --- /dev/null +++ b/ReqIFSharp/XmlReaderExtensions.cs @@ -0,0 +1,131 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2017-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ReqIFSharp +{ + using System.Threading.Tasks; + using System.Xml; + + /// + /// Extension methods for + /// + internal static class XmlReaderExtensions + { + /// + /// Reads the text content of the current element and leaves the reader on its end tag + /// (or on the element itself when it is empty). + /// + /// + /// moves the reader past the end tag, onto the next node. + /// In a loop that calls for every iteration, that skips the next sibling + /// element when there is no whitespace between the elements. Use this method in such a loop instead. + /// + /// + /// The , positioned on an element + /// + /// + /// The text content of the element; an empty string when the element is empty + /// + /// + /// The reader is not on an element, or the element contains child elements + /// + internal static string ReadElementContentAsStringToEndElement(this XmlReader reader) + { + if (reader.MoveToContent() != XmlNodeType.Element) + { + throw CreateException(reader, $"The reader must be positioned on an element, not on {reader.NodeType}"); + } + + if (reader.IsEmptyElement) + { + return string.Empty; + } + + var localName = reader.LocalName; + + reader.Read(); + + var content = reader.NodeType == XmlNodeType.Element ? null : reader.ReadContentAsString(); + + if (reader.NodeType != XmlNodeType.EndElement) + { + throw CreateException(reader, $"The {localName} element may only contain text"); + } + + return content; + } + + /// + /// Asynchronously reads the text content of the current element and leaves the reader on its end tag + /// (or on the element itself when it is empty). + /// + /// + /// moves the reader past the end tag, onto the next node. + /// In a loop that calls for every iteration, that skips the next sibling + /// element when there is no whitespace between the elements. Use this method in such a loop instead. + /// + /// + /// The , positioned on an element + /// + /// + /// The text content of the element; an empty string when the element is empty + /// + /// + /// The reader is not on an element, or the element contains child elements + /// + internal static async Task ReadElementContentAsStringToEndElementAsync(this XmlReader reader) + { + if (await reader.MoveToContentAsync() != XmlNodeType.Element) + { + throw CreateException(reader, $"The reader must be positioned on an element, not on {reader.NodeType}"); + } + + if (reader.IsEmptyElement) + { + return string.Empty; + } + + var localName = reader.LocalName; + + await reader.ReadAsync(); + + var content = reader.NodeType == XmlNodeType.Element ? null : await reader.ReadContentAsStringAsync(); + + if (reader.NodeType != XmlNodeType.EndElement) + { + throw CreateException(reader, $"The {localName} element may only contain text"); + } + + return content; + } + + /// + /// Creates an with the line and position of the reader, when it has them + /// + private static XmlException CreateException(XmlReader reader, string message) + { + var lineInfo = reader as IXmlLineInfo; + + return lineInfo != null && lineInfo.HasLineInfo() + ? new XmlException(message, null, lineInfo.LineNumber, lineInfo.LinePosition) + : new XmlException(message); + } + } +}