refactor: compile where/sort/distinct and Advanced Search through expression trees (#2625)
## Summary `where`, `sort by`, `distinct` and the Advanced Search property test now compile through expression trees instead of the hand-rolled IL in `Emitter.cs`. The emitter and its three `Reflection.Emit` compilers were RunUO-era code from before expression trees existed; they were the only way to avoid per-object reflection at the time, and they are not any more. Net: ~1,900 lines of IL bookkeeping deleted, one comparison engine instead of two, faster per object, cheaper to compile, collectible, and `Nullable<T>` properties work. ## Why - **Bugs hid in the IL.** Equality on a type with value semantics but no `IComparable` (`TextDefinition`) was a raw `ceq`, so `where Message = 1060847` never matched. A chained binding (`Message.Number`) dereferenced every link unguarded, so the first swept object with a null intermediate killed the sweep with an NRE. A constant narrower or unsigned than `int` threw before compiling. A struct with no `CompareTo` reached `ceq` on two unboxed values, which is invalid IL. Every dynamic assembly was `Run`, so each `[global where` grew the process for good. - **Advanced Search had its own engine.** Per entity and per leaf it re-split the expression, scanned the runtime type's properties by name, read the value by reflection, re-parsed the right-hand side and dispatched on type through ~300 lines of `CompareValues`. Same job, second implementation, second set of bugs. ## What changed - `ICondition.Compile(MethodEmitter)` becomes `ICondition.Build(ParameterExpression)` returning an `Expression`. `ConditionalCompiler` assembles a `Func<object, bool>`, `SortCompiler` a `Comparison<T>`, `DistinctCompiler` both comparer interfaces over one lambda. The parsed constant is an `Expression.Constant`, so the generated type, its constructor and the per-condition field for non-primitive constants disappear with `PropertyValue`. - `PropertyExpressions` holds the shared pieces: the chain walk with its null-intermediate guard, `CompareTo` resolution with the old null ordering, the integral/enum operator path, and constant parsing. - `BaseExtension.Optimize` loses its `ref AssemblyEmitter` parameter. An out-of-tree extension that overrides `Optimize` needs to drop it. - Advanced Search keeps its grammar (`~` negates, `@` is AND, `|` is OR and binds looser, string `>` is "starts with") and translates each leaf into the same conditions, compiled once per declaring type per search and memoized across the workers. Float and double keep their typed-precision tolerance through a small `EpsilonCondition` in the Advanced Search folder. ## Semantics preserved - Equality on a non-comparable reference type is `object.Equals`, never reference identity. A non-comparable struct boxes into the same call. - A null intermediate in a chained binding is no match, and stays no match under negation. Sort and distinct read it as `default(T)`. - Unsigned relational compares stay unsigned; integral primitives and enums use the operator directly, nothing widens to a signed type. `float`, `double`, `decimal`, `string` and structs still go through the type's own `CompareTo`, so `string` equality stays culture-sensitive exactly as before. - Only `==` and `!=` are valid for non-comparable types; a relational operator still throws at build time. - `TypeCondition` is still first and still null-checks the cast target. ## Behavior changes - **`Nullable<T>` works**, with C# lifted semantics in `where`: two nulls are equal, a null and a value are unequal, a null satisfies no relation. Sort keeps a total order with unset values at one end. A null *reference* keeps the ordering it had. - **Advanced Search**: a leaf that cannot be parsed or resolved is no match even under `~` (it used to negate the failure and match every entity); `null` is the null value for equality on strings, nullables and reference types, as in `where`; dotted names walk into a property; static properties are no longer searchable. ## Measurements `where`, from the handoff (Debug test host, single condition, ratios not absolutes): | Approach | ns/object | Compile | Collectible | LOC | |---|---:|---:|---|---:| | `AssemblyBuilder` + IL (before) | 20.5 | 0.311 ms | no (`Run`) | ~1,916 | | Expression trees (after) | ~12 | 0.187 ms warm | yes | ~600 | Advanced Search, Release, one `SkillTeleporter`, 2M evaluations per leaf: | Leaf | Before | After | |---|---:|---:| | `Hue=5` | 137 ns | 20 ns | | `Name~~gate` | 121 ns | 39 ns | | `Skill=Magery` | 116 ns | 21 ns | | `Weight>0.5` | 122 ns | 28 ns | Plus 0.5 to 2 ms to compile each declaring type a search meets (12 ms for the first compile in the process). All of it runs on the search workers; nothing new touches the loop. ## Also fixed: Advanced Search map filters Found while testing the property test in game. The map boxes are independent checkboxes, but the worker applied each ticked map as "must be on this map", so ticking two or more (all maps and Internal, say) rejected every entity before any other filter ran. Present since #1649; the default of Felucca alone never showed it. An entity now passes when its map is any of the ticked ones, with none ticked meaning no map constraint. Pinned by a worker test. ## Test plan - [x] `UOContent.Tests`: 862 passed, 0 failed - [x] `Server.Tests`: 869 passed, 0 failed - [x] Every commit builds and its tests pass on its own (bisectable) - [ ] In game: `[global where`, `[area where`, `[condition`, `sort by`, `distinct`, Advanced Search property test
This commit is contained in:
parent
865a8cf218
commit
535a098996
24 changed files with 2294 additions and 2142 deletions
|
|
@ -0,0 +1,113 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Commands;
|
||||
using Server.Commands.Generic;
|
||||
using Server.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Commands;
|
||||
|
||||
// `sort by` and `distinct` compile the same property chain the conditionals do, and had the same
|
||||
// crash on a null link in the middle. Ordering cannot answer "no match" the way a condition can,
|
||||
// so an unreadable binding reads as the default value instead -- which is what a null link means.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class ChainedBindingSortTests : IDisposable
|
||||
{
|
||||
private readonly List<Item> _items = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
for (var i = 0; i < _items.Count; i++)
|
||||
{
|
||||
_items[i].Delete();
|
||||
}
|
||||
|
||||
_items.Clear();
|
||||
}
|
||||
|
||||
private SkillTeleporter Teleporter(TextDefinition message)
|
||||
{
|
||||
var tp = new SkillTeleporter { Message = message };
|
||||
_items.Add(tp);
|
||||
return tp;
|
||||
}
|
||||
|
||||
private static Property Bind(string binding)
|
||||
{
|
||||
var prop = new Property(binding);
|
||||
prop.BindTo(typeof(SkillTeleporter), PropertyAccess.Read);
|
||||
return prop;
|
||||
}
|
||||
|
||||
private static IComparer<object> Sorter(string binding) =>
|
||||
SortCompiler.Compile<object>(
|
||||
typeof(SkillTeleporter),
|
||||
[new OrderInfo(Bind(binding), true)]
|
||||
);
|
||||
|
||||
private static IComparer<object> Distincter(string binding) =>
|
||||
DistinctCompiler.Compile<object>(
|
||||
typeof(SkillTeleporter),
|
||||
[Bind(binding)]
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void SortingOnAChainSurvivesANullIntermediate()
|
||||
{
|
||||
var named = Teleporter(TextDefinition.Of("Alpha"));
|
||||
var blank = Teleporter(null);
|
||||
|
||||
var comparer = Sorter("Message.String");
|
||||
|
||||
// A consistent total order is the contract; which end the blanks land on is not asserted.
|
||||
var forward = comparer.Compare(named, blank);
|
||||
|
||||
Assert.NotEqual(0, forward);
|
||||
Assert.Equal(-Math.Sign(forward), Math.Sign(comparer.Compare(blank, named)));
|
||||
Assert.Equal(0, comparer.Compare(blank, blank));
|
||||
Assert.Equal(0, comparer.Compare(named, named));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortingOnAChainStillOrdersReadableValues()
|
||||
{
|
||||
var alpha = Teleporter(TextDefinition.Of("Alpha"));
|
||||
var beta = Teleporter(TextDefinition.Of("Beta"));
|
||||
|
||||
var comparer = Sorter("Message.String");
|
||||
|
||||
Assert.True(comparer.Compare(alpha, beta) < 0);
|
||||
Assert.True(comparer.Compare(beta, alpha) > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistinctOnAChainSurvivesANullIntermediate()
|
||||
{
|
||||
var named = Teleporter(TextDefinition.Of("Alpha"));
|
||||
var blank = Teleporter(null);
|
||||
var alsoBlank = Teleporter(null);
|
||||
|
||||
var comparer = Distincter("Message.String");
|
||||
|
||||
Assert.NotEqual(0, comparer.Compare(named, blank));
|
||||
Assert.Equal(0, comparer.Compare(blank, alsoBlank));
|
||||
}
|
||||
|
||||
// An unchained binding never had the problem and must keep working untouched.
|
||||
[Fact]
|
||||
public void SortingOnAPlainBindingIsUnchanged()
|
||||
{
|
||||
var low = Teleporter(null);
|
||||
low.Hue = 1;
|
||||
|
||||
var high = Teleporter(null);
|
||||
high.Hue = 2;
|
||||
|
||||
var comparer = Sorter("Hue");
|
||||
|
||||
Assert.True(comparer.Compare(low, high) < 0);
|
||||
Assert.True(comparer.Compare(high, low) > 0);
|
||||
Assert.Equal(0, comparer.Compare(low, low));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Commands;
|
||||
using Server.Commands.Generic;
|
||||
using Server.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Commands;
|
||||
|
||||
// Two shapes the IL emitter got wrong or could not express. A chained binding ending in a value
|
||||
// type (Message.Number) reused a temp while its value was still live, so every pair compared
|
||||
// equal and `sort by` was a no-op. A struct with no CompareTo fell through to a raw Ceq, which is
|
||||
// not valid IL for a non-primitive struct. Both are pinned here against the expression-tree port.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class ConditionalCompilerEdgeTests : IDisposable
|
||||
{
|
||||
private readonly List<Item> _items = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
for (var i = 0; i < _items.Count; i++)
|
||||
{
|
||||
_items[i].Delete();
|
||||
}
|
||||
|
||||
_items.Clear();
|
||||
}
|
||||
|
||||
private SkillTeleporter Teleporter(TextDefinition message)
|
||||
{
|
||||
var tp = new SkillTeleporter { Message = message };
|
||||
_items.Add(tp);
|
||||
return tp;
|
||||
}
|
||||
|
||||
private static Property Bind(Type type, string binding)
|
||||
{
|
||||
var prop = new Property(binding);
|
||||
prop.BindTo(type, PropertyAccess.Read);
|
||||
return prop;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortingOnAChainedValueTypeOrdersValues()
|
||||
{
|
||||
var low = Teleporter(TextDefinition.Of(1060847));
|
||||
var high = Teleporter(TextDefinition.Of(1060848));
|
||||
|
||||
var comparer = SortCompiler.Compile<object>(
|
||||
typeof(SkillTeleporter),
|
||||
[new OrderInfo(Bind(typeof(SkillTeleporter), "Message.Number"), true)]
|
||||
);
|
||||
|
||||
Assert.True(comparer.Compare(low, high) < 0);
|
||||
Assert.True(comparer.Compare(high, low) > 0);
|
||||
Assert.Equal(0, comparer.Compare(low, low));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistinctOnAChainedValueTypeSeparatesValues()
|
||||
{
|
||||
var low = Teleporter(TextDefinition.Of(1060847));
|
||||
var high = Teleporter(TextDefinition.Of(1060848));
|
||||
var alsoLow = Teleporter(TextDefinition.Of(1060847));
|
||||
|
||||
var comparer = DistinctCompiler.Compile<object>(
|
||||
typeof(SkillTeleporter),
|
||||
[Bind(typeof(SkillTeleporter), "Message.Number")]
|
||||
);
|
||||
|
||||
Assert.NotEqual(0, comparer.Compare(low, high));
|
||||
Assert.Equal(0, comparer.Compare(low, alsoLow));
|
||||
}
|
||||
|
||||
public class Subject
|
||||
{
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Rectangle2D Bounds { get; set; } = new(new Point2D(1, 2), new Point2D(3, 4));
|
||||
}
|
||||
|
||||
private static bool Check(ComparisonOperator op, string value)
|
||||
{
|
||||
var compiled = ConditionalCompiler.Compile(
|
||||
typeof(Subject),
|
||||
[TypeCondition.Default, new ComparisonCondition(Bind(typeof(Subject), "Bounds"), false, op, value)]
|
||||
);
|
||||
|
||||
return compiled.Verify(new Subject());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonComparableStructComparesByValueEquality()
|
||||
{
|
||||
Assert.True(Check(ComparisonOperator.Equal, "(1, 2)+(3, 4)"));
|
||||
Assert.False(Check(ComparisonOperator.Equal, "(1, 2)+(3, 5)"));
|
||||
Assert.False(Check(ComparisonOperator.NotEqual, "(1, 2)+(3, 4)"));
|
||||
Assert.True(Check(ComparisonOperator.NotEqual, "(1, 2)+(3, 5)"));
|
||||
}
|
||||
|
||||
// Only == and != are meaningful without a CompareTo; a relational operator is an error at
|
||||
// compile time, not garbage at run time.
|
||||
[Theory]
|
||||
[InlineData(ComparisonOperator.Greater)]
|
||||
[InlineData(ComparisonOperator.GreaterEqual)]
|
||||
[InlineData(ComparisonOperator.Lesser)]
|
||||
[InlineData(ComparisonOperator.LesserEqual)]
|
||||
public void NonComparableStructRejectsRelationalOperators(ComparisonOperator op)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => Check(op, "(1, 2)+(3, 4)"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Commands;
|
||||
using Server.Commands.Generic;
|
||||
using Server.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Commands;
|
||||
|
||||
// The distinct comparer doubles as an IEqualityComparer: objects that compare equal on every
|
||||
// listed property must hash the same, whatever shape the property is -- an int, a reference that
|
||||
// may be null, a struct, or a chain with a null link in the middle.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class DistinctComparerTests : IDisposable
|
||||
{
|
||||
private readonly List<Item> _items = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
for (var i = 0; i < _items.Count; i++)
|
||||
{
|
||||
_items[i].Delete();
|
||||
}
|
||||
|
||||
_items.Clear();
|
||||
}
|
||||
|
||||
private SkillTeleporter Teleporter(Action<SkillTeleporter> setup = null)
|
||||
{
|
||||
var tp = new SkillTeleporter();
|
||||
setup?.Invoke(tp);
|
||||
_items.Add(tp);
|
||||
return tp;
|
||||
}
|
||||
|
||||
private static IEqualityComparer<object> Comparer(params string[] bindings)
|
||||
{
|
||||
var props = new Property[bindings.Length];
|
||||
|
||||
for (var i = 0; i < bindings.Length; i++)
|
||||
{
|
||||
props[i] = new Property(bindings[i]);
|
||||
props[i].BindTo(typeof(SkillTeleporter), PropertyAccess.Read);
|
||||
}
|
||||
|
||||
return (IEqualityComparer<object>)DistinctCompiler.Compile<object>(typeof(SkillTeleporter), props);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualObjectsHashTheSameAcrossPropertyShapes()
|
||||
{
|
||||
var comparer = Comparer("Hue", "Name", "Location", "Message.String");
|
||||
|
||||
var one = Teleporter(tp =>
|
||||
{
|
||||
tp.Hue = 7;
|
||||
tp.Name = "Gate";
|
||||
tp.Location = new Point3D(1, 2, 3);
|
||||
tp.Message = TextDefinition.Of("Alpha");
|
||||
});
|
||||
|
||||
var two = Teleporter(tp =>
|
||||
{
|
||||
tp.Hue = 7;
|
||||
tp.Name = "Gate";
|
||||
tp.Location = new Point3D(1, 2, 3);
|
||||
tp.Message = TextDefinition.Of("Alpha");
|
||||
});
|
||||
|
||||
Assert.True(comparer.Equals(one, two));
|
||||
Assert.Equal(comparer.GetHashCode(one), comparer.GetHashCode(two));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullReferencesAndNullChainLinksHashWithoutThrowing()
|
||||
{
|
||||
var comparer = Comparer("Name", "Message.String");
|
||||
|
||||
var blank = Teleporter();
|
||||
var alsoBlank = Teleporter();
|
||||
|
||||
Assert.True(comparer.Equals(blank, alsoBlank));
|
||||
Assert.Equal(comparer.GetHashCode(blank), comparer.GetHashCode(alsoBlank));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferingObjectsAreNotEqual()
|
||||
{
|
||||
var comparer = Comparer("Hue", "Location");
|
||||
|
||||
var one = Teleporter(tp => tp.Hue = 1);
|
||||
var two = Teleporter(tp => tp.Hue = 2);
|
||||
|
||||
Assert.False(comparer.Equals(one, two));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
using System;
|
||||
using Server;
|
||||
using Server.Commands;
|
||||
using Server.Commands.Generic;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Commands;
|
||||
|
||||
// The conditional compiler emits into a fresh dynamic assembly for every command invocation --
|
||||
// there is no per-type cache, so `Run` (which can never be unloaded) grows the process by one
|
||||
// assembly per `[global where`. And PropertyValue could only load a handful of primitive constant
|
||||
// types, so a comparison against anything narrower or unsigned than int threw instead of running.
|
||||
public class EmitterRobustnessTests
|
||||
{
|
||||
public class Subject
|
||||
{
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public byte Tiny { get; set; } = 5;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public sbyte Signed { get; set; } = -5;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public short Small { get; set; } = -300;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public ushort Key { get; set; } = 40000;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public uint Big { get; set; } = 3_000_000_000;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public ulong Huge { get; set; } = 18_000_000_000_000_000_000;
|
||||
}
|
||||
|
||||
private static bool Check(string binding, ComparisonOperator op, string value)
|
||||
{
|
||||
var prop = new Property(binding);
|
||||
prop.BindTo(typeof(Subject), PropertyAccess.Read);
|
||||
|
||||
var compiled = ConditionalCompiler.Compile(
|
||||
typeof(Subject),
|
||||
[TypeCondition.Default, new ComparisonCondition(prop, false, op, value)]
|
||||
);
|
||||
|
||||
return compiled.Verify(new Subject());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Tiny", "5")]
|
||||
[InlineData("Signed", "-5")]
|
||||
[InlineData("Small", "-300")]
|
||||
[InlineData("Key", "40000")]
|
||||
[InlineData("Big", "3000000000")]
|
||||
[InlineData("Huge", "18000000000000000000")]
|
||||
public void NarrowAndUnsignedIntegersCompareByEquality(string binding, string value)
|
||||
{
|
||||
Assert.True(Check(binding, ComparisonOperator.Equal, value));
|
||||
Assert.False(Check(binding, ComparisonOperator.NotEqual, value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Tiny", "4")]
|
||||
[InlineData("Signed", "-6")]
|
||||
[InlineData("Small", "-301")]
|
||||
[InlineData("Key", "39999")]
|
||||
[InlineData("Big", "2999999999")]
|
||||
[InlineData("Huge", "17999999999999999999")]
|
||||
public void NarrowAndUnsignedIntegersCompareRelationally(string binding, string value)
|
||||
{
|
||||
Assert.True(Check(binding, ComparisonOperator.Greater, value));
|
||||
Assert.False(Check(binding, ComparisonOperator.Lesser, value));
|
||||
}
|
||||
|
||||
// Unsigned values above the signed range must not wrap into a negative comparison.
|
||||
[Fact]
|
||||
public void UnsignedComparisonsDoNotWrapThroughSignedMath()
|
||||
{
|
||||
Assert.True(Check("Big", ComparisonOperator.Greater, "2147483647"));
|
||||
Assert.True(Check("Huge", ComparisonOperator.Greater, "9223372036854775807"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmittedAssembliesAreCollectible()
|
||||
{
|
||||
var prop = new Property("Tiny");
|
||||
prop.BindTo(typeof(Subject), PropertyAccess.Read);
|
||||
|
||||
var compiled = ConditionalCompiler.Build(
|
||||
typeof(Subject),
|
||||
[TypeCondition.Default, new ComparisonCondition(prop, false, ComparisonOperator.Equal, "5")]
|
||||
).Compile();
|
||||
|
||||
var method = compiled.Method;
|
||||
|
||||
Assert.True(
|
||||
method.IsCollectible,
|
||||
"The conditional compiler compiles one delegate per command invocation; code that is "
|
||||
+ $"not collectible ({method.GetType().Name} in {method.Module}) can never be unloaded, "
|
||||
+ "so every [global where would grow the process for good."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
using System;
|
||||
using Server;
|
||||
using Server.Commands;
|
||||
using Server.Commands.Generic;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Commands;
|
||||
|
||||
// A `where` clause against a Nullable<T> property. No [CommandProperty] in the tree is nullable
|
||||
// yet, so nothing is broken in practice -- but the conditional compiler should not fall over on
|
||||
// one either: a set value compares as its underlying type, an unset one equals `null` and
|
||||
// satisfies no relation, just as C#'s lifted operators would have it.
|
||||
public class NullablePropertyConditionTests
|
||||
{
|
||||
public class Subject
|
||||
{
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int? Count { get; set; }
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public TimeSpan? Delay { get; set; }
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public SkillName? Skill { get; set; }
|
||||
}
|
||||
|
||||
private static bool Check(Subject subject, string binding, ComparisonOperator op, string value)
|
||||
{
|
||||
var prop = new Property(binding);
|
||||
prop.BindTo(typeof(Subject), PropertyAccess.Read);
|
||||
|
||||
var conditional = new ObjectConditional(
|
||||
typeof(Subject),
|
||||
[[TypeCondition.Default, new ComparisonCondition(prop, false, op, value)]]
|
||||
);
|
||||
|
||||
return conditional.CheckCondition(subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetValueComparesByEquality()
|
||||
{
|
||||
var subject = new Subject { Count = 5 };
|
||||
|
||||
Assert.True(Check(subject, "Count", ComparisonOperator.Equal, "5"));
|
||||
Assert.False(Check(subject, "Count", ComparisonOperator.Equal, "4"));
|
||||
Assert.True(Check(subject, "Count", ComparisonOperator.NotEqual, "4"));
|
||||
Assert.False(Check(subject, "Count", ComparisonOperator.NotEqual, "5"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetValueComparesRelationally()
|
||||
{
|
||||
var subject = new Subject { Count = 5 };
|
||||
|
||||
Assert.True(Check(subject, "Count", ComparisonOperator.Greater, "4"));
|
||||
Assert.False(Check(subject, "Count", ComparisonOperator.Lesser, "4"));
|
||||
Assert.True(Check(subject, "Count", ComparisonOperator.GreaterEqual, "5"));
|
||||
Assert.True(Check(subject, "Count", ComparisonOperator.LesserEqual, "5"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsetValueEqualsNull()
|
||||
{
|
||||
Assert.True(Check(new Subject(), "Count", ComparisonOperator.Equal, "null"));
|
||||
Assert.False(Check(new Subject { Count = 5 }, "Count", ComparisonOperator.Equal, "null"));
|
||||
Assert.True(Check(new Subject { Count = 5 }, "Count", ComparisonOperator.NotEqual, "null"));
|
||||
}
|
||||
|
||||
// Lifted semantics: null is never greater, lesser, or equal to a value -- only unequal.
|
||||
[Fact]
|
||||
public void UnsetValueSatisfiesNoRelation()
|
||||
{
|
||||
var subject = new Subject();
|
||||
|
||||
Assert.False(Check(subject, "Count", ComparisonOperator.Equal, "0"));
|
||||
Assert.True(Check(subject, "Count", ComparisonOperator.NotEqual, "0"));
|
||||
Assert.False(Check(subject, "Count", ComparisonOperator.Greater, "0"));
|
||||
Assert.False(Check(subject, "Count", ComparisonOperator.GreaterEqual, "0"));
|
||||
Assert.False(Check(subject, "Count", ComparisonOperator.Lesser, "0"));
|
||||
Assert.False(Check(subject, "Count", ComparisonOperator.LesserEqual, "0"));
|
||||
}
|
||||
|
||||
// A struct that compares through CompareTo rather than a primitive operator.
|
||||
[Fact]
|
||||
public void NullableStructComparesThroughCompareTo()
|
||||
{
|
||||
var set = new Subject { Delay = TimeSpan.FromSeconds(5) };
|
||||
|
||||
Assert.True(Check(set, "Delay", ComparisonOperator.Equal, "00:00:05"));
|
||||
Assert.True(Check(set, "Delay", ComparisonOperator.Greater, "00:00:04"));
|
||||
Assert.False(Check(set, "Delay", ComparisonOperator.Lesser, "00:00:04"));
|
||||
Assert.False(Check(set, "Delay", ComparisonOperator.Equal, "null"));
|
||||
|
||||
var unset = new Subject();
|
||||
|
||||
Assert.True(Check(unset, "Delay", ComparisonOperator.Equal, "null"));
|
||||
Assert.False(Check(unset, "Delay", ComparisonOperator.Greater, "00:00:04"));
|
||||
Assert.False(Check(unset, "Delay", ComparisonOperator.Lesser, "00:00:04"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullableEnumComparesByNameAndOrder()
|
||||
{
|
||||
var set = new Subject { Skill = SkillName.Magery };
|
||||
|
||||
Assert.True(Check(set, "Skill", ComparisonOperator.Equal, "Magery"));
|
||||
Assert.False(Check(set, "Skill", ComparisonOperator.Equal, "Anatomy"));
|
||||
Assert.True(Check(set, "Skill", ComparisonOperator.Greater, "Alchemy"));
|
||||
Assert.False(Check(set, "Skill", ComparisonOperator.Equal, "null"));
|
||||
|
||||
var unset = new Subject();
|
||||
|
||||
Assert.True(Check(unset, "Skill", ComparisonOperator.Equal, "null"));
|
||||
Assert.False(Check(unset, "Skill", ComparisonOperator.Equal, "Magery"));
|
||||
Assert.False(Check(unset, "Skill", ComparisonOperator.Greater, "Alchemy"));
|
||||
}
|
||||
|
||||
// `sort by` on a nullable: values order by the underlying type and an unset value takes a
|
||||
// consistent place at one end, the same convention a null reference already had.
|
||||
[Fact]
|
||||
public void SortingOnANullableOrdersValuesAndPlacesUnsetConsistently()
|
||||
{
|
||||
var prop = new Property("Count");
|
||||
prop.BindTo(typeof(Subject), PropertyAccess.Read);
|
||||
|
||||
var comparer = SortCompiler.Compile<object>(typeof(Subject), [new OrderInfo(prop, true)]);
|
||||
|
||||
var low = new Subject { Count = 1 };
|
||||
var high = new Subject { Count = 2 };
|
||||
var unset = new Subject();
|
||||
|
||||
Assert.True(comparer.Compare(low, high) < 0);
|
||||
Assert.True(comparer.Compare(high, low) > 0);
|
||||
Assert.Equal(0, comparer.Compare(low, low));
|
||||
|
||||
var forward = comparer.Compare(high, unset);
|
||||
|
||||
Assert.NotEqual(0, forward);
|
||||
Assert.Equal(-Math.Sign(forward), Math.Sign(comparer.Compare(unset, high)));
|
||||
Assert.Equal(0, comparer.Compare(unset, new Subject()));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Commands.Generic;
|
||||
using Server.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Commands;
|
||||
|
||||
// `where` compiles conditions to IL. Two shapes were broken there: a property type with value
|
||||
// equality but no IComparable fell through to a raw Ceq (reference equality, so never true), and
|
||||
// a chained binding dereferenced every link with no null guard (so the first object with a null
|
||||
// intermediate took the whole sweep down with an NRE).
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class ObjectConditionalTests : IDisposable
|
||||
{
|
||||
private readonly List<Item> _items = [];
|
||||
private readonly Mobile _from = new() { AccessLevel = AccessLevel.Developer };
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
for (var i = 0; i < _items.Count; i++)
|
||||
{
|
||||
_items[i].Delete();
|
||||
}
|
||||
|
||||
_items.Clear();
|
||||
_from.Delete();
|
||||
}
|
||||
|
||||
private SkillTeleporter Teleporter(TextDefinition message)
|
||||
{
|
||||
var tp = new SkillTeleporter { Message = message };
|
||||
_items.Add(tp);
|
||||
return tp;
|
||||
}
|
||||
|
||||
private bool Check(object target, params string[] condition)
|
||||
{
|
||||
var args = new string[condition.Length + 1];
|
||||
args[0] = nameof(SkillTeleporter);
|
||||
Array.Copy(condition, 0, args, 1, condition.Length);
|
||||
|
||||
return ObjectConditional.ParseDirect(_from, args, 0, args.Length).CheckCondition(target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualityUsesValueSemanticsNotReferenceIdentity()
|
||||
{
|
||||
var tp = Teleporter(TextDefinition.Of(1060847));
|
||||
|
||||
Assert.True(Check(tp, "Message", "=", "1060847"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InequalityUsesValueSemanticsNotReferenceIdentity()
|
||||
{
|
||||
var tp = Teleporter(TextDefinition.Of(1060847));
|
||||
|
||||
Assert.False(Check(tp, "Message", "!=", "1060847"));
|
||||
Assert.True(Check(tp, "Message", "!=", "1234567"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringValuedDefinitionsCompareByValue()
|
||||
{
|
||||
var tp = Teleporter(TextDefinition.Of("Hail, traveller."));
|
||||
|
||||
Assert.True(Check(tp, "Message", "=", "Hail, traveller."));
|
||||
Assert.False(Check(tp, "Message", "=", "Farewell."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullComparisonStillWorks()
|
||||
{
|
||||
Assert.True(Check(Teleporter(null), "Message", "=", "null"));
|
||||
Assert.False(Check(Teleporter(TextDefinition.Of(1060847)), "Message", "=", "null"));
|
||||
}
|
||||
|
||||
// The sweep case: [global where SkillTeleporter Message.Number = X hits teleporters whose
|
||||
// Message was never set long before it hits one that matches.
|
||||
[Fact]
|
||||
public void ChainedBindingOnANullIntermediateIsFalseNotAnException()
|
||||
{
|
||||
var blank = Teleporter(null);
|
||||
|
||||
Assert.False(Check(blank, "Message.Number", "=", "1060847"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChainedBindingStillMatchesWhenIntermediateIsPresent()
|
||||
{
|
||||
var tp = Teleporter(TextDefinition.Of(1060847));
|
||||
|
||||
Assert.True(Check(tp, "Message.Number", "=", "1060847"));
|
||||
Assert.False(Check(tp, "Message.Number", "=", "1234567"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChainedBindingOnANullIntermediateIsFalseUnderNegationToo()
|
||||
{
|
||||
var blank = Teleporter(null);
|
||||
|
||||
Assert.False(Check(blank, "not", "Message.Number", "=", "1060847"));
|
||||
}
|
||||
|
||||
// The string operators (contains / starts / ends / =~) compile through StringCondition, which
|
||||
// chains the same way ComparisonCondition does and so had the same null-intermediate crash.
|
||||
[Theory]
|
||||
[InlineData("contains")]
|
||||
[InlineData("contains~")]
|
||||
[InlineData("starts")]
|
||||
[InlineData("ends")]
|
||||
[InlineData("=~")]
|
||||
[InlineData("!=~")]
|
||||
public void StringOperatorsOnANullIntermediateAreFalseNotAnException(string oper)
|
||||
{
|
||||
var blank = Teleporter(null);
|
||||
|
||||
Assert.False(Check(blank, "Message.String", oper, "gate"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringOperatorsOnANullIntermediateAreFalseUnderNegationToo()
|
||||
{
|
||||
var blank = Teleporter(null);
|
||||
|
||||
Assert.False(Check(blank, "not", "Message.String", "contains", "gate"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringOperatorsStillMatchThroughAChain()
|
||||
{
|
||||
var tp = Teleporter(TextDefinition.Of("Moongate"));
|
||||
|
||||
Assert.True(Check(tp, "Message.String", "contains", "gate"));
|
||||
Assert.True(Check(tp, "Message.String", "starts", "Moon"));
|
||||
Assert.True(Check(tp, "Message.String", "ends", "gate"));
|
||||
Assert.True(Check(tp, "Message.String", "=~", "moongate"));
|
||||
Assert.False(Check(tp, "Message.String", "contains", "portal"));
|
||||
}
|
||||
|
||||
// The chain resolves here -- Message is set -- but its String is null because the definition
|
||||
// holds a cliloc. That is the final value, which StringCondition already guarded.
|
||||
[Fact]
|
||||
public void StringOperatorsHandleANullFinalValue()
|
||||
{
|
||||
var tp = Teleporter(TextDefinition.Of(1060847));
|
||||
|
||||
Assert.False(Check(tp, "Message.String", "contains", "gate"));
|
||||
Assert.False(Check(tp, "Message.String", "=~", "gate"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnchainedStringOperatorsStillWork()
|
||||
{
|
||||
var tp = Teleporter(null);
|
||||
tp.Name = "Moongate";
|
||||
|
||||
Assert.True(Check(tp, "Name", "contains", "gate"));
|
||||
Assert.True(Check(tp, "Name", "=~", "moongate"));
|
||||
Assert.False(Check(tp, "Name", "contains", "portal"));
|
||||
}
|
||||
|
||||
// Guards for the comparison paths the equality change must not disturb. Ints, strings and
|
||||
// enums are IComparable, so they route through CompareTo and never reach CompareEquality --
|
||||
// these prove that routing is intact.
|
||||
[Fact]
|
||||
public void NumericComparisonsStillWork()
|
||||
{
|
||||
var tp = Teleporter(null);
|
||||
tp.Hue = 42;
|
||||
|
||||
Assert.True(Check(tp, "Hue", "=", "42"));
|
||||
Assert.False(Check(tp, "Hue", "=", "43"));
|
||||
Assert.True(Check(tp, "Hue", ">", "41"));
|
||||
Assert.True(Check(tp, "Hue", "<", "43"));
|
||||
Assert.True(Check(tp, "Hue", "!=", "43"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringComparisonsStillWork()
|
||||
{
|
||||
var tp = Teleporter(null);
|
||||
tp.Name = "gate";
|
||||
|
||||
Assert.True(Check(tp, "Name", "=", "gate"));
|
||||
Assert.False(Check(tp, "Name", "=", "portal"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumComparisonsStillWork()
|
||||
{
|
||||
var tp = Teleporter(null);
|
||||
tp.Skill = SkillName.Magery;
|
||||
|
||||
Assert.True(Check(tp, "Skill", "=", "Magery"));
|
||||
Assert.False(Check(tp, "Skill", "=", "Anatomy"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
using System;
|
||||
using Server;
|
||||
using Server.Engines.AdvancedSearch;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests;
|
||||
|
||||
// The Advanced Search property test compiles through the same conditions a `where` clause does.
|
||||
// The grammar is the gump's own; these pin its operators and precedence, and that a leaf which
|
||||
// cannot be resolved or parsed is "no match" rather than an exception on the search worker.
|
||||
public class AdvancedSearchConditionsTests
|
||||
{
|
||||
public sealed class Inner
|
||||
{
|
||||
public int Value { get; set; } = 7;
|
||||
}
|
||||
|
||||
public sealed class LegacyParseType
|
||||
{
|
||||
public string Value { get; private init; }
|
||||
public static LegacyParseType Parse(string s) => new() { Value = s };
|
||||
public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value;
|
||||
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
|
||||
}
|
||||
|
||||
public class Subject
|
||||
{
|
||||
public int Hue { get; set; } = 5;
|
||||
public string Name { get; set; } = "Moongate";
|
||||
public bool Movable { get; set; } = true;
|
||||
public bool Visible { get; set; }
|
||||
public double Weight { get; set; } = 0.1 + 0.2;
|
||||
public float Ratio { get; set; } = 0.1f;
|
||||
public TimeSpan Delay { get; set; } = TimeSpan.FromMinutes(5);
|
||||
public Guid Id { get; set; } = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
public Layer Layer { get; set; } = Layer.OneHanded;
|
||||
public object Reference { get; set; } = new();
|
||||
public LegacyParseType Legacy { get; set; } = LegacyParseType.Parse("alpha");
|
||||
public Inner Child { get; set; } = new();
|
||||
public int? Maybe { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Derived : Subject
|
||||
{
|
||||
}
|
||||
|
||||
private static bool Check(string test, Subject subject = null) =>
|
||||
AdvancedSearchConditions.Compile(typeof(Subject), test)(subject ?? new Subject());
|
||||
|
||||
[Theory]
|
||||
[InlineData("Hue=abc")] // not a number
|
||||
[InlineData("Hue=99999999999")] // overflows int
|
||||
[InlineData("Hue=0xZZ")] // bad hex
|
||||
[InlineData("Layer=Bogus")] // not an enum member
|
||||
[InlineData("Delay=notaspan")]
|
||||
[InlineData("Bogus=1")] // no such property
|
||||
[InlineData("Hue=")] // no value
|
||||
[InlineData("Hue")] // no operator
|
||||
public void UnusableLeafIsNoMatchAndDoesNotThrow(string test)
|
||||
{
|
||||
var ex = Record.Exception(() => Assert.False(Check(test)));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
// The old evaluator negated a failed comparison, so `~Hue=abc` matched every entity.
|
||||
[Fact]
|
||||
public void UnusableLeafStaysNoMatchUnderNegation()
|
||||
{
|
||||
Assert.False(Check("~Hue=abc"));
|
||||
Assert.False(Check("~Bogus=1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumericOperators()
|
||||
{
|
||||
Assert.True(Check("Hue=5"));
|
||||
Assert.True(Check("Hue==5"));
|
||||
Assert.True(Check("Hue=0x5"));
|
||||
Assert.False(Check("Hue!=5"));
|
||||
Assert.True(Check("Hue!4"));
|
||||
Assert.True(Check("Hue>4"));
|
||||
Assert.True(Check("Hue<6"));
|
||||
Assert.True(Check("Hue>=5"));
|
||||
Assert.True(Check("Hue<=5"));
|
||||
Assert.False(Check("Hue~5"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegationPrefix()
|
||||
{
|
||||
Assert.False(Check("~Hue=5"));
|
||||
Assert.True(Check("~Hue=4"));
|
||||
}
|
||||
|
||||
// '|' binds looser than '@'.
|
||||
[Theory]
|
||||
[InlineData("Movable=1", true)]
|
||||
[InlineData("Visible=1", false)]
|
||||
[InlineData("Visible=1@Visible=1|Movable=1", true)] // (F&&F)||T
|
||||
[InlineData("Movable=1|Visible=1@Visible=1", true)] // T||(F&&F)
|
||||
[InlineData("Movable=1@Visible=1", false)]
|
||||
[InlineData("Movable=1@Movable=1", true)]
|
||||
[InlineData("Visible=1|Visible=1", false)]
|
||||
public void Precedence(string test, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, Check(test));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Name=Moongate", true)]
|
||||
[InlineData("Name=moongate", false)]
|
||||
[InlineData("Name!Moongate", false)]
|
||||
[InlineData("Name>Moon", true)]
|
||||
[InlineData("Name<gate", true)]
|
||||
[InlineData("Name~ong", true)]
|
||||
[InlineData("Name~>moon", true)]
|
||||
[InlineData("Name~<GATE", true)]
|
||||
[InlineData("Name~~ONG", true)]
|
||||
[InlineData("Name~=MOONGATE", true)]
|
||||
[InlineData("Name~!MOONGATE", false)]
|
||||
[InlineData("Name>=Moon", false)] // no such string operator
|
||||
public void StringOperators(string test, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, Check(test));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringNullIsTheNullStringForEqualityAndTextOtherwise()
|
||||
{
|
||||
var unnamed = new Subject { Name = null };
|
||||
|
||||
Assert.True(Check("Name=null", unnamed));
|
||||
Assert.False(Check("Name=null"));
|
||||
Assert.False(Check("Name~null", unnamed));
|
||||
Assert.True(Check("Name~null", new Subject { Name = "nullable" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BooleanAcceptsSwitchWordsAndOnlyEquality()
|
||||
{
|
||||
Assert.True(Check("Movable=true"));
|
||||
Assert.True(Check("Movable=1"));
|
||||
Assert.True(Check("Movable=on"));
|
||||
Assert.True(Check("Movable=Enabled"));
|
||||
Assert.True(Check("Visible=off"));
|
||||
Assert.False(Check("Movable=maybe"));
|
||||
Assert.False(Check("Movable>0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumIgnoresCaseAndOrdersByValue()
|
||||
{
|
||||
Assert.True(Check("Layer=onehanded"));
|
||||
Assert.True(Check("Layer>Invalid"));
|
||||
Assert.False(Check("Layer=TwoHanded"));
|
||||
}
|
||||
|
||||
// Floating point compares to a tolerance derived from the typed value.
|
||||
[Fact]
|
||||
public void FloatingPointUsesEpsilon()
|
||||
{
|
||||
Assert.True(Check("Weight=0.3"));
|
||||
Assert.False(Check("Weight=0.31"));
|
||||
Assert.True(Check("Weight>0.2"));
|
||||
Assert.True(Check("Weight<=0.3"));
|
||||
Assert.True(Check("Ratio=0.1"));
|
||||
Assert.False(Check("Ratio=0.2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeSpanParsesAndCompares()
|
||||
{
|
||||
Assert.True(Check("Delay=00:05:00"));
|
||||
Assert.False(Check("Delay=00:10:00"));
|
||||
Assert.True(Check("Delay>00:01:00"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueTypeWithoutHotPathParsesThroughTypes()
|
||||
{
|
||||
Assert.True(Check("Id=00000000-0000-0000-0000-000000000001"));
|
||||
Assert.False(Check("Id=00000000-0000-0000-0000-000000000002"));
|
||||
}
|
||||
|
||||
// A pre-IParsable type with only a static Parse(string) is still searchable, compared against
|
||||
// a real parsed instance rather than the raw text.
|
||||
[Fact]
|
||||
public void LegacyParseStringParsesThroughTypes()
|
||||
{
|
||||
Assert.True(Check("Legacy=alpha"));
|
||||
Assert.False(Check("Legacy=beta"));
|
||||
}
|
||||
|
||||
// A reference type with no CompareTo answers equality only; ordering is no match, not a throw.
|
||||
[Fact]
|
||||
public void ReferenceTypeOrderingIsNoMatchAndDoesNotThrow()
|
||||
{
|
||||
var ex = Record.Exception(() => Assert.False(Check("Reference>whatever")));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DottedNameWalksIntoTheProperty()
|
||||
{
|
||||
Assert.True(Check("Child.Value=7"));
|
||||
Assert.False(Check("Child.Value=8"));
|
||||
Assert.False(Check("Child.Value=7", new Subject { Child = null }));
|
||||
Assert.False(Check("~Child.Value=7", new Subject { Child = null }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullableCompares()
|
||||
{
|
||||
Assert.True(Check("Maybe=null"));
|
||||
Assert.False(Check("Maybe=5"));
|
||||
Assert.True(Check("Maybe=5", new Subject { Maybe = 5 }));
|
||||
Assert.True(Check("Maybe>4", new Subject { Maybe = 5 }));
|
||||
}
|
||||
|
||||
// Two runtime types resolving the same declared property share one compiled predicate.
|
||||
[Fact]
|
||||
public void SubclassesShareThePredicateCompiledForTheDeclaringType()
|
||||
{
|
||||
var cache = new AdvancedSearchConditions.Cache();
|
||||
|
||||
var forBase = AdvancedSearchConditions.GetPredicate(cache, typeof(Subject), "Hue=5");
|
||||
var forDerived = AdvancedSearchConditions.GetPredicate(cache, typeof(Derived), "Hue=5");
|
||||
|
||||
Assert.Same(forBase, forDerived);
|
||||
Assert.True(forDerived(new Derived()));
|
||||
}
|
||||
}
|
||||
|
|
@ -7,21 +7,27 @@ namespace UOContent.Tests;
|
|||
[Collection("Sequential UOContent Tests")]
|
||||
public class AdvancedSearchTypesTests
|
||||
{
|
||||
public class Subject
|
||||
{
|
||||
public Poison Venom { get; set; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareValues_Poison_ReferenceTypeParsedViaTypes()
|
||||
public void Poison_ReferenceTypeParsedViaTypes()
|
||||
{
|
||||
PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set
|
||||
|
||||
// Poison is a reference type implementing ISpanParsable; it can't use the compile-time span
|
||||
// path and routes through the shared Server.Types converter. Poison.Parse returns the
|
||||
// registered singleton, so "= Lethal" is a reference-equality match — this is the case that
|
||||
// previously compared a Poison against the raw string and always failed.
|
||||
var prop = Poison.Lethal;
|
||||
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lethal", "="));
|
||||
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lesser", "="));
|
||||
// Poison is a reference type implementing ISpanParsable; its value routes through the shared
|
||||
// Server.Types converter. Poison.Parse returns the registered singleton, so "= Lethal" is a
|
||||
// reference-equality match — this is the case that previously compared a Poison against the
|
||||
// raw string and always failed.
|
||||
var subject = new Subject { Venom = Poison.Lethal };
|
||||
|
||||
Assert.True(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lethal")(subject));
|
||||
Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lesser")(subject));
|
||||
|
||||
var ex = Record.Exception(() =>
|
||||
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "notapoison", "=")));
|
||||
Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=notapoison")(subject)));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
using System;
|
||||
using Server;
|
||||
using Server.Engines.AdvancedSearch;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests;
|
||||
|
||||
public class AdvancedSearchUtilitiesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("abc")] // not a number -> was FormatException
|
||||
[InlineData("99999999999")] // overflows int -> was OverflowException
|
||||
[InlineData("0xZZ")] // bad hex -> was FormatException
|
||||
public void CompareValues_BadNumeric_ReturnsFalse_DoesNotThrow(string value)
|
||||
{
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
var result = AdvancedSearchUtilities.CompareValues(typeof(int), 5, value, ">");
|
||||
Assert.False(result);
|
||||
});
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Bogus")] // not a member -> was ArgumentException
|
||||
[InlineData("onehandedxyz")] // not a member, even case-insensitively -> was ArgumentException
|
||||
public void CompareValues_BadEnum_ReturnsFalse_DoesNotThrow(string value)
|
||||
{
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
var result = AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, value, "=");
|
||||
Assert.False(result);
|
||||
});
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareValues_ValidEnum_IgnoresCase()
|
||||
{
|
||||
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, "onehanded", "="));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// leaf value is "T"/"F"; evalLeaf returns leaf=="T"
|
||||
[InlineData("T", true)]
|
||||
[InlineData("F", false)]
|
||||
[InlineData("F@F|T", true)] // (F&&F)||T = T (buggy code gave F&&(F||T)=F)
|
||||
[InlineData("T|F@F", true)] // T||(F&&F) = T (buggy code gave (T||F)&&F=F)
|
||||
[InlineData("T@F", false)]
|
||||
[InlineData("T@T", true)]
|
||||
[InlineData("F|F", false)]
|
||||
public void EvaluateBoolean_Precedence(string expr, bool expected)
|
||||
{
|
||||
// State is unused here; the leaf evaluator just checks the span equals "T".
|
||||
var result = AdvancedSearchUtilities.EvaluateBoolean(expr, 0, static (_, leaf) => leaf.SequenceEqual("T"));
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareValues_ReferenceType_EqualityByString_NoThrow()
|
||||
{
|
||||
// A reference-typed property (e.g. RootParent name-ish) compared with "=" should not throw,
|
||||
// and ordering operators must return false rather than throwing.
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(object), new object(), "whatever", ">"));
|
||||
});
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareValues_TimeSpan_ParsesViaSpanParsable()
|
||||
{
|
||||
// TimeSpan is not IConvertible, so the old Convert.ChangeType fallback threw and silently
|
||||
// returned no-match. ISpanParsable<TimeSpan> parses it correctly.
|
||||
var prop = TimeSpan.FromMinutes(5);
|
||||
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:05:00", "="));
|
||||
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:10:00", "="));
|
||||
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:01:00", ">"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareValues_TimeSpan_BadInput_ReturnsFalse_NoThrow()
|
||||
{
|
||||
var ex = Record.Exception(() =>
|
||||
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), TimeSpan.Zero, "notaspan", "=")));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareValues_Guid_ValueTypeParsedViaTypes()
|
||||
{
|
||||
// Guid is a value type not named by the hot paths; it's parsed via Types (IParsable) and
|
||||
// compared by value.
|
||||
var g = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000001", "="));
|
||||
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000002", "="));
|
||||
}
|
||||
|
||||
// A reference type with a legacy RunUO-style static Parse(string) and NO IParsable<> interface —
|
||||
// the Faction/Town shape. Types must still discover its Parse by reflection.
|
||||
private sealed class LegacyParseType
|
||||
{
|
||||
public string Value { get; private init; }
|
||||
public static LegacyParseType Parse(string s) => new() { Value = s };
|
||||
public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value;
|
||||
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareValues_LegacyParseString_ParsedViaTypes()
|
||||
{
|
||||
// Pre-IParsable types (only a static Parse(string)) must still be searchable: Types binds the
|
||||
// legacy Parse by reflection, so we compare against a real parsed instance, not the raw text.
|
||||
var prop = LegacyParseType.Parse("alpha");
|
||||
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "alpha", "="));
|
||||
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "beta", "="));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Engines.AdvancedSearch;
|
||||
using Server.Items;
|
||||
|
|
@ -48,6 +49,110 @@ public class AdvancedSearchWorkerTests
|
|||
}
|
||||
}
|
||||
|
||||
// End to end through Wake/Push/Sleep on real entities: the property test is compiled on the
|
||||
// worker, memoized per type, and applied to items and mobiles alike.
|
||||
[Fact]
|
||||
public void Worker_PropertyTest_FiltersItemsAndMobiles()
|
||||
{
|
||||
var worker = new AdvancedSearchThreadWorker();
|
||||
var results = new ConcurrentQueue<AdvancedSearchResult>();
|
||||
var ignore = new ConcurrentQueue<IEntity>();
|
||||
var filter = new AdvancedSearchFilter
|
||||
{
|
||||
FilterPropertyTest = true,
|
||||
PropertyTest = "Hue > 0",
|
||||
};
|
||||
|
||||
var plain = new Item(0x1);
|
||||
var hued = new Item(0x1) { Hue = 42 };
|
||||
var huedToo = new Gold(1) { Hue = 7 };
|
||||
var mobile = new Mobile { Hue = 1002 };
|
||||
|
||||
try
|
||||
{
|
||||
worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore);
|
||||
worker.Push(plain);
|
||||
worker.Push(hued);
|
||||
worker.Push(huedToo);
|
||||
worker.Push(mobile);
|
||||
worker.Sleep();
|
||||
|
||||
var matched = new HashSet<IEntity>();
|
||||
foreach (var r in results)
|
||||
{
|
||||
matched.Add(r.Entity);
|
||||
}
|
||||
|
||||
Assert.Equal(3, matched.Count);
|
||||
Assert.Contains(hued, matched);
|
||||
Assert.Contains(huedToo, matched);
|
||||
Assert.Contains(mobile, matched);
|
||||
Assert.DoesNotContain(plain, matched);
|
||||
}
|
||||
finally
|
||||
{
|
||||
plain.Delete();
|
||||
hued.Delete();
|
||||
huedToo.Delete();
|
||||
mobile.Delete();
|
||||
worker.Exit();
|
||||
}
|
||||
}
|
||||
|
||||
// The map boxes are independent checks. Ticking several used to reject everything, because
|
||||
// each ticked map was applied as "must be on this map"; an entity on any ticked map passes.
|
||||
[Fact]
|
||||
public void Worker_SeveralMapsTicked_MatchesAnyOfThem()
|
||||
{
|
||||
var worker = new AdvancedSearchThreadWorker();
|
||||
var results = new ConcurrentQueue<AdvancedSearchResult>();
|
||||
var ignore = new ConcurrentQueue<IEntity>();
|
||||
var filter = new AdvancedSearchFilter
|
||||
{
|
||||
FilterFelucca = true,
|
||||
FilterTrammel = true,
|
||||
FilterInternalMap = true,
|
||||
HideValidInternalMap = false,
|
||||
};
|
||||
|
||||
var fel = new Item(0x1);
|
||||
fel.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
|
||||
var tram = new Item(0x1);
|
||||
tram.MoveToWorld(new Point3D(1000, 1000, 0), Map.Trammel);
|
||||
var internalItem = new Item(0x1); // starts on Map.Internal
|
||||
var malas = new Item(0x1);
|
||||
malas.MoveToWorld(new Point3D(1000, 1000, 0), Map.Malas);
|
||||
|
||||
try
|
||||
{
|
||||
worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore);
|
||||
worker.Push(fel);
|
||||
worker.Push(tram);
|
||||
worker.Push(internalItem);
|
||||
worker.Push(malas);
|
||||
worker.Sleep();
|
||||
|
||||
var matched = new HashSet<IEntity>();
|
||||
foreach (var r in results)
|
||||
{
|
||||
matched.Add(r.Entity);
|
||||
}
|
||||
|
||||
Assert.Contains(fel, matched);
|
||||
Assert.Contains(tram, matched);
|
||||
Assert.Contains(internalItem, matched);
|
||||
Assert.DoesNotContain(malas, matched);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fel.Delete();
|
||||
tram.Delete();
|
||||
internalItem.Delete();
|
||||
malas.Delete();
|
||||
worker.Exit();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Worker_DeletedEntity_IsSkipped()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue