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