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
200 changes: 200 additions & 0 deletions MyIA.AI.Shared.Tests/FiltersConcreteTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
using System.CodeDom;
using MyIA.AI.ComponentModel.Filters;
using Xunit;

namespace MyIA.AI.Shared.Tests;

/// <summary>
/// Battery for the A3-T1 concrete filters (EPIC #7265, pépite #19088): the reflective
/// filters that consume the core contracts — <see cref="SimpleFilter{T}"/>,
/// <see cref="PredicateFilter{T}"/>, <see cref="ListFilter{T}"/> — including the corrected
/// semantics documented in the PR body (ListFilter subject/element bugs, empty-list vacuous
/// truth) and their composition inside a <see cref="FilterExpression"/>. The contracts, the
/// composition algebra and the comparers live in the stacked tranche and are covered by
/// <c>FiltersCoreTests</c>.
/// </summary>
public class FiltersConcreteTests
{
private sealed class SampleTag
{
public string Label { get; set; } = "";
}

private sealed class SampleItem
{
public string Name { get; set; } = "";
public int Score { get; set; }
public List<SampleTag> Tags { get; set; } = new();
}

private static SampleItem Item(int score, string name = "a") =>
new() { Name = name, Score = score };

// --- SimpleFilter : chaque opérateur ---

[Theory]
[InlineData(CodeBinaryOperatorType.ValueEquality, 5, 5, true)]
[InlineData(CodeBinaryOperatorType.ValueEquality, 5, 6, false)]
[InlineData(CodeBinaryOperatorType.GreaterThan, 7, 5, true)]
[InlineData(CodeBinaryOperatorType.GreaterThan, 5, 5, false)]
[InlineData(CodeBinaryOperatorType.GreaterThanOrEqual, 5, 5, true)]
[InlineData(CodeBinaryOperatorType.GreaterThanOrEqual, 4, 5, false)]
[InlineData(CodeBinaryOperatorType.LessThan, 3, 5, true)]
[InlineData(CodeBinaryOperatorType.LessThan, 5, 5, false)]
[InlineData(CodeBinaryOperatorType.LessThanOrEqual, 5, 5, true)]
[InlineData(CodeBinaryOperatorType.LessThanOrEqual, 6, 5, false)]
public void SimpleFilter_Operators(CodeBinaryOperatorType op, int score, int value, bool expected)
{
var filter = new SimpleFilter<int>("Score", op, value);
Assert.Equal(expected, filter.Match(Item(score)));
}

[Fact]
public void SimpleFilter_IdentityEquality_UsesEqualsNotCompareTo()
{
// IdentityEquality on strings: Equals, so two distinct-but-equal strings match.
var filter = new SimpleFilter<string>("Name", CodeBinaryOperatorType.IdentityEquality, "alpha");
Assert.True(filter.Match(new SampleItem { Name = "alpha" }));
Assert.False(filter.Match(new SampleItem { Name = "beta" }));
}

[Fact]
public void SimpleFilter_UnsupportedOperator_ThrowsNotSupported()
{
var filter = new SimpleFilter<int>("Score", CodeBinaryOperatorType.Modulus, 1);
Assert.Throws<NotSupportedException>(() => filter.Match(Item(5)));
}

[Fact]
public void SimpleFilter_MissingProperty_ThrowsKeyNotFound()
{
var filter = new SimpleFilter<int>("NoSuchProp", CodeBinaryOperatorType.ValueEquality, 1);
Assert.Throws<KeyNotFoundException>(() => filter.Match(Item(5)));
}

[Fact]
public void SimpleFilter_DescriptorShape()
{
var filter = new SimpleFilter<int>("Score", CodeBinaryOperatorType.GreaterThan, 3);
Assert.StartsWith("fScore-GreaterThan-3", filter.GetArgs());
var expr = Assert.IsType<CodeBinaryOperatorExpression>(filter.GetCodeExpression());
Assert.Equal(CodeBinaryOperatorType.GreaterThan, expr.Operator);
var propRef = Assert.IsType<CodePropertyReferenceExpression>(expr.Left);
Assert.Equal("Score", propRef.PropertyName);
var primitive = Assert.IsType<CodePrimitiveExpression>(expr.Right);
Assert.Equal(3, primitive.Value);
}

// --- PredicateFilter ---

[Fact]
public void PredicateFilter_MatchesPropertyThroughPredicate()
{
var filter = new PredicateFilter<int>("Score", s => s > 10);
Assert.True(filter.Match(Item(11)));
Assert.False(filter.Match(Item(10)));
}

[Fact]
public void PredicateFilter_CodeExpressionNotSupported()
=> Assert.Throws<NotSupportedException>(
() => new PredicateFilter<int>("Score", _ => true).GetCodeExpression());

// --- ListFilter : bugs d'origine corrigés ---

private static IFilter LabelFilter(string label) =>
new PredicateFilter<string>("Label", l => l == label);

[Fact]
public void ListFilter_Any_OneElementMatches()
{
var item = new SampleItem
{
Tags = new() { new() { Label = "x" }, new() { Label = "y" } },
};
var filter = new ListFilter<SampleTag>("Tags", LabelFilter("y"), ScopeOperator.Any);
Assert.True(filter.Match(item));
}

[Fact]
public void ListFilter_Any_NoElementMatches()
{
var item = new SampleItem
{
Tags = new() { new() { Label = "x" }, new() { Label = "z" } },
};
var filter = new ListFilter<SampleTag>("Tags", LabelFilter("y"), ScopeOperator.Any);
Assert.False(filter.Match(item));
}

[Fact]
public void ListFilter_All_EveryElementMatches()
{
var item = new SampleItem
{
Tags = new() { new() { Label = "y" }, new() { Label = "y" } },
};
var filter = new ListFilter<SampleTag>("Tags", LabelFilter("y"), ScopeOperator.All);
Assert.True(filter.Match(item));
}

[Fact]
public void ListFilter_All_OneElementFails()
{
var item = new SampleItem
{
Tags = new() { new() { Label = "y" }, new() { Label = "x" } },
};
var filter = new ListFilter<SampleTag>("Tags", LabelFilter("y"), ScopeOperator.All);
Assert.False(filter.Match(item));
}

[Fact]
public void ListFilter_EmptyList_AnyFalse_AllTrue()
{
// Corrected: empty list satisfies All (vacuous truth) — the VB original
// returned the default False for both scopes.
var item = new SampleItem { Tags = new() };
Assert.False(new ListFilter<SampleTag>("Tags", LabelFilter("y"), ScopeOperator.Any).Match(item));
Assert.True(new ListFilter<SampleTag>("Tags", LabelFilter("y"), ScopeOperator.All).Match(item));
}

[Fact]
public void ListFilter_InnerFilterReceives_Elements_NotContent()
{
// Regression guard for the corrected bug: the inner filter must see each list
// element (its Label property), not the outer content object (no Label on SampleItem).
var item = new SampleItem
{
Name = "outer",
Tags = new() { new() { Label = "outer" } },
};
var filter = new ListFilter<SampleTag>("Tags", LabelFilter("outer"), ScopeOperator.All);
Assert.True(filter.Match(item));
}

[Fact]
public void ListFilter_GetArgs_Shape()
{
var filter = new ListFilter<SampleTag>("Tags", LabelFilter("y"), ScopeOperator.Any);
Assert.StartsWith("fAny-f", filter.GetArgs());
}

// --- Composition : expression imbriquant ListFilter ---

[Fact]
public void Composition_ListFilter_Inside_FilterExpression()
{
var item = new SampleItem
{
Score = 5,
Tags = new() { new() { Label = "y" } },
};
var expr = new FilterExpression(
OperatorFilterExp.And,
new SimpleFilter<int>("Score", CodeBinaryOperatorType.ValueEquality, 5),
new ListFilter<SampleTag>("Tags", LabelFilter("y"), ScopeOperator.Any));
Assert.True(expr.Match(item));
Assert.True(expr.IsSimpleMatch);
}
}
74 changes: 74 additions & 0 deletions MyIA.AI.Shared/ComponentModel/Filters/ListFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.CodeDom;
using System.Collections;

