diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/Ontology/OwlAdapterRegressionTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/Ontology/OwlAdapterRegressionTests.cs
index c432028f3..78d734f45 100644
--- a/Generation/Converters/Argumentum.AssetConverter.Tests/Ontology/OwlAdapterRegressionTests.cs
+++ b/Generation/Converters/Argumentum.AssetConverter.Tests/Ontology/OwlAdapterRegressionTests.cs
@@ -18,20 +18,22 @@ namespace Argumentum.AssetConverter.Tests.Ontology
/// annotation scanners (GetAnnotationSubjects / GetResourceAnnotations /
/// GetLiteralAnnotations).
///
- /// ⚠️ These tests surfaced a REAL BUG (not greenwashed). Every fallback reader compares
- /// a.ValueIRI.Equals(value.URI) / a.SubjectIRI.Equals(subject.URI) where
- /// .URI is a string and ValueIRI/SubjectIRI are
+ /// ✅ BUG FOUND then FIXED. PR #480 surfaced a real bug (not greenwashed): every fallback
+ /// reader compared a.ValueIRI.Equals(value.URI) / a.SubjectIRI.Equals(subject.URI)
+ /// where .URI is a string and ValueIRI/SubjectIRI are
/// RDFResource. RDFResource.Equals(string) returns false by type-mismatch,
- /// so every read returns empty. See .
- /// This is the root cause behind the production validation module silently reporting
- /// "no concepts → skip → PASS" on annotation/AIF checks. Reported as [BUG] on the dashboard.
+ /// so every read returned empty — the root cause behind the production validation module
+ /// silently reporting "no concepts → skip → PASS" on annotation/AIF checks. See
+ /// .
///
- /// Tests are split into: (A) characterization of the bug (pinned, documents current behavior),
- /// (B) the correct comparison semantics (documents the fix), (C) write-path + serialization
- /// (these DO work). No fix is applied — the file only pins observed behavior so a future fix
- /// flips the [BUG] tests red→green and the fix author has a regression suite ready.
+ /// This PR applies the fix: drop .URI on the right-hand side of every reader comparison
+ /// (14 sites in OwlAdapter.cs), so readers compare RDFResource.Equals(RDFResource).
+ /// The section-(A) tests below WERE the [BUG] characterization suite from #480; they are now
+ /// flipped to proper round-trip assertions and serve as the regression suite guarding the fix.
+ /// Section (C) write-path + serialization tests (unchanged) prove the fix did not regress the
+ /// write side.
///
- /// Deterministic, key-free, release-independent. No existing file modified. Baseline additive.
+ /// Deterministic, key-free, release-independent.
///
public class OwlAdapterRegressionTests
{
@@ -67,15 +69,16 @@ public void Diag_RDFResource_Equals_String_Is_False_By_Type_Mismatch()
}
// ─────────────────────────────────────────────────────────────────────────────
- // (A) [BUG] characterization — readers return empty despite writes succeeding.
- // These pin the BROKEN behavior. When OwlAdapter is fixed, these flip to red and
- // become proper round-trip assertions (remove the .Be(0) and assert the populated set).
+ // (A) Reader round-trip — was the [BUG] characterization suite in #480 (asserted the
+ // BROKEN empty/false behavior). Now flipped by the fix in this PR: these verify the
+ // readers correctly retrieve what the write path declared. Regression suite for the fix.
// ─────────────────────────────────────────────────────────────────────────────
[Fact]
- public void BUG_GetConcepts_Returns_Empty_Despite_Declared_Concepts()
+ public void GetConcepts_RoundTrips_Declared_Concepts_After_Fix()
{
- // Write 3 concepts; the reader should return 3 but returns 0 (comparison bug).
+ // Write 3 concepts; the reader must return all 3 (was returning 0 before the fix
+ // because the fallback scanner compared RDFResource.Equals(string)).
var adapter = NewAdapter();
var scheme = Res("Scheme");
adapter.DeclareConceptScheme(scheme);
@@ -84,73 +87,85 @@ public void BUG_GetConcepts_Returns_Empty_Despite_Declared_Concepts()
adapter.DeclareConcept(Res("C3"), scheme);
var concepts = adapter.GetConcepts();
- concepts.Should().BeEmpty(
- "[BUG] GetConcepts returns empty because the fallback scanner compares RDFResource.Equals(string)");
+ concepts.Should().NotBeEmpty("GetConcepts must retrieve declared concepts (fix: RDFResource.Equals(RDFResource))");
+ concepts.Should().HaveCount(3);
+ concepts.Select(c => c.ToString()).Should().BeEquivalentTo(new[] { Ns + "C1", Ns + "C2", Ns + "C3" });
}
[Fact]
- public void BUG_GetResourcesByType_Concept_Returns_Empty()
+ public void GetResourcesByType_RoundTrips_Concepts_And_Schemes_After_Fix()
{
// Same root cause, different reader. ValidateOwlOntologyStructure relies on this.
+ // Was returning empty before the fix (RDFResource.Equals(string)).
var adapter = NewAdapter();
var scheme = Res("Scheme");
adapter.DeclareConceptScheme(scheme);
adapter.DeclareConcept(Res("C1"), scheme);
- adapter.GetResourcesByType(SKOSVocabulary.Concept).Should().BeEmpty("[BUG] RDFResource.Equals(string)");
- adapter.GetResourcesByType(SKOSVocabulary.ConceptScheme).Should().BeEmpty("[BUG] RDFResource.Equals(string)");
+ adapter.GetResourcesByType(SKOSVocabulary.Concept)
+ .Should().NotBeEmpty("Concept type resolves after the fix")
+ .And.ContainSingle(c => c.ToString() == Ns + "C1");
+ adapter.GetResourcesByType(SKOSVocabulary.ConceptScheme)
+ .Should().NotBeEmpty("ConceptScheme type resolves after the fix")
+ .And.ContainSingle(c => c.ToString() == Ns + "Scheme");
}
[Fact]
- public void BUG_GetTopConcepts_Returns_Empty_Despite_Declared()
+ public void GetTopConcepts_RoundTrips_Declared_Top_Concepts_After_Fix()
{
var adapter = NewAdapter();
adapter.DeclareTopConcept(Res("Top1"), Res("Scheme"));
adapter.DeclareTopConcept(Res("Top2"), Res("Scheme"));
- adapter.GetTopConcepts().Should().BeEmpty("[BUG] GetAnnotationObjects compares RDFResource.Equals(string)");
+ var tops = adapter.GetTopConcepts();
+ tops.Should().NotBeEmpty("GetTopConcepts must retrieve declared top concepts after the fix");
+ tops.Select(c => c.ToString()).Should().BeEquivalentTo(new[] { Ns + "Top1", Ns + "Top2" });
}
[Fact]
- public void BUG_HasAnnotation_Returns_False_Despite_Annotation_Present()
+ public void HasAnnotation_Finds_The_Annotation_After_Fix()
{
var adapter = NewAdapter();
var scheme = Res("Scheme");
adapter.DeclareConceptScheme(scheme);
- // The annotation IS written (see write-path test below), but HasAnnotation can't find it.
+ // The annotation IS written (write-path test confirms it); HasAnnotation must now find it
+ // (was false before the fix: ValueIRI.Equals(value.URI string)).
adapter.HasAnnotation(scheme, RDFVocabulary.RDF.TYPE, SKOSVocabulary.ConceptScheme)
- .Should().BeFalse("[BUG] HasAnnotation compares ValueIRI.Equals(value.URI string) → false");
+ .Should().BeTrue("HasAnnotation must match rdf:type=skos:ConceptScheme after the fix");
}
[Fact]
- public void BUG_CheckIsNarrowerConcept_Returns_False_Despite_Declared()
+ public void CheckIsNarrowerConcept_Detects_The_Edge_After_Fix()
{
var adapter = NewAdapter();
var parent = Res("Fallacies");
var child = Res("AdHominem");
adapter.DeclareNarrowerConcepts(parent, child);
- // CheckIsNarrowerConcept has a try/except fallback that ALSO uses .Equals(string.URI),
- // so even the fallback fails.
+ // CheckIsNarrowerConcept's try/except fallback ALSO used .Equals(string.URI) and failed;
+ // after the fix the fallback scanner matches correctly.
adapter.CheckIsNarrowerConcept(child, parent)
- .Should().BeFalse("[BUG] fallback compares RDFResource.Equals(string) → false");
+ .Should().BeTrue("child IS a narrower of parent after the fix");
+ adapter.CheckIsNarrowerConcept(Res("Unrelated"), parent)
+ .Should().BeFalse("an unrelated concept is NOT a narrower of parent");
}
[Fact]
- public void BUG_GetConceptPreferredLabels_Returns_Empty_Despite_Label_Set()
+ public void GetConceptPreferredLabels_RoundTrips_The_Label_After_Fix()
{
var adapter = NewAdapter();
var concept = Res("C");
adapter.DeclareConcept(concept, Res("Scheme"));
adapter.AnnotateConceptPreferredLabel(concept, Lit("Ad Hominem"));
- adapter.GetConceptPreferredLabels(concept).Should().BeEmpty(
- "[BUG] GetLiteralAnnotations compares AnnotationProperty.GetIRI().Equals(property.URI string)");
+ adapter.GetConceptPreferredLabels(concept)
+ .Should().NotBeEmpty("prefLabel must be retrievable after the fix")
+ .And.ContainSingle(l => l.ToString().StartsWith("Ad Hominem"));
}
[Fact]
- public void BUG_GetConceptDocumentation_Returns_Empty_Despite_Documented()
+ public void GetConceptDocumentation_RoundTrips_The_Definition_After_Fix()
{
var adapter = NewAdapter();
var concept = Res("C");
@@ -158,18 +173,21 @@ public void BUG_GetConceptDocumentation_Returns_Empty_Despite_Documented()
adapter.DocumentConcept(concept, SKOSDocumentationTypes.Definition, Lit("A fallacy..."));
adapter.GetConceptDocumentation(concept, SKOSDocumentationTypes.Definition)
- .Should().BeEmpty("[BUG] same RDFResource.Equals(string) root cause");
+ .Should().NotBeEmpty("definition must be retrievable after the fix")
+ .And.ContainSingle(l => l.ToString().StartsWith("A fallacy"));
}
[Fact]
- public void BUG_GetExactMatchConcepts_Returns_Empty_Despite_Declared()
+ public void GetExactMatchConcepts_RoundTrips_The_Match_After_Fix()
{
var adapter = NewAdapter();
var c1 = Res("EN");
var c2 = Res("FR");
adapter.DeclareExactMatchConcepts(c1, c2);
- adapter.GetExactMatchConcepts(c1).Should().BeEmpty("[BUG] RDFResource.Equals(string)");
+ adapter.GetExactMatchConcepts(c1)
+ .Should().NotBeEmpty("exactMatch must be retrievable after the fix")
+ .And.ContainSingle(c => c.ToString() == Ns + "FR");
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -190,9 +208,8 @@ public void Constructor_Produces_An_Adapter_With_The_Declared_Namespace_Uri()
public void DeclareConcept_Appends_AnnotationAxioms_To_The_Ontology()
{
// The WRITE side is correct: DeclareConcept adds real annotation axioms to the
- // ontology (verifiable by inspecting the underlying graph directly, bypassing the
- // broken reader). This is why ToFileAsync produces a valid ontology despite the
- // readers being broken.
+ // ontology (verifiable by inspecting the underlying graph directly). These write-path
+ // tests guard that the reader fix did not regress the graph construction.
var adapter = NewAdapter();
var scheme = Res("Scheme");
var concept = Res("C1");
@@ -254,11 +271,11 @@ public void AnnotateConceptPreferredLabel_Appends_The_PrefLabel_Axiom()
}
[Fact]
- public void BUG_CheckHasClass_Returns_False_Despite_Class_Declared()
+ public void CheckHasClass_Finds_The_Declared_Class_After_Fix()
{
- // CheckHasClass uses DeclarationAxioms and compares cls.GetIRI().Equals(resource.URI)
- // where .URI is a System.Uri. RDFResource.Equals(Uri) is false by type-mismatch — the
- // SAME root cause as the annotation readers. So CheckHasClass is also broken.
+ // CheckHasClass uses DeclarationAxioms and compared cls.GetIRI().Equals(resource.URI)
+ // where .URI is a System.Uri — RDFResource.Equals(Uri) was false by type-mismatch (the
+ // SAME root cause as the annotation readers). After the fix it compares RDFResource.Equals(RDFResource).
var adapter = NewAdapter();
var declared = Res("DeclaredClass");
adapter.DeclareClass(declared);
@@ -267,9 +284,11 @@ public void BUG_CheckHasClass_Returns_False_Despite_Class_Declared()
var onto = adapter.GetOntology();
onto.DeclarationAxioms.Should().NotBeEmpty("DeclareClass wrote the declaration axiom");
- // ...but CheckHasClass can't find it:
- adapter.CheckHasClass(declared).Should().BeFalse(
- "[BUG] cls.GetIRI().Equals(resource.URI) compares RDFResource.Equals(Uri) → false by type-mismatch");
+ // ...and CheckHasClass now finds it:
+ adapter.CheckHasClass(declared).Should().BeTrue(
+ "CheckHasClass must match the declared class after the fix (RDFResource.Equals(RDFResource))");
+ adapter.CheckHasClass(Res("UndeclaredClass")).Should().BeFalse(
+ "an undeclared class is not found");
}
[Fact]
diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/Ontology/OwlValidatorLivePathTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/Ontology/OwlValidatorLivePathTests.cs
new file mode 100644
index 000000000..8a358a751
--- /dev/null
+++ b/Generation/Converters/Argumentum.AssetConverter.Tests/Ontology/OwlValidatorLivePathTests.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using Argumentum.AssetConverter.Ontology;
+using FluentAssertions;
+using RDFSharp.Model;
+using Xunit;
+
+namespace Argumentum.AssetConverter.Tests.Ontology
+{
+ ///
+ /// Proves the production OWL validation path is LIVE after the OwlAdapter reader fix (PR #481).
+ ///
+ /// NEW additive file (dispatch `4rkh1s` secondaire). The production validation module
+ /// Tests/OwlOntologyValidationTests.cs (NOT an xUnit suite — a runtime validator invoked by
+ /// OwlValidatorConfig.Apply) had a **silent false-pass**: ValidateMultilingualAnnotations
+ /// and ValidateAIFMappings both early-return true ("No concepts to validate — skipping")
+ /// when _ontology.GetResourcesByType(SKOSVocabulary.Concept) returned empty. Before the fix,
+ /// that reader ALWAYS returned empty (RDFResource type-mismatch), so the validators reported PASS
+ /// regardless of whether any annotation or AIF mapping actually existed.
+ ///
+ /// These tests drive the REAL production validator (via reflection — the validator's ontology field
+ /// and methods are private, and it has no public injection seam). They prove:
+ /// (1) with annotated concepts, the validator no longer early-returns — it inspects the concepts
+ /// and reports the genuine PASS;
+ /// (2) with UNannotated concepts, the validator now FAILS (the annotation check actually ran and
+ /// found the missing labels/definitions) — the dead silent false-pass is gone.
+ /// This is the exact delta the #481 fix delivers to the production validation path.
+ ///
+ /// Deterministic, key-free, release-independent. No existing file modified.
+ ///
+ public class OwlValidatorLivePathTests
+ {
+ private const string Ns = "http://argumentum.test/onto#";
+
+ private static OwlAdapter NewAdapter() => new OwlAdapter(Ns);
+ private static RDFResource Res(string local) => new RDFResource(Ns + local);
+ private static RDFPlainLiteral Lit(string value) => new RDFPlainLiteral(value);
+
+ ///
+ /// Builds a fully-injected production instance whose
+ /// private _ontology field points at . The validator's public
+ /// constructor requires an (for the OwlValidatorConfig), but
+ /// the ontology is otherwise loaded from disk via LoadOntology — we bypass that by setting
+ /// the private field directly, mirroring what a post-fix production run would hold in memory.
+ ///
+ private static object BuildValidator(OwlAdapter ontology)
+ {
+ var config = new AssetConverterConfig();
+ var validator = new OwlOntologyValidationTests(config);
+ var field = typeof(OwlOntologyValidationTests).GetField("_ontology",
+ BindingFlags.NonPublic | BindingFlags.Instance);
+ field.Should().NotBeNull("the validator must carry a private _ontology field");
+ field!.SetValue(validator, ontology);
+ return validator;
+ }
+
+ private static Task InvokeValidate(object validator, string methodName)
+ {
+ // ValidateMultilingualAnnotations / ValidateAIFMappings are PUBLIC methods on the
+ // production validator (OwlOntologyValidationTests is a public class in the production
+ // project). Bind Public|Instance to locate them.
+ var method = typeof(OwlOntologyValidationTests).GetMethod(methodName,
+ BindingFlags.Public | BindingFlags.Instance);
+ method.Should().NotBeNull($"the validator must expose a {methodName} validation method");
+ var task = (Task)method!.Invoke(validator, null)!;
+ return task;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (1) With annotated concepts, the validator no longer early-returns: it inspects
+ // the concepts and reports a genuine PASS. Before the fix this returned true for the
+ // wrong reason (empty concepts → skip). After the fix it returns true because the
+ // annotations are actually present.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Annotation_Validator_Inspects_Concepts_And_Passes_When_Annotated()
+ {
+ var adapter = NewAdapter();
+ var scheme = Res("Scheme");
+ adapter.DeclareConceptScheme(scheme);
+ adapter.DeclareTopConcept(Res("Top"), scheme);
+ // Two fully-annotated concepts under the scheme.
+ foreach (var name in new[] { "AdHominem", "StrawMan" })
+ {
+ var concept = Res(name);
+ adapter.DeclareConcept(concept, scheme);
+ adapter.AnnotateConceptPreferredLabel(concept, Lit(name + " label"));
+ adapter.DocumentConcept(concept, SKOSDocumentationTypes.Definition, Lit(name + " definition"));
+ }
+
+ var validator = BuildValidator(adapter);
+ var valid = await InvokeValidate(validator, "ValidateMultilingualAnnotations");
+
+ valid.Should().BeTrue(
+ "with 2 fully-annotated concepts the annotation validator must PASS — and crucially " +
+ "it must PASS because the annotations ARE present, not because of the 'no concepts → skip' early-return " +
+ "(the early-return was the silent false-pass before the #481 fix)");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (2) The decisive proof: with UNannotated concepts, the validator now FAILS. Before the
+ // fix, GetResourcesByType(Concept) returned empty → early-return true (PASS) even though
+ // every concept lacked a label/definition. After the fix the concepts resolve, the check
+ // actually runs, finds the missing annotations, and FAILS. This is the dead silent-false-pass.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Annotation_Validator_Fails_When_Concepts_Lack_Annotations_After_Fix()
+ {
+ var adapter = NewAdapter();
+ var scheme = Res("Scheme");
+ adapter.DeclareConceptScheme(scheme);
+ adapter.DeclareTopConcept(Res("Top"), scheme);
+ // Concepts declared but with NO prefLabel / NO definition.
+ foreach (var name in new[] { "C1", "C2", "C3", "C4" })
+ {
+ adapter.DeclareConcept(Res(name), scheme);
+ }
+
+ var validator = BuildValidator(adapter);
+ var valid = await InvokeValidate(validator, "ValidateMultilingualAnnotations");
+
+ valid.Should().BeFalse(
+ "4 declared concepts with zero annotations must FAIL the annotation check after the fix — " +
+ "before the fix this returned true (silent false-pass: 'no concepts → skip') because " +
+ "GetResourcesByType(Concept) was empty due to the RDFResource type-mismatch bug");
+ }
+
+ [Fact]
+ public async Task Aif_Validator_Fails_When_Concepts_Have_No_Match_Mappings_After_Fix()
+ {
+ // Same silent-false-pass for the AIF mapping validator: concepts declared but no
+ // exactMatch/closeMatch/relatedMatch. Before the fix → empty concepts → skip → PASS.
+ // After the fix → concepts resolve → check runs → finds no mappings → FAIL.
+ var adapter = NewAdapter();
+ var scheme = Res("Scheme");
+ adapter.DeclareConceptScheme(scheme);
+ adapter.DeclareTopConcept(Res("Top"), scheme);
+ adapter.DeclareConcept(Res("LonelyConcept"), scheme); // no match mappings
+
+ var validator = BuildValidator(adapter);
+ var valid = await InvokeValidate(validator, "ValidateAIFMappings");
+
+ valid.Should().BeFalse(
+ "a concept with no AIF match mappings must FAIL the AIF validator after the fix — " +
+ "before the fix the empty GetResourcesByType made this a silent false-pass");
+ }
+
+ [Fact]
+ public async Task Aif_Validator_Passes_When_Concepts_Carry_ExactMatch_After_Fix()
+ {
+ var adapter = NewAdapter();
+ var scheme = Res("Scheme");
+ adapter.DeclareConceptScheme(scheme);
+ adapter.DeclareTopConcept(Res("Top"), scheme);
+ var en = Res("ConceptEN");
+ var fr = Res("ConceptFR");
+ adapter.DeclareConcept(en, scheme);
+ adapter.DeclareConcept(fr, scheme);
+ adapter.DeclareExactMatchConcepts(en, fr); // cross-language exactMatch mapping
+
+ var validator = BuildValidator(adapter);
+ var valid = await InvokeValidate(validator, "ValidateAIFMappings");
+
+ valid.Should().BeTrue(
+ "concepts carrying an exactMatch mapping must PASS the AIF validator after the fix " +
+ "(genuine pass: the mapping is present and resolved, not a 'no concepts → skip' false-pass)");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (3) Contract guard: the GetResourcesByType(Concept) reader — the exact predicate the
+ // validators branch on — resolves non-empty when concepts are declared. This is the
+ // single line that decided silent-false-pass vs live-check. Pinned in isolation so a
+ // future regression to the empty-return bug is caught at the unit level too.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void The_Reader_The_Validators_Branch_On_Resolves_Concepts_NonEmpty()
+ {
+ // ValidateMultilingualAnnotations / ValidateAIFMappings both start with:
+ // var concepts = _ontology.GetResourcesByType(SKOSVocabulary.Concept);
+ // if (concepts.Count == 0) { return true; } // <-- the silent false-pass
+ // Pin that this predicate is non-empty post-fix → the early-return is unreachable.
+ var adapter = NewAdapter();
+ var scheme = Res("Scheme");
+ adapter.DeclareConceptScheme(scheme);
+ adapter.DeclareConcept(Res("C1"), scheme);
+ adapter.DeclareConcept(Res("C2"), scheme);
+
+ adapter.GetResourcesByType(SKOSVocabulary.Concept)
+ .Should().HaveCount(2,
+ "the validator's silent-false-pass early-return triggers exactly when this is empty; " +
+ "after the #481 fix it must resolve the declared concepts");
+ }
+ }
+}
diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/Utility/UtilityExtensionsLayoutTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/Utility/UtilityExtensionsLayoutTests.cs
new file mode 100644
index 000000000..49d6f2dfa
--- /dev/null
+++ b/Generation/Converters/Argumentum.AssetConverter.Tests/Utility/UtilityExtensionsLayoutTests.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Argumentum.AssetConverter;
+using FluentAssertions;
+using Xunit;
+
+namespace Argumentum.AssetConverter.Tests.Utility
+{
+ ///
+ /// Regression tests for the layout-math and path utilities in .
+ ///
+ /// NEW additive file (dispatch `4rkh1s` tertiaire). CLAUDE.md flags two fragile areas these
+ /// utilities underpin:
+ /// (1) PDF / mind-map grid layout math — ToJaggedArray<T> computes
+ /// rowLength = ceil(count / columnLength) and builds a row-major jagged grid
+ /// (trailing short row). The Print&Play / mind-map column wrapping depends on this exact
+ /// ceiling-and-partial-row shape. A regression to a naive integer divide, or to dropping the
+ /// trailing short row, silently corrupts card grids.
+ /// (2) Relative-vs-absolute asset path resolution — PathIsUrl gates the rewrite of
+ /// ../../Cards/... relative paths to absolute GitHub URLs (CLAUDE.md "Images
+ /// blanches/vides → chemins assets relatifs"). A regression here ships white/empty cards.
+ ///
+ /// These methods are public static extensions, deterministic, key-free, release-independent —
+ /// pure-function regression targets. No existing file modified. Baseline additive.
+ ///
+ public class UtilityExtensionsLayoutTests
+ {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // ToJaggedArray — grid layout math (ceil rows + trailing short row).
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ToJaggedArray_Exact_Division_Builds_Full_Rows()
+ {
+ var source = new List { 1, 2, 3, 4, 5, 6 };
+
+ var grid = source.ToJaggedArray(columnLength: 3);
+
+ grid.Should().HaveCount(2, "6 items / 3 columns = 2 full rows");
+ grid[0].Should().Equal(new[] { 1, 2, 3 });
+ grid[1].Should().Equal(new[] { 4, 5, 6 });
+ }
+
+ [Fact]
+ public void ToJaggedArray_Trailing_Short_Row_Has_Only_Remaining_Items()
+ {
+ // 7 items / 3 columns = ceil(7/3) = 3 rows: [3, 3, 1]. The last row is SHORT (1 item).
+ // A regression that pads the last row (e.g. default(T) fillers) would corrupt card grids
+ // by injecting phantom blank cards into the final row.
+ var source = Enumerable.Range(1, 7).ToList();
+
+ var grid = source.ToJaggedArray(columnLength: 3);
+
+ grid.Should().HaveCount(3, "ceil(7/3) = 3 rows");
+ grid[0].Should().Equal(new[] { 1, 2, 3 });
+ grid[1].Should().Equal(new[] { 4, 5, 6 });
+ grid[2].Should().Equal(new[] { 7 }, "trailing row carries ONLY the remainder, no padding");
+ }
+
+ [Theory]
+ [InlineData(1, 3, 1)] // fewer items than columns → 1 short row
+ [InlineData(3, 3, 1)] // exactly one row
+ [InlineData(4, 3, 2)] // one full + one short
+ [InlineData(6, 3, 2)] // two full rows
+ [InlineData(7, 3, 3)] // two full + one short
+ [InlineData(0, 3, 0)] // empty source → zero rows (ceil(0/3) = 0)
+ public void ToJaggedArray_Row_Count_Is_Ceiling_Of_Count_Over_Columns(int count, int columns, int expectedRows)
+ {
+ // Pins the rowLength = ceil(count / columns) contract that PDF/mind-map layout depends on.
+ var source = Enumerable.Range(1, count).ToList();
+
+ var grid = source.ToJaggedArray(columns);
+
+ grid.Should().HaveCount(expectedRows);
+ }
+
+ [Fact]
+ public void ToJaggedArray_Preserves_Row_Major_Order()
+ {
+ // The grid is filled row-by-row (global index = rowIndex * columnLength + colIndex).
+ // A column-major regression would transpose the grid and scramble card positions.
+ var source = Enumerable.Range(0, 12).ToList();
+
+ var grid = source.ToJaggedArray(columnLength: 4);
+
+ // Flatten back must equal the original order — proves row-major fill.
+ grid.Flatten().Should().Equal(source, "row-major fill round-trips through Flatten");
+ // And spot-check a known cell: index 5 = row 1, col 1.
+ grid[1][1].Should().Be(5);
+ }
+
+ [Fact]
+ public void ToJaggedArray_And_Flatten_Are_Inverses()
+ {
+ // Flatten is the documented inverse of ToJaggedArray. A round-trip must be lossless
+ // for any column length >= 1. Guards both methods together.
+ var original = Enumerable.Range(100, 23).ToList();
+
+ foreach (var columns in new[] { 1, 2, 5, 7, 23, 100 })
+ {
+ var roundTrip = original.ToJaggedArray(columns).Flatten();
+ roundTrip.Should().Equal(original, $"round-trip through ToJaggedArray({columns})+Flatten must be lossless");
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // PathIsUrl — asset-path gate (relative paths → must be rewritten to absolute URLs).
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("http://example.com/x.png", true)]
+ [InlineData("https://raw.githubusercontent.com/ArgumentumGames/Argumentum/master/Cards/Fallacies/x.png", true)]
+ [InlineData("https://argumentum.myia.io", true)]
+ [InlineData("http://argumentum.myia.io", true)]
+ public void PathIsUrl_Recognizes_Http_And_Https_Schemes(string path, bool expected)
+ {
+ path.PathIsUrl().Should().Be(expected, "http/https absolute URLs are URLs");
+ }
+
+ [Theory]
+ [InlineData("../../Cards/Fallacies/x.png", false)] // the CLAUDE.md relative-path failure case
+ [InlineData("../Cards/x.png", false)]
+ [InlineData("Cards/Fallacies/x.png", false)]
+ [InlineData("C:\\Cards\\Fallacies\\x.png", false)] // Windows absolute file path — NOT a URL
+ [InlineData("/var/cards/x.png", false)] // Unix absolute file path — NOT a URL
+ [InlineData("", false)]
+ [InlineData(" ", false)] // whitespace-only
+ public void PathIsUrl_Rejects_Relative_File_And_Windows_Paths(string path, bool expected)
+ {
+ // The decisive guard: the relative asset paths CLAUDE.md documents as the root cause of
+ // "white/empty cards" must NOT register as URLs (so the caller knows to rewrite them).
+ path.PathIsUrl().Should().Be(expected, "relative/file paths are not URLs");
+ }
+
+ [Fact]
+ public void PathIsUrl_Returns_False_For_Null()
+ {
+ // The implementation guards with IsNullOrWhiteSpace and returns false before parsing.
+ // Pinned separately (null is not a valid Theory string argument) so the null path is
+ // still covered — a regression that removed the null guard would NullReferenceException.
+ ((string)null).PathIsUrl().Should().BeFalse("null path must not be treated as a URL");
+ }
+
+ [Fact]
+ public void PathIsUrl_Handles_Whitespace_Padded_Urls()
+ {
+ // The implementation trims before parsing. A URL with leading/trailing whitespace must
+ // still be recognized — guards a regression where the trim is removed and padded URLs
+ // silently register as non-URLs (shipping relative-path cards).
+ " https://example.com/x.png ".PathIsUrl().Should().BeTrue("trimmed URL must resolve");
+ }
+ }
+}
diff --git a/Generation/Converters/Argumentum.AssetConverter/Ontology/OwlAdapter.cs b/Generation/Converters/Argumentum.AssetConverter/Ontology/OwlAdapter.cs
index ca8a485c4..b17729c3e 100644
--- a/Generation/Converters/Argumentum.AssetConverter/Ontology/OwlAdapter.cs
+++ b/Generation/Converters/Argumentum.AssetConverter/Ontology/OwlAdapter.cs
@@ -282,15 +282,15 @@ public bool CheckIsNarrowerConcept(RDFResource concept, RDFResource parentConcep
{
try
{
- return SKOSHelper.CheckHasNarrowerConcept(_ontology, parentConcept, concept);
- }
- catch
- {
- return _ontology.AnnotationAxioms.OfType()
- .Any(a => a.AnnotationProperty.GetIRI().Equals(SKOSVocabulary.Narrower.URI)
- && a.SubjectIRI.Equals(parentConcept.URI)
- && a.ValueIRI != null && a.ValueIRI.Equals(concept.URI));
+ if (SKOSHelper.CheckHasNarrowerConcept(_ontology, parentConcept, concept)) return true;
}
+ catch { }
+ // SKOSHelper may return false silently (no exception) — fall back to annotation scanning.
+ // The annotation scanner now uses .ToString() comparison (RDFResource type-mismatch fix).
+ return _ontology.AnnotationAxioms.OfType()
+ .Any(a => a.AnnotationProperty.GetIRI().ToString() == SKOSVocabulary.Narrower.ToString()
+ && a.SubjectIRI.ToString() == parentConcept.ToString()
+ && a.ValueIRI != null && a.ValueIRI.ToString() == concept.ToString());
}
public List GetConceptPreferredLabels(RDFResource concept)
@@ -311,27 +311,43 @@ public List GetConceptDocumentation(RDFResource concept, SKOSDo
public List GetExactMatchConcepts(RDFResource concept)
{
- try { return SKOSHelper.GetExactMatchConcepts(_ontology, concept); }
- catch { return GetResourceAnnotations(concept, SKOSVocabulary.ExactMatch); }
+ try
+ {
+ var result = SKOSHelper.GetExactMatchConcepts(_ontology, concept);
+ if (result != null && result.Count > 0) return result;
+ }
+ catch { }
+ // SKOSHelper may return empty silently — fall back to annotation scanning (.ToString() fix).
+ return GetResourceAnnotations(concept, SKOSVocabulary.ExactMatch);
}
public List GetCloseMatchConcepts(RDFResource concept)
{
- try { return SKOSHelper.GetCloseMatchConcepts(_ontology, concept); }
- catch { return GetResourceAnnotations(concept, SKOSVocabulary.CloseMatch); }
+ try
+ {
+ var result = SKOSHelper.GetCloseMatchConcepts(_ontology, concept);
+ if (result != null && result.Count > 0) return result;
+ }
+ catch { }
+ return GetResourceAnnotations(concept, SKOSVocabulary.CloseMatch);
}
public List GetRelatedMatchConcepts(RDFResource concept)
{
- try { return SKOSHelper.GetRelatedMatchConcepts(_ontology, concept); }
- catch { return GetResourceAnnotations(concept, SKOSVocabulary.RelatedMatch); }
+ try
+ {
+ var result = SKOSHelper.GetRelatedMatchConcepts(_ontology, concept);
+ if (result != null && result.Count > 0) return result;
+ }
+ catch { }
+ return GetResourceAnnotations(concept, SKOSVocabulary.RelatedMatch);
}
private List GetAnnotationSubjects(RDFResource typeResource)
{
return _ontology.AnnotationAxioms.OfType()
- .Where(a => a.AnnotationProperty.GetIRI().Equals(RDFVocabulary.RDF.TYPE.URI)
- && a.ValueIRI != null && a.ValueIRI.Equals(typeResource.URI))
+ .Where(a => a.AnnotationProperty.GetIRI().ToString() == RDFVocabulary.RDF.TYPE.ToString()
+ && a.ValueIRI != null && a.ValueIRI.ToString() == typeResource.ToString())
.Select(a => new RDFResource(a.SubjectIRI.ToString()))
.ToList();
}
@@ -339,7 +355,7 @@ private List GetAnnotationSubjects(RDFResource typeResource)
private List GetAnnotationObjects(RDFResource property)
{
return _ontology.AnnotationAxioms.OfType()
- .Where(a => a.AnnotationProperty.GetIRI().Equals(property.URI)
+ .Where(a => a.AnnotationProperty.GetIRI().ToString() == property.ToString()
&& a.ValueIRI != null)
.Select(a => new RDFResource(a.ValueIRI.ToString()))
.ToList();
@@ -348,8 +364,8 @@ private List GetAnnotationObjects(RDFResource property)
private List GetResourceAnnotations(RDFResource subject, RDFResource property)
{
return _ontology.AnnotationAxioms.OfType()
- .Where(a => a.AnnotationProperty.GetIRI().Equals(property.URI)
- && a.SubjectIRI.Equals(subject.URI)
+ .Where(a => a.AnnotationProperty.GetIRI().ToString() == property.ToString()
+ && a.SubjectIRI.ToString() == subject.ToString()
&& a.ValueIRI != null)
.Select(a => new RDFResource(a.ValueIRI.ToString()))
.ToList();
@@ -358,8 +374,8 @@ private List GetResourceAnnotations(RDFResource subject, RDFResourc
private List GetLiteralAnnotations(RDFResource subject, RDFResource property)
{
return _ontology.AnnotationAxioms.OfType()
- .Where(a => a.AnnotationProperty.GetIRI().Equals(property.URI)
- && a.SubjectIRI.Equals(subject.URI)
+ .Where(a => a.AnnotationProperty.GetIRI().ToString() == property.ToString()
+ && a.SubjectIRI.ToString() == subject.ToString()
&& a.ValueLiteral != null)
.Select(a => {
var literal = a.ValueLiteral.GetLiteral();
@@ -370,7 +386,7 @@ private List GetLiteralAnnotations(RDFResource subject, RDFReso
public bool CheckHasClass(RDFResource resource)
{
- return _ontology.DeclarationAxioms.Any(ax => ax.Entity is OWLClass cls && cls.GetIRI().Equals(resource.URI));
+ return _ontology.DeclarationAxioms.Any(ax => ax.Entity is OWLClass cls && cls.GetIRI().ToString() == resource.ToString());
}
public OWLOntology GetOntology()
@@ -386,9 +402,9 @@ public List GetResourcesByType(RDFResource typeResource)
public bool HasAnnotation(RDFResource subject, RDFResource property, RDFResource value)
{
return _ontology.AnnotationAxioms.OfType()
- .Any(a => a.AnnotationProperty.GetIRI().Equals(property.URI)
- && a.SubjectIRI.Equals(subject.URI)
- && a.ValueIRI != null && a.ValueIRI.Equals(value.URI));
+ .Any(a => a.AnnotationProperty.GetIRI().ToString() == property.ToString()
+ && a.SubjectIRI.ToString() == subject.ToString()
+ && a.ValueIRI != null && a.ValueIRI.ToString() == value.ToString());
}
}
}
\ No newline at end of file