Skip to content
Open
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
27 changes: 24 additions & 3 deletions src/SIL.Machine/DataStructures/BidirList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,15 +240,31 @@ public bool Find(TNode start, TNode example, out TNode result)

public bool Find(TNode example, Direction dir, out TNode result)
{
return Find(
return FindCore(
dir == Direction.LeftToRight ? _begin.GetNext(_begin.Levels - 1) : _end.GetPrev(_end.Levels - 1),
example,
dir,
out result
);
}

/// <summary>
/// Finds the first matching node at or after <paramref name="start"/> in the specified direction.
/// On a miss, the result is at or after <paramref name="start"/> in that direction.
/// </summary>
public bool Find(TNode start, TNode example, Direction dir, out TNode result)
{
int comparison = _comparer.Compare(start, example);
if (comparison == 0 || (dir == Direction.LeftToRight ? comparison > 0 : comparison < 0))
{
result = start;
return comparison == 0;
}

return FindCore(start, example, dir, out result);
}

private bool FindCore(TNode start, TNode example, Direction dir, out TNode result)
{
TNode cur =
dir == Direction.LeftToRight ? start.GetPrev(start.Levels - 1) : start.GetNext(start.Levels - 1);
Expand All @@ -264,8 +280,13 @@ public bool Find(TNode start, TNode example, Direction dir, out TNode result)
break;
if (res == 0)
{
result = next;
return true;
if (i == 0)
{
result = next;
return true;
}

break;
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
}
cur = next;
next = dir == Direction.LeftToRight ? cur.GetNext(i) : cur.GetPrev(i);
Expand Down
6 changes: 4 additions & 2 deletions src/SIL.Machine/DataStructures/IBidirList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public interface IBidirList<TNode> : ICollection<TNode>
/// <summary>
/// Finds the node that matches the specified example node.
/// </summary>
/// <param name="start">The node to start searching from.</param>
/// <param name="start">The node to start searching from, inclusive.</param>
/// <param name="example">The example node.</param>
/// <param name="result">The result.</param>
/// <returns></returns>
Expand All @@ -100,7 +100,9 @@ public interface IBidirList<TNode> : ICollection<TNode>
/// <summary>
/// Finds the node that matches the specified example node.
/// </summary>
/// <param name="start">The node to start searching from.</param>
/// <param name="start">
/// The node to start searching from, inclusive. A match is at or after this node in the search direction.
/// </param>
/// <param name="example">The example node.</param>
/// <param name="dir">The direction.</param>
/// <param name="result">The result.</param>
Expand Down
244 changes: 244 additions & 0 deletions tests/SIL.Machine.Tests/Annotations/AnnotationTieTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
using System.Globalization;
using System.Reflection;
using NUnit.Framework;
using SIL.Machine.DataStructures;
using SIL.Machine.FeatureModel;

namespace SIL.Machine.Annotations;

[TestFixture]
public class AnnotationTieTests
{
private const int ExtraCount = 5;
private const string TagFeatureID = "test-tag";
private static readonly FeatureSystem TagFeatureSystem = new() { new StringFeature(TagFeatureID) };
private static readonly FieldInfo RandomField = typeof(BidirList<Annotation<ShapeNode>>).GetField(
"_rand",
BindingFlags.Instance | BindingFlags.NonPublic
)!;

private static Shape CreateEmptyShape(int seed)
{
var annotations = new AnnotationList<ShapeNode>();
// Seed before Shape construction inserts its margins and later annotations.
RandomField.SetValue(annotations, new Random(seed));
return new Shape(begin => new ShapeNode(TaggedFeatures(begin ? "margin-begin" : "margin-end")), annotations);
}

private static (Shape Shape, ShapeNode[] Nodes) BuildShape(int seed)
{
var shape = CreateEmptyShape(seed);
var nodes = new ShapeNode[3];
for (int i = 0; i < nodes.Length; i++)
nodes[i] = shape.Add(TaggedFeatures(IndexTag("node", i)));

foreach (int nodeIndex in new[] { 1, 2 })
{
for (int i = 0; i < ExtraCount; i++)
{
var ann = new Annotation<ShapeNode>(
Range<ShapeNode>.Create(nodes[nodeIndex], nodes[nodeIndex]),
TaggedFeatures(IndexTag("point-" + nodeIndex.ToString(CultureInfo.InvariantCulture), i))
);
shape.Annotations.Add(ann, false);
}
}

return (shape, nodes);
}

private static (
Shape Shape,
ShapeNode[] Nodes,
Annotation<ShapeNode> Owner,
Annotation<ShapeNode>[] TiedAnnotations
) BuildNestedEndpointTieShape(int seed, int tiedEndpointNodeIndex)
{
var shape = CreateEmptyShape(seed);
var nodes = new ShapeNode[4];
for (int i = 0; i < nodes.Length; i++)
nodes[i] = shape.Add(TaggedFeatures(IndexTag("nested-node", i)));

var owner = new Annotation<ShapeNode>(
Range<ShapeNode>.Create(nodes[1], nodes[3]),
TaggedFeatures("multi-node-owner")
);
RandomField.SetValue(owner.Children, new Random(seed + 200));
owner.Children.AddRange(new[] { nodes[1].Annotation, nodes[2].Annotation, nodes[3].Annotation });
var tiedAnnotations = new Annotation<ShapeNode>[ExtraCount];
for (int i = 0; i < ExtraCount; i++)
{
tiedAnnotations[i] = new Annotation<ShapeNode>(
Range<ShapeNode>.Create(nodes[tiedEndpointNodeIndex], nodes[tiedEndpointNodeIndex]),
TaggedFeatures(IndexTag("endpoint-tie", i))
);
owner.Children.Add(tiedAnnotations[i], false);
}

shape.Annotations.Add(owner, false);
return (shape, nodes, owner, tiedAnnotations);
}

private static FeatureStruct TaggedFeatures(string tag)
{
return FeatureStruct.New(TagFeatureSystem).Feature(TagFeatureID).EqualTo(tag).Value;
}

private static string IndexTag(string prefix, int index)
{
return prefix + "-" + index.ToString(CultureInfo.InvariantCulture);
}

private static AnnotationSnapshot Snapshot(Annotation<ShapeNode> annotation)
{
return new AnnotationSnapshot(
annotation.Range.Start,
annotation.Range.End,
annotation.FeatureStruct.GetValue<StringFeatureValue>(TagFeatureID).Values.Single()
);
}

private static void AssertMappedAnnotations(Shape source, Shape destination)
{
ShapeNode[] sourceNodes = source.ToArray();
ShapeNode[] destinationNodes = destination.ToArray();
Assert.That(destinationNodes, Has.Length.EqualTo(sourceNodes.Length));

var nodeMap = new Dictionary<ShapeNode, ShapeNode>();
for (int i = 0; i < sourceNodes.Length; i++)
nodeMap.Add(sourceNodes[i], destinationNodes[i]);

AnnotationSnapshot[] expected = source
.Annotations.Where(annotation =>
annotation != source.Begin.Annotation && annotation != source.End.Annotation
)
.Select(annotation => new AnnotationSnapshot(
nodeMap[annotation.Range.Start],
nodeMap[annotation.Range.End],
Snapshot(annotation).Tag
))
.Concat(new[] { Snapshot(destination.Begin.Annotation), Snapshot(destination.End.Annotation) })
.ToArray();
AnnotationSnapshot[] actual = destination.Annotations.Select(Snapshot).ToArray();

Assert.That(actual, Is.EquivalentTo(expected));
Assert.That(actual.All(annotation => destination.Contains(annotation.Start)), Is.True);
Assert.That(actual.All(annotation => destination.Contains(annotation.End)), Is.True);
}

private static void AssertNestedEndpointTieShrinks(int seed, int removedNodeIndex, int expectedStartNodeIndex)
{
(Shape shape, ShapeNode[] nodes, Annotation<ShapeNode> owner, Annotation<ShapeNode>[] tiedAnnotations) =
BuildNestedEndpointTieShape(seed, removedNodeIndex);
Annotation<ShapeNode>[] expectedRootAnnotations = shape
.Annotations.Where(annotation => annotation != owner)
.ToArray();
Annotation<ShapeNode>[] expectedChildAnnotations = owner
.Children.Where(annotation =>
annotation != nodes[removedNodeIndex].Annotation && !tiedAnnotations.Contains(annotation)
)
.ToArray();

shape.Remove(nodes[removedNodeIndex]);

Annotation<ShapeNode>[] actualRootAnnotations = shape.Annotations.ToArray();
Assert.That(actualRootAnnotations, Has.Length.EqualTo(expectedRootAnnotations.Length + 1));
foreach (Annotation<ShapeNode> annotation in expectedRootAnnotations)
Assert.That(actualRootAnnotations, Does.Contain(annotation));
Assert.That(actualRootAnnotations, Does.Not.Contain(owner));

Annotation<ShapeNode> resizedOwner = actualRootAnnotations.Single(annotation =>
Snapshot(annotation).Tag == "multi-node-owner"
);
ShapeNode expectedEnd = removedNodeIndex == 3 ? nodes[2] : nodes[3];
Assert.That(resizedOwner.Range.Start, Is.SameAs(nodes[expectedStartNodeIndex]));
Assert.That(resizedOwner.Range.End, Is.SameAs(expectedEnd));
Assert.That(resizedOwner.Children.Count, Is.EqualTo(expectedChildAnnotations.Length));
Assert.That(resizedOwner.Children, Is.EquivalentTo(expectedChildAnnotations));
foreach (Annotation<ShapeNode> annotation in tiedAnnotations)
Assert.That(resizedOwner.Children, Does.Not.Contain(annotation));
Assert.That(
resizedOwner.Children.Any(annotation =>
Snapshot(annotation).Tag.StartsWith("endpoint-tie-", StringComparison.Ordinal)
),
Is.False
);
}

[Test]
public void Find_ReturnsFirstMatchingAnnotationInSearchDirection([Range(0, 199)] int seed)
{
(Shape shape, ShapeNode[] nodes) = BuildShape(seed);
Annotation<ShapeNode>[] tied = shape
.Annotations.Where(ann => ann.Range.Start == nodes[1] && ann.Range.End == nodes[1])
.ToArray();
var probe = new Annotation<ShapeNode>(Range<ShapeNode>.Create(nodes[1], nodes[1]), TaggedFeatures("probe"));

Assert.That(
shape.Annotations.Find(probe, Direction.LeftToRight, out Annotation<ShapeNode> leftToRight),
Is.True
);
Assert.That(leftToRight, Is.SameAs(tied[0]));
Assert.That(
shape.Annotations.Find(probe, Direction.RightToLeft, out Annotation<ShapeNode> rightToLeft),
Is.True
);
Assert.That(rightToLeft, Is.SameAs(tied[^1]));
}

[Test]
public void GetNodes_ReturnsAnnotationsAtBothTiedBoundaries([Range(0, 199)] int seed)
{
(Shape shape, ShapeNode[] nodes) = BuildShape(seed);

Assert.That(shape.Annotations.GetNodes(nodes[0], nodes[2]).Count(), Is.EqualTo(3 + 2 * ExtraCount));
Assert.That(shape.Annotations.GetNodes(nodes[1], nodes[2]).Count(), Is.EqualTo(2 + 2 * ExtraCount));
Assert.That(shape.Annotations.GetNodes(nodes[0], nodes[1]).Count(), Is.EqualTo(2 + ExtraCount));
Assert.That(shape.Annotations.GetNodes(nodes[1], nodes[1]).Count(), Is.EqualTo(1 + ExtraCount));
}

[Test]
public void CopyAndClone_PreserveMappedAnnotationRangesAndPayloads([Range(0, 199)] int seed)
{
(Shape shape, _) = BuildShape(seed);
Shape copy = CreateEmptyShape(seed + 200);

shape.CopyTo(copy);
AssertMappedAnnotations(shape, copy);

Shape clone = shape.Clone();
AssertMappedAnnotations(shape, clone);
}

[Test]
public void RemoveNode_PreservesExactUnaffectedAnnotations([Range(0, 199)] int seed)
{
(Shape shape, ShapeNode[] nodes) = BuildShape(seed);
Annotation<ShapeNode>[] expectedSurvivors = shape
.Annotations.Where(annotation => annotation.Range.Start != nodes[1] && annotation.Range.End != nodes[1])
.ToArray();

shape.Remove(nodes[1]);

Assert.That(shape.Annotations.Count, Is.EqualTo(expectedSurvivors.Length));
Assert.That(shape.Annotations, Is.EquivalentTo(expectedSurvivors));
Assert.That(
shape.Annotations.Any(annotation => annotation.Range.Start == nodes[1] || annotation.Range.End == nodes[1]),
Is.False
);
}

[Test]
public void RemoveStartEndpoint_ShrinksMultiNodeOwnerAndRemovesEveryTie([Range(0, 199)] int seed)
{
AssertNestedEndpointTieShrinks(seed, removedNodeIndex: 1, expectedStartNodeIndex: 2);
}

[Test]
public void RemoveEndEndpoint_ShrinksMultiNodeOwnerAndRemovesEveryTie([Range(0, 199)] int seed)
{
AssertNestedEndpointTieShrinks(seed, removedNodeIndex: 3, expectedStartNodeIndex: 1);
}

private readonly record struct AnnotationSnapshot(ShapeNode Start, ShapeNode End, string Tag);
}
82 changes: 82 additions & 0 deletions tests/SIL.Machine.Tests/DataStructures/BidirListTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Reflection;
using NUnit.Framework;
using SIL.Machine.DataStructures;

namespace SIL.Machine.Tests.DataStructures;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: F2. This namespace breaks the test project convention: SIL.Machine.Tests.DataStructures is the only SIL.Machine.Tests.* namespace under tests/. Every other file uses the namespace of the code under test, as AnnotationTieTests in this PR does with SIL.Machine.Annotations. Use namespace SIL.Machine.DataStructures;.


[TestFixture]
public class BidirListTests
{
[TestCase(Direction.LeftToRight)]
[TestCase(Direction.RightToLeft)]
public void Find_WithStart_DoesNotReturnEarlierTiedNode(Direction direction)
{
var list = new TestList();
var left = new TestNode(1);
var start = new TestNode(1);
var right = new TestNode(1);
list.Add(left);
list.Add(start);
list.Add(right);

Assert.That(list.Find(start, new TestNode(1), direction, out TestNode result), Is.True);
Assert.That(result, Is.SameAs(start));
}

[TestCase(Direction.LeftToRight, 0)]
[TestCase(Direction.RightToLeft, 2)]
public void Find_WithStartAndEarlierExample_DoesNotReturnEarlierNode(Direction direction, int example)
{
var list = new TestList();
list.Add(new TestNode(1));
var start = new TestNode(1);
list.Add(start);
list.Add(new TestNode(1));

Assert.That(list.Find(start, new TestNode(example), direction, out TestNode result), Is.False);
Assert.That(result, Is.SameAs(start));
}

[TestCase(Direction.LeftToRight)]
[TestCase(Direction.RightToLeft)]
public void Find_WithStart_ReturnsFirstLaterTie(Direction direction)
{
var list = new TestList();
var left = new TestNode(direction == Direction.LeftToRight ? 0 : 1);
var middle = new TestNode(1);
var right = new TestNode(direction == Direction.LeftToRight ? 1 : 2);
list.Add(left);
list.Add(middle);
list.Add(right);
TestNode start = direction == Direction.LeftToRight ? left : right;

Assert.That(list.Find(start, new TestNode(1), direction, out TestNode result), Is.True);
Assert.That(result, Is.SameAs(middle));
}

private sealed class TestNode(int value) : BidirListNode<TestNode>
{
public int Value { get; } = value;
}

private sealed class TestList : BidirList<TestNode>
{
public TestList()
: base(
Comparer<TestNode>.Create((x, y) => x.Value.CompareTo(y.Value)),
begin => new TestNode(begin ? int.MinValue : int.MaxValue)
)
{
typeof(BidirList<TestNode>)
.GetField("_rand", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(this, new LevelRandom());
}
}

private sealed class LevelRandom : Random
{
private readonly Queue<int> _values = new(new[] { 0, 1, 0 });

public override int Next() => _values.Dequeue();
}
}
Loading