namespace MyIA.AI.ComponentModel.Filters;

/// <summary>How a <see cref="ListFilter{T}"/> aggregates its inner filter over a list property.</summary>
public enum ScopeOperator
{
/// <summary>At least one element matches.</summary>
Any,

/// <summary>All elements match.</summary>
All
}

/// <summary>
/// <see cref="IFilter"/> applying an inner filter to each element of a list property of the
/// content object, aggregated with a <see cref="ScopeOperator"/>. Modernized port of
/// Aricie.Shared ListFilter (EPIC #7265, pépite A3, T1).
/// </summary>
/// <typeparam name="T">Element type of the list property.</typeparam>
/// <remarks>
/// Measured deviations from the VB source (see PR body): the original read the list
/// property from the filter itself (<c>GetValue(Me)</c>) instead of the content object, and
/// matched the inner filter against the content object instead of each list element — both
/// restored to the evident intent here. The sentinel loop is rewritten as LINQ
/// All/Any (same observable semantics); the empty-list case is fixed so that an empty list
/// satisfies <see cref="ScopeOperator.All"/> (vacuous truth) instead of returning the VB
/// default false.
/// </remarks>
public class ListFilter<T> : IFilter
{
public ListFilter(IConvertible propName, IFilter innerFilter, ScopeOperator scope)
{
PropertyName = propName;
Scope = scope;
InnerFilter = innerFilter;
}

/// <summary>Name of the list property on the content object.</summary>
public IConvertible PropertyName { get; set; } = "";

/// <summary>Aggregation over the list elements.</summary>
public ScopeOperator Scope { get; set; }

/// <summary>Filter applied to each element.</summary>
public IFilter InnerFilter { get; set; } = default!;

/// <inheritdoc />
public bool IsSimpleMatch => true;

/// <inheritdoc />
public bool Match<T1>(T1 content) => GetSimpleMatch(content);

protected bool GetSimpleMatch<Y>(Y content)
{
var myProperty = ReflectionCache.Properties(typeof(Y))[PropertyName.ToString(null)];
var innerList = (IEnumerable)myProperty.GetValue(content)!;
// Iterate with the element type T so the inner filter resolves properties on
// typeof(T) — the VB original iterated As T (matching on the runtime element type,
// not the static list type).
return Scope == ScopeOperator.All
? innerList.Cast<T>().All(e => InnerFilter.Match(e))
: innerList.Cast<T>().Any(e => InnerFilter.Match(e));
}

/// <inheritdoc />
public CodeExpression GetCodeExpression()
=> throw new NotSupportedException(
"scope \"All\" is not supported in complex expressions with list filters nor complex inner filters");

/// <inheritdoc />
public string GetArgs() => $"f{Scope}-{InnerFilter.GetArgs()}";
}
51 changes: 51 additions & 0 deletions MyIA.AI.Shared/ComponentModel/Filters/PredicateFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.CodeDom;
using System.Reflection;

