From 7758ffa14e42ed126a5780fe9186a27aa41518e5 Mon Sep 17 00:00:00 2001 From: Mike Goatly <4577868+mikegoatly@users.noreply.github.com> Date: Thu, 3 Nov 2022 17:57:58 +0000 Subject: [PATCH] Copy mutated node state flags with split node #54 (#55) --- azure-pipelines.yml | 2 +- src/Lifti.Core/IndexNodeMutation.cs | 906 +++++++++--------- .../IndexInsertionMutationTests.cs | 323 +++---- test/Lifti.Tests/IndexRemovalMutationTests.cs | 53 + test/Lifti.Tests/MutationTestBase.cs | 73 ++ 5 files changed, 713 insertions(+), 644 deletions(-) create mode 100644 test/Lifti.Tests/IndexRemovalMutationTests.cs create mode 100644 test/Lifti.Tests/MutationTestBase.cs diff --git a/azure-pipelines.yml b/azure-pipelines.yml index f7f37e7b..ce02a658 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -10,7 +10,7 @@ trigger: variables: majorVersion: 3 minorVersion: 5 - patchVersion: 1 + patchVersion: 2 project: src/Lifti.Core/Lifti.Core.csproj testProject: test/Lifti.Tests/Lifti.Tests.csproj buildConfiguration: 'Release' diff --git a/src/Lifti.Core/IndexNodeMutation.cs b/src/Lifti.Core/IndexNodeMutation.cs index c73afa89..18135c05 100644 --- a/src/Lifti.Core/IndexNodeMutation.cs +++ b/src/Lifti.Core/IndexNodeMutation.cs @@ -1,453 +1,453 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.Contracts; -using System.Linq; -using System.Text; - -namespace Lifti -{ - internal class IndexNodeMutation - { - private readonly int depth; - private readonly IIndexNodeFactory indexNodeFactory; - private IndexNode? original; - - private IndexNodeMutation(IndexNodeMutation parent) - : this(parent.depth + 1, parent.indexNodeFactory) - { - } - - private IndexNodeMutation(int depth, IIndexNodeFactory indexNodeFactory) - { - this.depth = depth; - this.indexNodeFactory = indexNodeFactory; - } - - public IndexNodeMutation(int depth, IndexNode node, IIndexNodeFactory indexNodeFactory) - : this(depth, indexNodeFactory) - { - this.original = node; - this.IntraNodeText = node.IntraNodeText; - - this.HasMatches = node.HasMatches; - this.HasChildNodes = node.HasChildNodes; - } - - public bool IsEmpty => !this.HasChildNodes && !this.HasMatches; - public bool HasChildNodes { get; private set; } - public bool HasMatches { get; private set; } - public ReadOnlyMemory IntraNodeText { get; private set; } - public Dictionary? MutatedChildNodes { get; private set; } - - public IEnumerable> UnmutatedChildNodes - { - get - { - if (this.original == null) - { - return Array.Empty>(); - } - - if (this.MutatedChildNodes == null) - { - return this.original.ChildNodes; - } - - return this.original.ChildNodes.Where(n => !this.MutatedChildNodes.ContainsKey(n.Key)); - } - } - - public Dictionary>? MutatedMatches { get; private set; } - - internal void Index( - int itemId, - byte fieldId, - IReadOnlyList locations, - ReadOnlyMemory remainingTokenText) - { - var indexSupportLevel = this.indexNodeFactory.GetIndexSupportLevelForDepth(this.depth); - switch (indexSupportLevel) - { - case IndexSupportLevelKind.CharacterByCharacter: - this.IndexFromCharacter(itemId, fieldId, locations, remainingTokenText); - break; - case IndexSupportLevelKind.IntraNodeText: - this.IndexWithIntraNodeTextSupport(itemId, fieldId, locations, remainingTokenText); - break; - default: - throw new LiftiException(ExceptionMessages.UnsupportedIndexSupportLevel, indexSupportLevel); - } - } - - internal IndexNode Apply() - { - ImmutableDictionary childNodes; - ImmutableDictionary> matches; - - IEnumerable> mapNodeMutations() - { - return this.MutatedChildNodes.Select(p => new KeyValuePair(p.Key, p.Value.Apply())); - } - - if (this.original == null) - { - childNodes = this.MutatedChildNodes == null ? ImmutableDictionary.Empty : mapNodeMutations().ToImmutableDictionary(); - matches = this.MutatedMatches == null ? ImmutableDictionary>.Empty : this.MutatedMatches.ToImmutableDictionary(); - } - else - { - childNodes = this.original.ChildNodes; - if (this.MutatedChildNodes?.Count > 0) - { - childNodes = childNodes.SetItems(mapNodeMutations()); - } - - if (this.MutatedMatches == null) - { - matches = this.original.Matches; - } - else - { - matches = this.MutatedMatches.ToImmutableDictionary(); - } - } - - return this.indexNodeFactory.CreateNode(this.IntraNodeText, childNodes, matches); - } - - internal void Remove(int itemId) - { - if (this.HasChildNodes) - { - // First look through any already mutated child nodes - if (this.MutatedChildNodes != null) - { - foreach (var child in this.MutatedChildNodes) - { - child.Value.Remove(itemId); - } - } - - // Then any unmutated children - foreach (var child in this.UnmutatedChildNodes) - { - if (this.TryRemove(child.Value, itemId, this.depth + 1, out var mutatedChild)) - { - this.EnsureMutatedChildNodesCreated(); - this.MutatedChildNodes!.Add(child.Key, mutatedChild); - } - } - } - - if (this.HasMatches) - { - if (this.MutatedMatches != null) - { - this.MutatedMatches.Remove(itemId); - } - else - { - if (this.original != null && this.original.Matches.ContainsKey(itemId)) - { - // Mutate and remove - this.EnsureMutatedMatchesCreated(); - this.MutatedMatches!.Remove(itemId); - } - } - } - } - - private bool TryRemove(IndexNode node, int itemId, int nodeDepth, [NotNullWhen(true)]out IndexNodeMutation? mutatedNode) - { - mutatedNode = null; - - if (node.HasChildNodes) - { - // Work through the child nodes and recursively determine whether removals are needed from - // them. If they are, then this instance will also become mutated. - foreach (var child in node.ChildNodes) - { - if (this.TryRemove(child.Value, itemId, nodeDepth + 1, out var mutatedChild)) - { - if (mutatedNode == null) - { - mutatedNode = new IndexNodeMutation(nodeDepth, node, this.indexNodeFactory); - mutatedNode.EnsureMutatedChildNodesCreated(); - } - - mutatedNode.MutatedChildNodes!.Add(child.Key, mutatedChild); - } - } - } - - if (node.HasMatches) - { - // Removing an item from the nodes current matches will return the same dictionary - // if the item didn't exist - this removes the need for an extra Exists check - var mutatedMatches = node.Matches.Remove(itemId); - if (mutatedMatches != node.Matches) - { - if (mutatedNode == null) - { - mutatedNode = new IndexNodeMutation(nodeDepth, node, this.indexNodeFactory); - } - - mutatedNode.EnsureMutatedMatchesCreated(); - mutatedNode.MutatedMatches!.Remove(itemId); - } - } - - return mutatedNode != null; - } - - private void IndexFromCharacter( - int itemId, - byte fieldId, - IReadOnlyList locations, - ReadOnlyMemory remainingTokenText, - int testLength = 0) - { - if (remainingTokenText.Length > testLength) - { - this.ContinueIndexingAtChild(itemId, fieldId, locations, remainingTokenText, testLength); - } - else - { - // Remaining text == intraNodeText - this.AddMatchedItem(itemId, fieldId, locations); - } - } - - private void ContinueIndexingAtChild( - int itemId, - byte fieldId, - IReadOnlyList locations, - ReadOnlyMemory remainingTokenText, - int remainingTextSplitPosition) - { - var indexChar = remainingTokenText.Span[remainingTextSplitPosition]; - - this.EnsureMutatedChildNodesCreated(); - if (!this.MutatedChildNodes!.TryGetValue(indexChar, out var childNode)) - { - if (this.original != null && this.original.ChildNodes.TryGetValue(indexChar, out var originalChildNode)) - { - // the original had an unmutated child node that matched the index character - mutate it now - childNode = new IndexNodeMutation(this.depth + 1, originalChildNode, this.indexNodeFactory); - } - else - { - // This is a novel branch in the index - childNode = new IndexNodeMutation(this); - } - - // Track the mutated node - this.MutatedChildNodes.Add(indexChar, childNode); - } - - childNode.Index(itemId, fieldId, locations, remainingTokenText.Slice(remainingTextSplitPosition + 1)); - } - - private void EnsureMutatedChildNodesCreated() - { - if (this.MutatedChildNodes == null) - { - this.HasChildNodes = true; - this.MutatedChildNodes = new Dictionary(); - } - } - - private void IndexWithIntraNodeTextSupport( - int itemId, - byte fieldId, - IReadOnlyList locations, - ReadOnlyMemory remainingTokenText) - { - if (this.IntraNodeText.Length == 0) - { - if (this.IsEmpty) - { - // Currently a leaf node - this.IntraNodeText = remainingTokenText.Length == 0 ? null : remainingTokenText; - this.AddMatchedItem(itemId, fieldId, locations); - } - else - { - this.IndexFromCharacter(itemId, fieldId, locations, remainingTokenText); - } - } - else - { - if (remainingTokenText.Length == 0) - { - // The indexing ends before the start of the intranode text so we need to split - this.SplitIntraNodeText(0); - this.AddMatchedItem(itemId, fieldId, locations); - return; - } - - var testLength = Math.Min(remainingTokenText.Length, this.IntraNodeText.Length); - var intraNodeSpan = this.IntraNodeText.Span; - var tokenSpan = remainingTokenText.Span; - for (var i = 0; i < testLength; i++) - { - if (tokenSpan[i] != intraNodeSpan[i]) - { - this.SplitIntraNodeText(i); - this.ContinueIndexingAtChild(itemId, fieldId, locations, remainingTokenText, i); - return; - } - } - - if (this.IntraNodeText.Length > testLength) - { - // This token is indexed in the middle of intra-node text. Split it and index here - this.SplitIntraNodeText(testLength); - } - - this.IndexFromCharacter(itemId, fieldId, locations, remainingTokenText, testLength); - } - } - - private void AddMatchedItem(int itemId, byte fieldId, IReadOnlyList locations) - { - this.EnsureMutatedMatchesCreated(); - - var indexedToken = new IndexedToken(fieldId, locations); - if (this.MutatedMatches!.TryGetValue(itemId, out var itemFieldLocations)) - { - this.MutatedMatches[itemId] = itemFieldLocations.Add(new IndexedToken(fieldId, locations)); - } - else - { - if (this.MutatedMatches.TryGetValue(itemId, out var originalItemFieldLocations)) - { - this.MutatedMatches[itemId] = originalItemFieldLocations.Add(indexedToken); - } - else - { - // This item has not been indexed at this location previously - var builder = ImmutableList.CreateBuilder(); - builder.Add(indexedToken); - this.MutatedMatches.Add(itemId, builder.ToImmutable()); - } - } - } - - private void EnsureMutatedMatchesCreated() - { - if (this.MutatedMatches == null) - { - this.HasMatches = true; - - if (this.original?.HasMatches ?? false) - { - // Once we're mutating matches, copy everything across - this.MutatedMatches = new Dictionary>( - this.original.Matches); - } - else - { - this.MutatedMatches = new Dictionary>(); - } - } - } - - private void SplitIntraNodeText(int splitIndex) - { - var splitChildNode = new IndexNodeMutation(this) - { - MutatedChildNodes = this.MutatedChildNodes, - MutatedMatches = this.MutatedMatches, - IntraNodeText = splitIndex + 1 == this.IntraNodeText.Length ? null : this.IntraNodeText.Slice(splitIndex + 1), - - // Pass the original down to the child node - the only state that matters there is any unmutated child nodes/matches - original = this.original - }; - - this.original = null; - - var splitChar = this.IntraNodeText.Span[splitIndex]; - - // Reset the matches at this node - this.MutatedMatches = null; - this.HasMatches = false; - - // Replace any remaining intra node text - this.IntraNodeText = splitIndex == 0 ? null : this.IntraNodeText.Slice(0, splitIndex); - - this.HasChildNodes = true; - this.MutatedChildNodes = new Dictionary - { - { splitChar, splitChildNode } - }; - } - - [Pure] - public override string ToString() - { - if (this.IsEmpty) - { - return ""; - } - - var builder = new StringBuilder(); - this.FormatNodeText(builder); - this.FormatChildNodeText(builder, 0); - - return builder.ToString(); - } - - private void ToString(StringBuilder builder, char linkChar, int currentDepth) - { - builder.Append(' ', currentDepth * 2) - .Append(linkChar) - .Append('*') - .Append(' '); - - this.FormatNodeText(builder); - - this.FormatChildNodeText(builder, currentDepth); - } - - private void FormatChildNodeText(StringBuilder builder, int currentDepth) - { - if (this.HasChildNodes) - { - var nextDepth = currentDepth + 1; - if (this.original != null) - { - foreach (var item in this.original.ChildNodes.Where(e => this.MutatedChildNodes == null || !this.MutatedChildNodes.ContainsKey(e.Key))) - { - builder.AppendLine(); - item.Value.ToString(builder, item.Key, nextDepth); - } - } - - if (this.MutatedChildNodes != null) - { - foreach (var item in this.MutatedChildNodes) - { - builder.AppendLine(); - item.Value.ToString(builder, item.Key, nextDepth); - } - } - } - } - - private void FormatNodeText(StringBuilder builder) - { - if (this.IntraNodeText.Length > 0) - { - builder.Append(this.IntraNodeText); - } - - if (this.HasMatches) - { - builder.Append($" [{this.original?.Matches.Count ?? 0} original matche(s) - {this.MutatedMatches?.Count ?? 0} mutated]"); - } - } - } -} +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; +using System.Linq; +using System.Text; + +namespace Lifti +{ + internal class IndexNodeMutation + { + private readonly int depth; + private readonly IIndexNodeFactory indexNodeFactory; + private IndexNode? original; + + private IndexNodeMutation(IndexNodeMutation parent) + : this(parent.depth + 1, parent.indexNodeFactory) + { + } + + private IndexNodeMutation(int depth, IIndexNodeFactory indexNodeFactory) + { + this.depth = depth; + this.indexNodeFactory = indexNodeFactory; + } + + public IndexNodeMutation(int depth, IndexNode node, IIndexNodeFactory indexNodeFactory) + : this(depth, indexNodeFactory) + { + this.original = node; + this.IntraNodeText = node.IntraNodeText; + + this.HasMatches = node.HasMatches; + this.HasChildNodes = node.HasChildNodes; + } + + public bool IsEmpty => !this.HasChildNodes && !this.HasMatches; + + /// + /// Important note about and in an : + /// We can't easily derive the presence of child nodes or mutations from state, because it could be tracked in the + /// original unmodified node or the mutated state. To reduce compute effort, these flags are cached and manually updated. + /// + public bool HasChildNodes { get; private set; } + public bool HasMatches { get; private set; } + public ReadOnlyMemory IntraNodeText { get; private set; } + public Dictionary? MutatedChildNodes { get; private set; } + + public IEnumerable> UnmutatedChildNodes + { + get + { + if (this.original == null) + { + return Array.Empty>(); + } + + if (this.MutatedChildNodes == null) + { + return this.original.ChildNodes; + } + + return this.original.ChildNodes.Where(n => !this.MutatedChildNodes.ContainsKey(n.Key)); + } + } + + public Dictionary>? MutatedMatches { get; private set; } + + internal void Index( + int itemId, + byte fieldId, + IReadOnlyList locations, + ReadOnlyMemory remainingTokenText) + { + var indexSupportLevel = this.indexNodeFactory.GetIndexSupportLevelForDepth(this.depth); + switch (indexSupportLevel) + { + case IndexSupportLevelKind.CharacterByCharacter: + this.IndexFromCharacter(itemId, fieldId, locations, remainingTokenText); + break; + case IndexSupportLevelKind.IntraNodeText: + this.IndexWithIntraNodeTextSupport(itemId, fieldId, locations, remainingTokenText); + break; + default: + throw new LiftiException(ExceptionMessages.UnsupportedIndexSupportLevel, indexSupportLevel); + } + } + + internal IndexNode Apply() + { + ImmutableDictionary childNodes; + ImmutableDictionary> matches; + + IEnumerable> mapNodeMutations() + { + return this.MutatedChildNodes.Select(p => new KeyValuePair(p.Key, p.Value.Apply())); + } + + if (this.original == null) + { + childNodes = this.MutatedChildNodes == null ? ImmutableDictionary.Empty : mapNodeMutations().ToImmutableDictionary(); + matches = this.MutatedMatches == null ? ImmutableDictionary>.Empty : this.MutatedMatches.ToImmutableDictionary(); + } + else + { + childNodes = this.original.ChildNodes; + if (this.MutatedChildNodes?.Count > 0) + { + childNodes = childNodes.SetItems(mapNodeMutations()); + } + + matches = this.MutatedMatches == null + ? this.original.Matches + : this.MutatedMatches.ToImmutableDictionary(); + } + + return this.indexNodeFactory.CreateNode(this.IntraNodeText, childNodes, matches); + } + + internal void Remove(int itemId) + { + if (this.HasChildNodes) + { + // First look through any already mutated child nodes + if (this.MutatedChildNodes != null) + { + foreach (var child in this.MutatedChildNodes) + { + child.Value.Remove(itemId); + } + } + + // Then any unmutated children + foreach (var child in this.UnmutatedChildNodes) + { + if (this.TryRemove(child.Value, itemId, this.depth + 1, out var mutatedChild)) + { + this.EnsureMutatedChildNodesCreated(); + this.MutatedChildNodes!.Add(child.Key, mutatedChild); + } + } + } + + if (this.HasMatches) + { + if (this.MutatedMatches != null) + { + this.MutatedMatches.Remove(itemId); + } + else + { + if (this.original != null && this.original.Matches.ContainsKey(itemId)) + { + // Mutate and remove + this.EnsureMutatedMatchesCreated(); + this.MutatedMatches!.Remove(itemId); + } + } + } + } + + private bool TryRemove(IndexNode node, int itemId, int nodeDepth, [NotNullWhen(true)] out IndexNodeMutation? mutatedNode) + { + mutatedNode = null; + + if (node.HasChildNodes) + { + // Work through the child nodes and recursively determine whether removals are needed from + // them. If they are, then this instance will also become mutated. + foreach (var child in node.ChildNodes) + { + if (this.TryRemove(child.Value, itemId, nodeDepth + 1, out var mutatedChild)) + { + if (mutatedNode == null) + { + mutatedNode = new IndexNodeMutation(nodeDepth, node, this.indexNodeFactory); + mutatedNode.EnsureMutatedChildNodesCreated(); + } + + mutatedNode.MutatedChildNodes!.Add(child.Key, mutatedChild); + } + } + } + + if (node.HasMatches) + { + // Removing an item from the nodes current matches will return the same dictionary + // if the item didn't exist - this removes the need for an extra Exists check + var mutatedMatches = node.Matches.Remove(itemId); + if (mutatedMatches != node.Matches) + { + mutatedNode ??= new IndexNodeMutation(nodeDepth, node, this.indexNodeFactory); + + mutatedNode.EnsureMutatedMatchesCreated(); + mutatedNode.MutatedMatches!.Remove(itemId); + } + } + + return mutatedNode != null; + } + + private void IndexFromCharacter( + int itemId, + byte fieldId, + IReadOnlyList locations, + ReadOnlyMemory remainingTokenText, + int testLength = 0) + { + if (remainingTokenText.Length > testLength) + { + this.ContinueIndexingAtChild(itemId, fieldId, locations, remainingTokenText, testLength); + } + else + { + // Remaining text == intraNodeText + this.AddMatchedItem(itemId, fieldId, locations); + } + } + + private void ContinueIndexingAtChild( + int itemId, + byte fieldId, + IReadOnlyList locations, + ReadOnlyMemory remainingTokenText, + int remainingTextSplitPosition) + { + var indexChar = remainingTokenText.Span[remainingTextSplitPosition]; + + this.EnsureMutatedChildNodesCreated(); + if (!this.MutatedChildNodes!.TryGetValue(indexChar, out var childNode)) + { + if (this.original != null && this.original.ChildNodes.TryGetValue(indexChar, out var originalChildNode)) + { + // the original had an unmutated child node that matched the index character - mutate it now + childNode = new IndexNodeMutation(this.depth + 1, originalChildNode, this.indexNodeFactory); + } + else + { + // This is a novel branch in the index + childNode = new IndexNodeMutation(this); + } + + // Track the mutated node + this.MutatedChildNodes.Add(indexChar, childNode); + } + + childNode.Index(itemId, fieldId, locations, remainingTokenText.Slice(remainingTextSplitPosition + 1)); + } + + private void EnsureMutatedChildNodesCreated() + { + if (this.MutatedChildNodes == null) + { + this.HasChildNodes = true; + this.MutatedChildNodes = new Dictionary(); + } + } + + private void IndexWithIntraNodeTextSupport( + int itemId, + byte fieldId, + IReadOnlyList locations, + ReadOnlyMemory remainingTokenText) + { + if (this.IntraNodeText.Length == 0) + { + if (this.IsEmpty) + { + // Currently a leaf node + this.IntraNodeText = remainingTokenText.Length == 0 ? null : remainingTokenText; + this.AddMatchedItem(itemId, fieldId, locations); + } + else + { + this.IndexFromCharacter(itemId, fieldId, locations, remainingTokenText); + } + } + else + { + if (remainingTokenText.Length == 0) + { + // The indexing ends before the start of the intranode text so we need to split + this.SplitIntraNodeText(0); + this.AddMatchedItem(itemId, fieldId, locations); + return; + } + + var testLength = Math.Min(remainingTokenText.Length, this.IntraNodeText.Length); + var intraNodeSpan = this.IntraNodeText.Span; + var tokenSpan = remainingTokenText.Span; + for (var i = 0; i < testLength; i++) + { + if (tokenSpan[i] != intraNodeSpan[i]) + { + this.SplitIntraNodeText(i); + this.ContinueIndexingAtChild(itemId, fieldId, locations, remainingTokenText, i); + return; + } + } + + if (this.IntraNodeText.Length > testLength) + { + // This token is indexed in the middle of intra-node text. Split it and index here + this.SplitIntraNodeText(testLength); + } + + this.IndexFromCharacter(itemId, fieldId, locations, remainingTokenText, testLength); + } + } + + private void AddMatchedItem(int itemId, byte fieldId, IReadOnlyList locations) + { + this.EnsureMutatedMatchesCreated(); + + var indexedToken = new IndexedToken(fieldId, locations); + if (this.MutatedMatches!.TryGetValue(itemId, out var itemFieldLocations)) + { + this.MutatedMatches[itemId] = itemFieldLocations.Add(new IndexedToken(fieldId, locations)); + } + else + { + if (this.MutatedMatches.TryGetValue(itemId, out var originalItemFieldLocations)) + { + this.MutatedMatches[itemId] = originalItemFieldLocations.Add(indexedToken); + } + else + { + // This item has not been indexed at this location previously + var builder = ImmutableList.CreateBuilder(); + builder.Add(indexedToken); + this.MutatedMatches.Add(itemId, builder.ToImmutable()); + } + } + } + + private void EnsureMutatedMatchesCreated() + { + if (this.MutatedMatches == null) + { + this.HasMatches = true; + + if (this.original?.HasMatches ?? false) + { + // Once we're mutating matches, copy everything across + this.MutatedMatches = new Dictionary>( + this.original.Matches); + } + else + { + this.MutatedMatches = new Dictionary>(); + } + } + } + + private void SplitIntraNodeText(int splitIndex) + { + var splitChildNode = new IndexNodeMutation(this) + { + HasMatches = this.HasMatches, + HasChildNodes = this.HasChildNodes, + MutatedChildNodes = this.MutatedChildNodes, + MutatedMatches = this.MutatedMatches, + IntraNodeText = splitIndex + 1 == this.IntraNodeText.Length ? null : this.IntraNodeText.Slice(splitIndex + 1), + + // Pass the original down to the child node - the only state that matters there is any unmutated child nodes/matches + original = this.original + }; + + this.original = null; + + var splitChar = this.IntraNodeText.Span[splitIndex]; + + // Reset the matches at this node + this.MutatedMatches = null; + this.HasMatches = false; + + // Replace any remaining intra node text + this.IntraNodeText = splitIndex == 0 ? null : this.IntraNodeText.Slice(0, splitIndex); + + this.HasChildNodes = true; + this.MutatedChildNodes = new Dictionary + { + { splitChar, splitChildNode } + }; + } + + [Pure] + public override string ToString() + { + if (this.IsEmpty) + { + return ""; + } + + var builder = new StringBuilder(); + this.FormatNodeText(builder); + this.FormatChildNodeText(builder, 0); + + return builder.ToString(); + } + + private void ToString(StringBuilder builder, char linkChar, int currentDepth) + { + builder.Append(' ', currentDepth * 2) + .Append(linkChar) + .Append('*') + .Append(' '); + + this.FormatNodeText(builder); + + this.FormatChildNodeText(builder, currentDepth); + } + + private void FormatChildNodeText(StringBuilder builder, int currentDepth) + { + if (this.HasChildNodes) + { + var nextDepth = currentDepth + 1; + if (this.original != null) + { + foreach (var item in this.original.ChildNodes.Where(e => this.MutatedChildNodes == null || !this.MutatedChildNodes.ContainsKey(e.Key))) + { + builder.AppendLine(); + item.Value.ToString(builder, item.Key, nextDepth); + } + } + + if (this.MutatedChildNodes != null) + { + foreach (var item in this.MutatedChildNodes) + { + builder.AppendLine(); + item.Value.ToString(builder, item.Key, nextDepth); + } + } + } + } + + private void FormatNodeText(StringBuilder builder) + { + if (this.IntraNodeText.Length > 0) + { + builder.Append(this.IntraNodeText); + } + + if (this.HasMatches) + { + builder.Append($" [{this.original?.Matches.Count ?? 0} original matche(s) - {this.MutatedMatches?.Count ?? 0} mutated]"); + } + } + } +} diff --git a/test/Lifti.Tests/IndexInsertionMutationTests.cs b/test/Lifti.Tests/IndexInsertionMutationTests.cs index 5ae3afb8..0dea869e 100644 --- a/test/Lifti.Tests/IndexInsertionMutationTests.cs +++ b/test/Lifti.Tests/IndexInsertionMutationTests.cs @@ -1,190 +1,133 @@ -using FluentAssertions; -using Lifti.Tokenization; -using System; -using System.Collections.Immutable; -using System.Linq; -using Xunit; - -namespace Lifti.Tests -{ - public class IndexInsertionMutationTests - { - private readonly IndexNodeFactory nodeFactory; - private readonly IndexNode rootNode; - private readonly IndexMutation sut; - private readonly IndexedToken locations1 = CreateLocations(0, (0, 1, 2), (1, 5, 8)); - private readonly IndexedToken locations2 = CreateLocations(0, (2, 9, 2)); - private readonly IndexedToken locations3 = CreateLocations(0, (3, 14, 5)); - private readonly IndexedToken locations4 = CreateLocations(0, (4, 4, 5)); - private const int item1 = 1; - private const int item2 = 2; - private const int item3 = 3; - private const int item4 = 4; - private const byte fieldId1 = 0; - - public IndexInsertionMutationTests() - { - this.nodeFactory = new IndexNodeFactory(new IndexOptions { SupportIntraNodeTextAfterIndexDepth = 0 } ); - this.rootNode = this.nodeFactory.CreateRootNode(); - this.sut = new IndexMutation(this.rootNode, this.nodeFactory); - } - - [Fact] - public void IndexingEmptyNode_ShouldResultInItemsDirectlyIndexedAtNode() - { - this.sut.Add(item1, fieldId1, new Token("test", this.locations1.Locations)); - var result = this.sut.Apply(); - - VerifyResult(result, "test", new[] { (item1, this.locations1) }); - } - - [Theory] - [InlineData("test")] - [InlineData("a")] - public void IndexingAtNodeWithSameTextForDifferentItem_ShouldResultInItemsDirectlyIndexedAtNode(string word) - { - this.sut.Add(item1, fieldId1, new Token(word, this.locations1.Locations)); - this.sut.Add(item2, fieldId1, new Token(word, this.locations2.Locations)); - var result = this.sut.Apply(); - - VerifyResult(result, word, new[] { (item1, this.locations1), (item2, this.locations2) }); - } - - [Fact] - public void IndexingWordEndingAtSplit_ShouldResultInItemIndexedWhereSplitOccurs() - { - this.sut.Add(item1, fieldId1, new Token("apple", this.locations1.Locations)); - this.sut.Add(item2, fieldId1, new Token("able", this.locations2.Locations)); - this.sut.Add(item3, fieldId1, new Token("banana", this.locations3.Locations)); - this.sut.Add(item4, fieldId1, new Token("a", this.locations4.Locations)); - var result = this.sut.Apply(); - - VerifyResult(result, null, expectedChildNodes: new[] { 'a', 'b' }); - VerifyResult(result, new[] { 'a' }, null, new[] { (item4, this.locations4) }, new[] { 'p', 'b' }); - VerifyResult(result, new[] { 'b' }, "anana", new[] { (item3, this.locations3) }); - VerifyResult(result, new[] { 'a', 'b' }, "le", new[] { (item2, this.locations2) }); - VerifyResult(result, new[] { 'a', 'p' }, "ple", new[] { (item1, this.locations1) }); - } - - [Fact] - public void IndexingWhenChildNodeAlreadyExists_ShouldContinueIndexingAtExistingChild() - { - this.sut.Add(item1, fieldId1, new Token("freedom", this.locations1.Locations)); - this.sut.Add(item2, fieldId1, new Token("fred", this.locations2.Locations)); - this.sut.Add(item3, fieldId1, new Token("freddy", this.locations3.Locations)); - var result = this.sut.Apply(); - - VerifyResult(result, "fre", expectedChildNodes: new[] { 'e', 'd' }); - VerifyResult(result, new[] { 'e' }, "dom", new[] { (item1, this.locations1) }); - VerifyResult(result, new[] { 'd' }, null, new[] { (item2, this.locations2) }, new[] { 'd' }); - VerifyResult(result, new[] { 'd', 'd' }, "y", new[] { (item3, this.locations3) }); - } - - [Fact] - public void IndexingAtNodeWithTextWithSameSuffix_ShouldCreateNewChildNode() - { - this.sut.Add(item1, fieldId1, new Token("test", this.locations1.Locations)); - this.sut.Add(item2, fieldId1, new Token("testing", this.locations2.Locations)); - this.sut.Add(item3, fieldId1, new Token("tester", this.locations3.Locations)); - var result = this.sut.Apply(); - - VerifyResult(result, "test", new[] { (item1, this.locations1) }, new[] { 'i', 'e' }); - VerifyResult(result, new[] { 'i' }, "ng", new[] { (item2, this.locations2) }); - VerifyResult(result, new[] { 'e' }, "r", new[] { (item3, this.locations3) }); - } - - [Theory] - [InlineData("pest", 't', 'p', null, "est", "est")] - [InlineData("taste", 'e', 'a', "t", "st", "ste")] - [InlineData("tesa", 't', 'a', "tes", null, null)] - public void IndexingAtNodeWithIntraNodeTextWithDifferentText_ShouldResultInSplitNodes( - string indexText, - char originalSplitChar, - char newSplitChar, - string remainingIntraText, - string splitIntraText, - string newIntraText) - { - this.sut.Add(item1, fieldId1, new Token("test", this.locations1.Locations)); - this.sut.Add(item2, fieldId1, new Token(indexText, this.locations2.Locations)); - var result = this.sut.Apply(); - - VerifyResult(result, remainingIntraText, expectedChildNodes: new[] { originalSplitChar, newSplitChar }); - VerifyResult(result, new[] { originalSplitChar }, splitIntraText, new[] { (item1, this.locations1) }); - VerifyResult(result, new[] { newSplitChar }, newIntraText, new[] { (item2, this.locations2) }); - } - - [Fact] - public void IndexingAtNodeCausingSplitAtMiddleOfIntraNodeText_ShouldPlaceMatchAtSplit() - { - this.sut.Add(item1, fieldId1, new Token("NOITAZI", this.locations1.Locations)); - this.sut.Add(item2, fieldId1, new Token("NOITA", this.locations2.Locations)); - var result = this.sut.Apply(); - - VerifyResult(result, "NOITA", new[] { (item2, this.locations2) }, expectedChildNodes: new[] { 'Z' }); - VerifyResult(result, new[] { 'Z' }, "I", new[] { (item1, this.locations1) }); - } - - [Fact] - public void IndexingAtNodeCausingSplitAtStartOfIntraNodeText_ShouldReturnInEntryAddedAtSplitNode() - { - this.sut.Add(item1, fieldId1, new Token("www", this.locations1.Locations)); - this.sut.Add(item2, fieldId1, new Token("w3c", this.locations2.Locations)); - this.sut.Add(item3, fieldId1, new Token("w3", this.locations3.Locations)); - var result = this.sut.Apply(); - - VerifyResult(result, "w", expectedChildNodes: new[] { 'w', '3' }); - VerifyResult(result, new[] { 'w' }, "w", new[] { (item1, this.locations1) }); - VerifyResult(result, new[] { '3' }, null, new[] { (item3, this.locations3) }, new[] { 'c' }); - VerifyResult(result, new[] { '3', 'c' }, null, new[] { (item2, this.locations2) }); - } - - // TODO Move to new test suite - //[Fact] - //public void RemovingItemId_ShouldCauseItemToBeRemovedFromIndexAndChildNodes() - //{ - // this.sut.Index(item1, fieldId1, new Token("www", this.locations1.Locations)); - // this.sut.Index(item1, fieldId1, new Token("wwwww", this.locations2.Locations)); - // this.sut.Remove(item1); - - // var result = this.sut.ApplyMutations(); - - // VerifyResult(result, "www", expectedChildNodes: new[] { 'w' }, expectedMatches: Array.Empty<(int, IndexedWord)>()); - // VerifyResult(result, new[] { 'w' }, "w", expectedMatches: Array.Empty<(int, IndexedWord)>()); - //} - - private static IndexedToken CreateLocations(byte fieldId, params (int, int, ushort)[] locations) - { - return new IndexedToken(fieldId, locations.Select(r => new TokenLocation(r.Item1, r.Item2, r.Item3)).ToArray()); - } - - private static void VerifyResult( - IndexNode node, - string intraNodeText, - (int, IndexedToken)[] expectedMatches = null, - char[] expectedChildNodes = null) - { - expectedChildNodes ??= Array.Empty(); - expectedMatches ??= Array.Empty<(int, IndexedToken)>(); - - node.IntraNodeText.ToArray().Should().BeEquivalentTo(intraNodeText?.ToCharArray() ?? Array.Empty()); - node.ChildNodes.Keys.Should().BeEquivalentTo(expectedChildNodes, o => o.WithoutStrictOrdering()); - node.Matches.Should().BeEquivalentTo(expectedMatches.ToImmutableDictionary(x => x.Item1, x => new[] { x.Item2 })); - } - - private static void VerifyResult( - IndexNode node, - char[] navigationChars, - string intraNodeText, - (int, IndexedToken)[] expectedMatches = null, - char[] expectedChildNodes = null) - { - foreach (var navigationChar in navigationChars) - { - node = node.ChildNodes[navigationChar]; - } - - VerifyResult(node, intraNodeText, expectedMatches, expectedChildNodes); - } - } -} +using Lifti.Tokenization; +using Xunit; + +namespace Lifti.Tests +{ + public class IndexInsertionMutationTests : MutationTestBase + { + + [Fact] + public void IndexingEmptyNode_ShouldResultInItemsDirectlyIndexedAtNode() + { + this.Sut.Add(Item1, FieldId1, new Token("test", this.Locations1.Locations)); + var result = this.Sut.Apply(); + + VerifyResult(result, "test", new[] { (Item1, this.Locations1) }); + } + + [Theory] + [InlineData("test")] + [InlineData("a")] + public void IndexingAtNodeWithSameTextForDifferentItem_ShouldResultInItemsDirectlyIndexedAtNode(string word) + { + this.Sut.Add(Item1, FieldId1, new Token(word, this.Locations1.Locations)); + this.Sut.Add(Item2, FieldId1, new Token(word, this.Locations2.Locations)); + var result = this.Sut.Apply(); + + VerifyResult(result, word, new[] { (Item1, this.Locations1), (Item2, this.Locations2) }); + } + + [Fact] + public void IndexingWordEndingAtSplit_ShouldResultInItemIndexedWhereSplitOccurs() + { + this.Sut.Add(Item1, FieldId1, new Token("apple", this.Locations1.Locations)); + this.Sut.Add(Item2, FieldId1, new Token("able", this.Locations2.Locations)); + this.Sut.Add(Item3, FieldId1, new Token("banana", this.Locations3.Locations)); + this.Sut.Add(Item4, FieldId1, new Token("a", this.Locations4.Locations)); + var result = this.Sut.Apply(); + + VerifyResult(result, null, expectedChildNodes: new[] { 'a', 'b' }); + VerifyResult(result, new[] { 'a' }, null, new[] { (Item4, this.Locations4) }, new[] { 'p', 'b' }); + VerifyResult(result, new[] { 'b' }, "anana", new[] { (Item3, this.Locations3) }); + VerifyResult(result, new[] { 'a', 'b' }, "le", new[] { (Item2, this.Locations2) }); + VerifyResult(result, new[] { 'a', 'p' }, "ple", new[] { (Item1, this.Locations1) }); + } + + [Fact] + public void IndexingWhenChildNodeAlreadyExists_ShouldContinueIndexingAtExistingChild() + { + this.Sut.Add(Item1, FieldId1, new Token("freedom", this.Locations1.Locations)); + this.Sut.Add(Item2, FieldId1, new Token("fred", this.Locations2.Locations)); + this.Sut.Add(Item3, FieldId1, new Token("freddy", this.Locations3.Locations)); + var result = this.Sut.Apply(); + + VerifyResult(result, "fre", expectedChildNodes: new[] { 'e', 'd' }); + VerifyResult(result, new[] { 'e' }, "dom", new[] { (Item1, this.Locations1) }); + VerifyResult(result, new[] { 'd' }, null, new[] { (Item2, this.Locations2) }, new[] { 'd' }); + VerifyResult(result, new[] { 'd', 'd' }, "y", new[] { (Item3, this.Locations3) }); + } + + [Fact] + public void IndexingAtNodeWithTextWithSameSuffix_ShouldCreateNewChildNode() + { + this.Sut.Add(Item1, FieldId1, new Token("test", this.Locations1.Locations)); + this.Sut.Add(Item2, FieldId1, new Token("testing", this.Locations2.Locations)); + this.Sut.Add(Item3, FieldId1, new Token("tester", this.Locations3.Locations)); + var result = this.Sut.Apply(); + + VerifyResult(result, "test", new[] { (Item1, this.Locations1) }, new[] { 'i', 'e' }); + VerifyResult(result, new[] { 'i' }, "ng", new[] { (Item2, this.Locations2) }); + VerifyResult(result, new[] { 'e' }, "r", new[] { (Item3, this.Locations3) }); + } + + [Fact] + public void IndexingAtNodeAlreadySplit_ShouldMaintainMatchesAtFirstSplitNode() + { + this.Sut.Add(Item1, FieldId1, new Token("broker", this.Locations1.Locations)); + this.Sut.Add(Item2, FieldId1, new Token("broken", this.Locations2.Locations)); + this.Sut.Add(Item3, FieldId1, new Token("brokerage", this.Locations3.Locations)); + var result = this.Sut.Apply(); + + VerifyResult(result, "broke", expectedChildNodes: new[] { 'r', 'n' }); + VerifyResult(result, new[] { 'r' }, "", new[] { (Item1, this.Locations1) }, new[] { 'a' }); + VerifyResult(result, new[] { 'n' }, "", new[] { (Item2, this.Locations2) }); + VerifyResult(result, new[] { 'r', 'a' }, "ge", new[] { (Item3, this.Locations3) }); + } + + [Theory] + [InlineData("pest", 't', 'p', null, "est", "est")] + [InlineData("taste", 'e', 'a', "t", "st", "ste")] + [InlineData("tesa", 't', 'a', "tes", null, null)] + public void IndexingAtNodeWithIntraNodeTextWithDifferentText_ShouldResultInSplitNodes( + string indexText, + char originalSplitChar, + char newSplitChar, + string remainingIntraText, + string splitIntraText, + string newIntraText) + { + this.Sut.Add(Item1, FieldId1, new Token("test", this.Locations1.Locations)); + this.Sut.Add(Item2, FieldId1, new Token(indexText, this.Locations2.Locations)); + var result = this.Sut.Apply(); + + VerifyResult(result, remainingIntraText, expectedChildNodes: new[] { originalSplitChar, newSplitChar }); + VerifyResult(result, new[] { originalSplitChar }, splitIntraText, new[] { (Item1, this.Locations1) }); + VerifyResult(result, new[] { newSplitChar }, newIntraText, new[] { (Item2, this.Locations2) }); + } + + [Fact] + public void IndexingAtNodeCausingSplitAtMiddleOfIntraNodeText_ShouldPlaceMatchAtSplit() + { + this.Sut.Add(Item1, FieldId1, new Token("NOITAZI", this.Locations1.Locations)); + this.Sut.Add(Item2, FieldId1, new Token("NOITA", this.Locations2.Locations)); + var result = this.Sut.Apply(); + + VerifyResult(result, "NOITA", new[] { (Item2, this.Locations2) }, expectedChildNodes: new[] { 'Z' }); + VerifyResult(result, new[] { 'Z' }, "I", new[] { (Item1, this.Locations1) }); + } + + [Fact] + public void IndexingAtNodeCausingSplitAtStartOfIntraNodeText_ShouldReturnInEntryAddedAtSplitNode() + { + this.Sut.Add(Item1, FieldId1, new Token("www", this.Locations1.Locations)); + this.Sut.Add(Item2, FieldId1, new Token("w3c", this.Locations2.Locations)); + this.Sut.Add(Item3, FieldId1, new Token("w3", this.Locations3.Locations)); + var result = this.Sut.Apply(); + + VerifyResult(result, "w", expectedChildNodes: new[] { 'w', '3' }); + VerifyResult(result, new[] { 'w' }, "w", new[] { (Item1, this.Locations1) }); + VerifyResult(result, new[] { '3' }, null, new[] { (Item3, this.Locations3) }, new[] { 'c' }); + VerifyResult(result, new[] { '3', 'c' }, null, new[] { (Item2, this.Locations2) }); + } + } +} diff --git a/test/Lifti.Tests/IndexRemovalMutationTests.cs b/test/Lifti.Tests/IndexRemovalMutationTests.cs new file mode 100644 index 00000000..8692c94a --- /dev/null +++ b/test/Lifti.Tests/IndexRemovalMutationTests.cs @@ -0,0 +1,53 @@ +using Lifti.Tokenization; +using Xunit; + +namespace Lifti.Tests +{ + public class IndexRemovalMutationTests : MutationTestBase + { + [Fact] + public void RemovingItemIdDuringMutation_ShouldCauseItemToBeRemovedFromIndexAndChildNodes() + { + this.Sut.Add(Item1, FieldId1, new Token("www", this.Locations1.Locations)); + this.Sut.Add(Item1, FieldId1, new Token("wwwww", this.Locations2.Locations)); + this.Sut.Remove(Item1); + + var result = this.Sut.Apply(); + + VerifyResult(result, "www", expectedChildNodes: new[] { 'w' }); + VerifyResult(result, new[] { 'w' }, "w"); + } + + [Fact] + public void RemovingItemIdFromUnmutatedIndex_ShouldCauseItemToBeRemovedFromIndexAndChildNodes() + { + this.Sut.Add(Item1, FieldId1, new Token("www", this.Locations1.Locations)); + this.Sut.Add(Item1, FieldId1, new Token("wwwww", this.Locations2.Locations)); + + this.ApplyMutationsToNewSut(); + + this.Sut.Remove(Item1); + + var result = this.Sut.Apply(); + + VerifyResult(result, "www", expectedChildNodes: new[] { 'w' }); + VerifyResult(result, new[] { 'w' }, "w"); + } + + [Fact] + public void RemovingItemIdFromUnmutatedIndex_ShouldNotAffectOtherItemsData() + { + this.Sut.Add(Item1, FieldId1, new Token("www", this.Locations1.Locations)); + this.Sut.Add(Item2, FieldId1, new Token("wwwww", this.Locations2.Locations)); + + this.ApplyMutationsToNewSut(); + + this.Sut.Remove(Item1); + + var result = this.Sut.Apply(); + + VerifyResult(result, "www", expectedChildNodes: new[] { 'w' }); + VerifyResult(result, new[] { 'w' }, "w", new[] { (Item2, this.Locations2) }); + } + } +} diff --git a/test/Lifti.Tests/MutationTestBase.cs b/test/Lifti.Tests/MutationTestBase.cs new file mode 100644 index 00000000..8df6c284 --- /dev/null +++ b/test/Lifti.Tests/MutationTestBase.cs @@ -0,0 +1,73 @@ +using FluentAssertions; +using System; +using System.Collections.Immutable; +using System.Linq; + +namespace Lifti.Tests +{ + public abstract class MutationTestBase + { + private readonly IndexNodeFactory indexNodeFactory; + + protected readonly IndexedToken Locations1 = CreateLocations(0, (0, 1, 2), (1, 5, 8)); + protected readonly IndexedToken Locations2 = CreateLocations(0, (2, 9, 2)); + protected readonly IndexedToken Locations3 = CreateLocations(0, (3, 14, 5)); + protected readonly IndexedToken Locations4 = CreateLocations(0, (4, 4, 5)); + protected const int Item1 = 1; + protected const int Item2 = 2; + protected const int Item3 = 3; + protected const int Item4 = 4; + protected const byte FieldId1 = 0; + + protected MutationTestBase() + { + this.indexNodeFactory = new IndexNodeFactory(new IndexOptions { SupportIntraNodeTextAfterIndexDepth = 0 }); + this.RootNode = this.indexNodeFactory.CreateRootNode(); + this.Sut = new IndexMutation(this.RootNode, this.indexNodeFactory); + } + + protected IndexNode RootNode { get; } + internal IndexMutation Sut { get; set; } + + protected void ApplyMutationsToNewSut() + { + this.Sut = new IndexMutation(this.Sut.Apply(), this.indexNodeFactory); + } + + protected static void VerifyResult( + IndexNode node, + string intraNodeText, + (int, IndexedToken)[] expectedMatches = null, + char[] expectedChildNodes = null) + { + expectedChildNodes ??= Array.Empty(); + expectedMatches ??= Array.Empty<(int, IndexedToken)>(); + + node.HasChildNodes.Should().Be(expectedChildNodes.Count() > 0); + node.HasMatches.Should().Be(expectedMatches.Count() > 0); + node.IntraNodeText.ToArray().Should().BeEquivalentTo(intraNodeText?.ToCharArray() ?? Array.Empty()); + node.ChildNodes.Keys.Should().BeEquivalentTo(expectedChildNodes, o => o.WithoutStrictOrdering()); + node.Matches.Should().BeEquivalentTo(expectedMatches.ToImmutableDictionary(x => x.Item1, x => new[] { x.Item2 })); + } + + protected static void VerifyResult( + IndexNode node, + char[] navigationChars, + string intraNodeText, + (int, IndexedToken)[] expectedMatches = null, + char[] expectedChildNodes = null) + { + foreach (var navigationChar in navigationChars) + { + node = node.ChildNodes[navigationChar]; + } + + VerifyResult(node, intraNodeText, expectedMatches, expectedChildNodes); + } + + private static IndexedToken CreateLocations(byte fieldId, params (int, int, ushort)[] locations) + { + return new IndexedToken(fieldId, locations.Select(r => new TokenLocation(r.Item1, r.Item2, r.Item3)).ToArray()); + } + } +}