Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Argumentum.AssetConverter.Ontology;
using FluentAssertions;
using Xunit;

namespace Argumentum.AssetConverter.Tests.Ontology
{
/// <summary>
/// Contract pins for #499 Phase 2 — the Virtues OWL generator (<see cref="VirtueOwlGeneratorConfig"/>
/// / <see cref="VirtueOwlDocumentConfig"/>). Part 2 mirrors the Fallacies OWL pass but emits
/// argumentum_virtues.owl, and links each Virtue concept to its Walton argumentation scheme via a
/// custom <c>aif:goodTenorOf</c> object property (the Virtue AIF_skosMappingType holds the FR-prose
/// critical question, not a skos:*Match token, so the Fallacies switch cannot fire). Cross-corpus
/// Virtue↔Fallacy links (crossLink_Opposes PK→URI) are deferred to Phase 3.
///
/// These tests pin the pure, deterministic contract additively (no I/O, no pipeline run):
/// the IRI transform, the generator defaults, and the <c>aif:goodTenorOf</c> URI construction.
/// </summary>
public class VirtueOwlGenerationContractTests
{
// ─────────────────────────────────────────────────────────────────────────────
// (1) GetId — the Virtue concept IRI fragment derives from TitleEn via the SAME transform as
// the Fallacies pass (Humanizer Camelize + strip apostrophes/hyphens/commas). Pinning it
// separately guards against drift between the two generator configs.
// ─────────────────────────────────────────────────────────────────────────────

[Theory]
[InlineData("Arguments vertueux", "argumentsVertueux")]
[InlineData("Rigorous language", "rigorousLanguage")]
public void GetId_MirrorsFallaciesIriTransform(string input, string expected)
{
VirtueOwlDocumentConfig.GetId(input).Should().Be(expected,
"the Virtue concept IRI fragment must use the same Camelize + strip transform as OwlDocumentConfig.GetId.");
}

[Fact]
public void GetId_StripsApostrophesHyphensCommas()
{
// Mirrors the proven OwlDocumentConfig.GetId contract (byte-identical transform), asserted
// on the exact same input strings so a divergence between the two configs is caught.
// Camelize preserves punctuation; the trailing Replace chain strips apostrophes/hyphens/commas;
// accented characters are preserved (Humanizer does not ASCII-fold).
VirtueOwlDocumentConfig.GetId("A-B").Should().Be("aB", "hyphens are stripped.");
VirtueOwlDocumentConfig.GetId("A, B").Should().Be("aB", "commas are stripped.");
VirtueOwlDocumentConfig.GetId("Appel à l'autorité").Should().Be("appelÀLautorité",
"apostrophes stripped, accents preserved, segment starts uppercased by Camelize.");
}

// ─────────────────────────────────────────────────────────────────────────────
// (2) Generator defaults — lock the mono-corpus Virtues ontology identity so a config reset
// never silently repoints the document name / namespace / dataset.
// ─────────────────────────────────────────────────────────────────────────────

[Fact]
public void DefaultConfig_EmitsVirtuesOntologyIdentity()
{
var config = new VirtueOwlGeneratorConfig();

config.DocumentConfigs.Should().ContainSingle();
var doc = config.DocumentConfigs[0];

doc.DocumentName.Should().Be("argumentum_virtues.owl");
doc.DataSet.Should().Be(KnownDataSets.VirtuesTaxonomy,
"the Virtues OWL pass must feed off the Virtues taxonomy dataset, not the Fallacies one.");
doc.OntologyNamespace.Should().Be("https://www.argumentum.games/argumentum_virtues.owl#");
doc.ExternalReferenceOntologyNamespaceURI.Should().StartWith("http://www.arg.dundee.ac.uk/aif#",
"the AIF namespace must be the Dundee AIF ontology so aif:goodTenorOf resolves externally.");
}

// ─────────────────────────────────────────────────────────────────────────────
// (3) aif:goodTenorOf URI — the custom object property linking a Virtue to the Walton scheme
// it is the good tenor of. Phase 2 carries the critical-question prose as rdfs:comment;
// this predicate is the structured link. Pin the URI so Phase 3 cross-corpus can reuse it.
// ─────────────────────────────────────────────────────────────────────────────

[Fact]
public void GoodTenorOf_Predicate_ResolvesInAifNamespace()
{
var doc = new VirtueOwlDocumentConfig
{
ExternalReferenceOntologyNamespaceURI = "http://www.arg.dundee.ac.uk/aif#"
};

// Mirrors the URI assembled in VirtueOwlDocumentConfig.CreateVirtueOwlDocument.
var goodTenorOfUri = $"{doc.ExternalReferenceOntologyNamespaceURI}goodTenorOf";

goodTenorOfUri.Should().Be("http://www.arg.dundee.ac.uk/aif#goodTenorOf",
"aif:goodTenorOf must live in the AIF namespace (already used by the Fallacies pass), not a new namespace.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,5 +159,73 @@ public void Virtue_LoadFromContent_RequiredColumnAbsent_Throws()
act.Should().Throw<HeaderValidationException>(
"title_fr is a non-Optional column; its absence must fail loud, not silently null the property");
}

/// <summary>
/// #499 Phase 1 — the 12 relational/AIF columns appended to the Virtues prod CSV map to their
/// properties when present. The representative row mirrors prod pk=71 (the "Valid results"
/// Virtue, Math-accuracy family): crossLink_Opposes is the semicolon list of prevented Fallacy
/// PKs, AIF_skosDirectRef the Walton scheme label, AIF_skosMappingType the FR-prose critical
/// question (RFC4180-quoted in prod because it contains a comma). The 9 structural-empty
/// columns stay null — a Virtue's only cross-taxon link is "Opposes" its prevented family.
/// </summary>
[Fact]
public void Virtue_LoadFromContent_Maps499RelationalAndAifColumns()
{
var newCols =
"crossLink_PredatesOn,crossLink_Denounces,crossLink_Leverages,crossLink_Allows," +
"crossLink_Opposes,crossLink_Inverts,crossLink_Mirrors,crossLink_IsRelatedTo," +
"AIF_skosDirectRef,AIF_skosExceptionRef,AIF_skosOther,AIF_skosMappingType";
// Representative pk=71 row: 3 of 12 populated, 9 empty by design. The FR-prose
// AIF_skosMappingType is quoted because it contains a comma (RFC4180, as written in prod).
var newRow =
",,,," + // PredatesOn, Denounces, Leverages, Allows (empty)
"681;690" + // crossLink_Opposes (prevented Fallacy PKs)
",,,," + // Inverts, Mirrors, IsRelatedTo (empty)
"Argument from Sign" + // AIF_skosDirectRef (Walton scheme label)
",,," + // AIF_skosExceptionRef, AIF_skosOther (empty)
"\"Les résultats sont-ils correctement calculés, reproductibles et cohérents ?\""; // AIF_skosMappingType

var csv = VirtueRequiredHeader + "," + newCols + "\n" + VirtueRequiredRow + "," + newRow + "\n";

var virtues = Virtue.LoadFromContent(csv);

virtues.Should().ContainSingle();
var v = virtues[0];
v.CrossLinkOpposes.Should().Be("681;690");
v.AIFSkosDirectRef.Should().Be("Argument from Sign");
v.AIFSkosMappingType.Should().Be("Les résultats sont-ils correctement calculés, reproductibles et cohérents ?");
// 9 structural empties — empty string by design (cell present but empty in prod; the 7
// other relation types are Fallacy↔Fallacy internal semantics, AIF Exception/Other are
// Fallacy attributes). CsvHelper maps an empty cell to "", not null.
v.CrossLinkPredatesOn.Should().BeEmpty();
v.CrossLinkDenounces.Should().BeEmpty();
v.CrossLinkLeverages.Should().BeEmpty();
v.CrossLinkAllows.Should().BeEmpty();
v.CrossLinkInverts.Should().BeEmpty();
v.CrossLinkMirrors.Should().BeEmpty();
v.CrossLinkIsRelatedTo.Should().BeEmpty();
v.AIFSkosExceptionRef.Should().BeEmpty();
v.AIFSkosOther.Should().BeEmpty();
}

/// <summary>
/// The 12 #499 columns are all .Optional() — a header set without them (a pre-#499 CSV, or a
/// FR-only harvest header) must load without throwing. Losing an .Optional() flag would make
/// HeaderValidated throw when the harvest loads a header missing the relational block.
/// </summary>
[Fact]
public void Virtue_LoadFromContent_499ColumnsAbsent_DoesNotThrow()
{
// VirtueRequiredHeader has none of the 12 #499 relational/AIF columns.
var csv = VirtueRequiredHeader + "\n" + VirtueRequiredRow + "\n";

var act = () => Virtue.LoadFromContent(csv);

act.Should().NotThrow();
var virtues = Virtue.LoadFromContent(csv);
virtues.Should().ContainSingle();
virtues[0].CrossLinkOpposes.Should().BeNull("crossLink_Opposes is Optional and absent → null, not throw");
virtues[0].AIFSkosMappingType.Should().BeNull();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,8 @@ public class AssetConverterConfig

public OwlGeneratorConfig OwlGeneratorConfig { get; set; } = new OwlGeneratorConfig();

public VirtueOwlGeneratorConfig VirtueOwlGeneratorConfig { get; set; } = new VirtueOwlGeneratorConfig();

public TaxonomyValidatorConfig TaxonomyValidatorConfig { get; set; } = new TaxonomyValidatorConfig();

public OwlValidatorConfig OwlValidatorConfig { get; set; } = new OwlValidatorConfig();
Expand Down Expand Up @@ -512,10 +514,12 @@ public async Task<bool> Apply()
if (AsynchronousPipeline)
{
tasks.Add(Task.Run(() => OwlGeneratorConfig.Apply(this)));
tasks.Add(Task.Run(() => VirtueOwlGeneratorConfig.Apply(this)));
}
else
{
await OwlGeneratorConfig.Apply(this);
await VirtueOwlGeneratorConfig.Apply(this);
}
}

Expand Down
32 changes: 32 additions & 0 deletions Generation/Converters/Argumentum.AssetConverter/Entities/Virtue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,23 @@ public class Virtue : CsvBase<Virtue, VirtueClassMap>, IMindMapItem
public string DescriptionEs { get; set; }
public string RemarkEs { get; set; }
public string LinkEs { get; set; }

// #499 Phase 1 — 12 relational/AIF columns appended to the Virtues prod CSV (66→78).
// crossLink_Opposes is the only one populated (the prevented Fallacy-family PK list);
// the other 7 relation types + AIF Exception/Other are structurally empty by design.
public string CrossLinkPredatesOn { get; set; }
public string CrossLinkDenounces { get; set; }
public string CrossLinkLeverages { get; set; }
public string CrossLinkAllows { get; set; }
public string CrossLinkOpposes { get; set; }
public string CrossLinkInverts { get; set; }
public string CrossLinkMirrors { get; set; }
public string CrossLinkIsRelatedTo { get; set; }

public string AIFSkosDirectRef { get; set; }
public string AIFSkosExceptionRef { get; set; }
public string AIFSkosOther { get; set; }
public string AIFSkosMappingType { get; set; }
}

public sealed class VirtueClassMap : ClassMap<Virtue>
Expand Down Expand Up @@ -122,6 +139,21 @@ public VirtueClassMap()
Map(m => m.DescriptionEs).Name("description_es").Optional();
Map(m => m.RemarkEs).Name("remark_es").Optional();
Map(m => m.LinkEs).Name("link_es").Optional();

// #499 Phase 1 — 12 relational/AIF columns. All Optional(): 9 are structurally
// empty by design; 3 are populated for the 222 real Virtue nodes (pk=0 root empty).
Map(m => m.CrossLinkPredatesOn).Name("crossLink_PredatesOn").Optional();
Map(m => m.CrossLinkDenounces).Name("crossLink_Denounces").Optional();
Map(m => m.CrossLinkLeverages).Name("crossLink_Leverages").Optional();
Map(m => m.CrossLinkAllows).Name("crossLink_Allows").Optional();
Map(m => m.CrossLinkOpposes).Name("crossLink_Opposes").Optional();
Map(m => m.CrossLinkInverts).Name("crossLink_Inverts").Optional();
Map(m => m.CrossLinkMirrors).Name("crossLink_Mirrors").Optional();
Map(m => m.CrossLinkIsRelatedTo).Name("crossLink_IsRelatedTo").Optional();
Map(m => m.AIFSkosDirectRef).Name("AIF_skosDirectRef").Optional();
Map(m => m.AIFSkosExceptionRef).Name("AIF_skosExceptionRef").Optional();
Map(m => m.AIFSkosOther).Name("AIF_skosOther").Optional();
Map(m => m.AIFSkosMappingType).Name("AIF_skosMappingType").Optional();
}
}
}
Loading
Loading