namespace MyIA.AI.ComponentModel.Filters;

/// <summary>
/// <see cref="IFilter"/> applying a <see cref="Predicate{T}"/> to a property of the content
/// object resolved by reflection. Modernized port of Aricie.Shared PredicateFilter
/// (EPIC #7265, pépite A3, T1).
/// </summary>
/// <typeparam name="T">Type the predicate receives (the property value is cast to it).</typeparam>
public class PredicateFilter<T> : IFilter
{
public PredicateFilter(IConvertible propName, Predicate<T> objPredicate)
{
PropertyName = propName;
Predicate = objPredicate;
}

/// <summary>Name of the content property fed to the predicate.</summary>
public IConvertible PropertyName { get; set; } = "";

/// <summary>Predicate applied to the property value.</summary>
public Predicate<T> Predicate { get; set; } = default!;

/// <inheritdoc />
public bool IsSimpleMatch => true;

protected virtual bool GetSimpleMatch<Y>(Y content)
{
PropertyInfo myProperty = ReflectionCache.Properties(typeof(Y))[PropertyName.ToString(null)];
return Predicate.Invoke((T)myProperty.GetValue(content)!);
}

/// <inheritdoc />
/// <remarks>Port note: the VB guard on <see cref="IsSimpleMatch"/> had a dead else-branch
/// (the property is constant true); the port calls the simple match directly.</remarks>
public bool Match<Y>(Y content) => GetSimpleMatch(content);

/// <inheritdoc />
/// <remarks>A delegate has no CodeDom representation — the VB original threw
/// NotImplementedException; the port uses the standard
/// <see cref="NotSupportedException"/> idiom for an unsupported operation.</remarks>
public virtual CodeExpression GetCodeExpression()
=> throw new NotSupportedException("No CodeDom expression for predicate filters: a delegate has no CodeDom representation.");

public virtual string GetTempArgs() => $"f{PropertyName.ToString(null)}-{Predicate}";

/// <inheritdoc />
public string GetArgs() => GetTempArgs();
}
Loading
Loading