diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a9a55b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +# Trigger the workflow on pushes to main branches and pull requests +on: + push: + branches: + - main + - master + - develop + pull_request: + branches: + - main + - master + - develop + +jobs: + build-and-test: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: csharp + + steps: + # Step 1: Checkout the repository + - name: Checkout repository + uses: actions/checkout@v4 + + # Step 2: Setup .NET SDK + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + # Step 3: Restore dependencies + - name: Restore dependencies + run: dotnet restore + + # Step 4: Build the project + - name: Build + run: dotnet build --configuration Release --no-restore + + # Step 5: Run tests + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" + + # Step 6: Upload test results + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: '**/TestResults/**/*' + + # Step 7: Build summary + - name: Build Summary + if: always() + run: | + echo "### Build Summary :rocket:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Status**: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + echo "- **Branch**: ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commit**: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY diff --git a/csharp/.changeset/fix-issue-62-review-coverage.md b/csharp/.changeset/fix-issue-62-review-coverage.md new file mode 100644 index 0000000..b48f769 --- /dev/null +++ b/csharp/.changeset/fix-issue-62-review-coverage.md @@ -0,0 +1,5 @@ +--- +'Foundation.Data.Doublets.Cli': patch +--- + +Fixed explicit indexed numeric updates so auto-created numeric references do not steal the substitution pair, and added issue 62 regression coverage. diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs new file mode 100644 index 0000000..e4087fe --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Linq; +using Platform.Data.Doublets; +using Xunit; +using DoubletLink = Platform.Data.Doublets.Link; + +namespace Foundation.Data.Doublets.Cli.Tests +{ + public class Issue62ReviewCoverageTests + { + [Fact] + public void ExplicitNumericIdUpdate_CanBeReversedWithAnotherUpdate() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((1: 1 1)))"); + AssertLink(links, 1, 1, 1); + + ProcessQuery(links, "(((1: 1 1)) ((1: 2 2)))"); + AssertLink(links, 1, 2, 2); + + ProcessQuery(links, "(((1: 2 2)) ((1: 1 1)))"); + AssertLink(links, 1, 1, 1); + }); + } + + [Fact] + public void NamedLink_CreateDeleteRecreate_DoesNotLeaveStaleNameMapping() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((child: father mother)))"); + var firstChild = links.GetByName("child"); + Assert.NotEqual(links.Constants.Null, firstChild); + + ProcessQuery(links, "((child: father mother)) ()"); + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.Null(links.GetName(firstChild)); + + ProcessQuery(links, "(() ((child: father mother)))"); + var recreatedChild = links.GetByName("child"); + Assert.NotEqual(links.Constants.Null, recreatedChild); + Assert.Equal("child", links.GetName(recreatedChild)); + }); + } + + private static void RunTestWithLinks(Action> testAction) + { + var tempDbFile = Path.GetTempFileName(); + NamedTypesDecorator? links = null; + try + { + links = new NamedTypesDecorator(tempDbFile); + testAction(links); + } + finally + { + if (links != null && File.Exists(links.NamedLinksDatabaseFileName)) + { + File.Delete(links.NamedLinksDatabaseFileName); + } + if (File.Exists(tempDbFile)) + { + File.Delete(tempDbFile); + } + } + } + + private static void ProcessQuery(NamedTypesDecorator links, string query) + { + AdvancedMixedQueryProcessor.ProcessQuery( + links, + new AdvancedMixedQueryProcessor.Options + { + Query = query, + AutoCreateMissingReferences = true + }); + } + + private static void AssertLink(NamedTypesDecorator links, uint index, uint source, uint target) + { + var any = links.Constants.Any; + var allLinks = links.All(new DoubletLink(any, any, any)) + .Select(link => new DoubletLink(link)) + .ToList(); + + var formattedLinks = string.Join(" ", allLinks.Select(link => $"({link.Index}: {link.Source}->{link.Target})")); + Assert.True( + allLinks.Any(link => link.Index == index && link.Source == source && link.Target == target), + $"Expected link ({index}: {source}->{target}) but found: {formattedLinks}"); + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs index 49da542..bcd2fad 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs @@ -43,6 +43,28 @@ public void Should_Create_And_Iterate_Over_Types() } } + [Fact] + public void Should_Terminate_Enumeration_When_Iterated_Without_Take() + { + // Arrange + var tempDbFile = Path.GetTempFileName(); + try + { + using var links = new UnitedMemoryLinks(tempDbFile); + var pinnedTypes = new PinnedTypes(links); + + // Act + var result = pinnedTypes.ToList(); + + // Assert + Assert.Equal(new ulong[] { 1, 2, 3, 4, 5, 6 }, result); + } + finally + { + File.Delete(tempDbFile); + } + } + [Fact] public void Should_Validate_Existing_Links() { @@ -103,7 +125,7 @@ public void Should_Throw_Exception_For_Invalid_Link_Structure() var allLinks = links.All(); // Act & Assert - var exception = Assert.Throws(() => + var exception = Assert.Throws(() => { var result = new List(); foreach (var type in pinnedTypes.Take(numberOfTypes)) @@ -248,4 +270,4 @@ public void Should_Destructure_PinnedTypes() } } } -} \ No newline at end of file +} diff --git a/csharp/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs index b032714..21c5c80 100644 --- a/csharp/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli/AdvancedMixedQueryProcessor.cs @@ -30,6 +30,9 @@ public class Options public static void ProcessQuery(INamedTypesLinks links, Options options) { + ArgumentNullException.ThrowIfNull(links); + ArgumentNullException.ThrowIfNull(options); + var query = options.Query; TraceIfEnabled(options, $"[ProcessQuery] Query: \"{query}\""); @@ -123,16 +126,18 @@ public static void ProcessQuery(INamedTypesLinks links, Options options) .ToList(); // ---------------------------------------------------------------- - // FIX: If we see restrictionLink with exactly 1 sub-link => that sub-link has 2 sub-values => no IDs => interpret as a single composite pattern + // FIX: If we see restrictionLink with exactly 1 sub-link => that sub-link has 2 sub-values => interpret as a single composite pattern + // This handles patterns like ((() (1 2))) where the outer restriction has a single composite child if ( string.IsNullOrEmpty(restrictionLink.Id) && restrictionLink.Values?.Count == 1 ) { var single = restrictionLink.Values[0]; + // Check if this is a composite (has 2 sub-values) and doesn't have a numeric/wildcard ID if ( - string.IsNullOrEmpty(single.Id) && - single.Values?.Count == 2 && !IsNumericOrStar(single.Id) + single.Values?.Count == 2 && + (string.IsNullOrEmpty(single.Id) || !IsNumericOrStar(single.Id)) ) { // Create a single composite pattern from ((1 *) (* 2)) @@ -150,7 +155,7 @@ public static void ProcessQuery(INamedTypesLinks links, Options options) restrictionInternalPatterns.Add(topLevelPattern); TraceIfEnabled(options, - "[ProcessQuery] Detected single sub-link (no ID) with 2 sub-values => replaced with one composite restriction pattern."); + "[ProcessQuery] Detected single sub-link with 2 sub-values => replaced with one composite restriction pattern."); } } // ---------------------------------------------------------------- @@ -249,24 +254,32 @@ public static void ProcessQuery(INamedTypesLinks links, Options options) var unexpectedDeletions = new List(); var originalHandler = options.ChangesHandler; - options.ChangesHandler = (before, after) => + + try { - var beforeLink = new DoubletLink(before); - var afterLink = new DoubletLink(after); - if (beforeLink.Index != 0 && afterLink.Index == 0) + options.ChangesHandler = (before, after) => { - bool isExpected = allPlannedOperations.Any(op => op.before.Index == beforeLink.Index && op.after.Index == 0); - if (!isExpected) + var beforeLink = new DoubletLink(before); + var afterLink = new DoubletLink(after); + if (beforeLink.Index != 0 && afterLink.Index == 0) { - unexpectedDeletions.Add(new DoubletLink(beforeLink)); - TraceIfEnabled(options, $"[ProcessQuery] Detected unexpected deletion of link #{beforeLink.Index} => will restore later."); + bool isExpected = allPlannedOperations.Any(op => op.before.Index == beforeLink.Index && op.after.Index == 0); + if (!isExpected) + { + unexpectedDeletions.Add(new DoubletLink(beforeLink)); + TraceIfEnabled(options, $"[ProcessQuery] Detected unexpected deletion of link #{beforeLink.Index} => will restore later."); + } } - } - return originalHandler?.Invoke(before, after) ?? links.Constants.Continue; - }; + return originalHandler?.Invoke(before, after) ?? links.Constants.Continue; + }; - TraceIfEnabled(options, "[ProcessQuery] Applying all planned operations..."); - ApplyAllPlannedOperations(links, allPlannedOperations, options); + TraceIfEnabled(options, "[ProcessQuery] Applying all planned operations..."); + ApplyAllPlannedOperations(links, allPlannedOperations, options); + } + finally + { + options.ChangesHandler = originalHandler; + } TraceIfEnabled(options, "[ProcessQuery] Restoring unexpected deletions if any..."); RestoreUnexpectedLinkDeletions(links, unexpectedDeletions, intendedFinalStates, options); @@ -680,9 +693,10 @@ private static uint ResolveId( { return anyConstant; } - if (TryParseLinkId(identifier, links, ref anyConstant)) + uint parsedValue = anyConstant; + if (TryParseLinkId(identifier, links, ref parsedValue)) { - return anyConstant; + return parsedValue; } return anyConstant; } @@ -921,10 +935,6 @@ private static void CreateOrUpdateLink(INamedTypesLinks links, DoubletLink TraceIfEnabled(options, $"[CreateOrUpdateLink] Updating link #{linkDefinition.Index}: {existingDoublet.Source}->{linkDefinition.Source}, {existingDoublet.Target}->{linkDefinition.Target}."); LinksExtensions.EnsureCreated(links, linkDefinition.Index); - options.ChangesHandler?.Invoke( - new DoubletLink(linkDefinition.Index, nullConstant, nullConstant), - new DoubletLink(linkDefinition.Index, nullConstant, nullConstant) - ); links.Update( new DoubletLink(linkDefinition.Index, anyConstant, anyConstant), linkDefinition, @@ -1377,7 +1387,7 @@ private static void ValidateLinksExistOrWillBeCreated( ); } - AutoCreateMissingReferences(links, plan.MissingReferences, options); + AutoCreateMissingReferences(links, plan, options); } TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Validation completed"); @@ -1387,6 +1397,7 @@ private sealed class LinkReferencePlan { public HashSet NumericIdsToBeCreated { get; } = new(); public HashSet NamesToBeCreated { get; } = new(StringComparer.Ordinal); + public HashSet<(uint Source, uint Target)> CompositePairsToBeCreated { get; } = new(); public List MissingReferences { get; } = new(); private readonly HashSet _missingReferenceKeys = new(StringComparer.Ordinal); @@ -1422,6 +1433,11 @@ private static LinkReferencePlan BuildLinkReferencePlan(INamedTypesLinks l CollectImplicitDefinitions(pattern, links, plan, reservedNumericIds); } + foreach (var pattern in substitutionPatterns) + { + CollectCompositePairs(pattern, plan); + } + return plan; } @@ -1449,6 +1465,26 @@ private static void CollectExplicitDefinitions(LinoLink pattern, LinkReferencePl } } + private static void CollectCompositePairs(LinoLink pattern, LinkReferencePlan plan) + { + if (IsComposite(pattern) && + TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier) && + pattern.Values != null && + TryGetConcreteNumericIdentifier(pattern.Values[0].Id, out var source) && + TryGetConcreteNumericIdentifier(pattern.Values[1].Id, out var target)) + { + plan.CompositePairsToBeCreated.Add((source, target)); + } + + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectCompositePairs(subPattern, plan); + } + } + } + private static void CollectImplicitDefinitions( LinoLink pattern, INamedTypesLinks links, @@ -1558,10 +1594,10 @@ private static void ValidateReferenceIdentifier( private static void AutoCreateMissingReferences( INamedTypesLinks links, - IList missingReferences, + LinkReferencePlan plan, Options options) { - foreach (var missing in missingReferences.Where(reference => reference.NumericId.HasValue).OrderBy(reference => reference.NumericId!.Value)) + foreach (var missing in plan.MissingReferences.Where(reference => reference.NumericId.HasValue).OrderBy(reference => reference.NumericId!.Value)) { var linkId = missing.NumericId!.Value; if (links.Exists(linkId)) @@ -1569,8 +1605,13 @@ private static void AutoCreateMissingReferences( continue; } - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {linkId} as point link."); + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {linkId}."); LinksExtensions.EnsureCreated(links, linkId); + if (plan.CompositePairsToBeCreated.Contains((linkId, linkId))) + { + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Link {linkId} exists as a placeholder because ({linkId}, {linkId}) is defined by the substitution."); + continue; + } links.Update( new DoubletLink(linkId, links.Constants.Null, links.Constants.Null), new DoubletLink(linkId, linkId, linkId), @@ -1579,7 +1620,7 @@ private static void AutoCreateMissingReferences( ); } - foreach (var missing in missingReferences.Where(reference => !reference.NumericId.HasValue).OrderBy(reference => reference.Identifier, StringComparer.Ordinal)) + foreach (var missing in plan.MissingReferences.Where(reference => !reference.NumericId.HasValue).OrderBy(reference => reference.Identifier, StringComparer.Ordinal)) { if (links.GetByName(missing.Identifier) != links.Constants.Null) { @@ -1626,5 +1667,11 @@ private static bool TryGetConcreteIdentifier(string? id, out string identifier) return true; } + + private static bool TryGetConcreteNumericIdentifier(string? id, out uint linkId) + { + linkId = 0; + return TryGetConcreteIdentifier(id, out var identifier) && uint.TryParse(identifier, out linkId); + } } } diff --git a/csharp/Foundation.Data.Doublets.Cli/EnumerableExtensions.cs b/csharp/Foundation.Data.Doublets.Cli/EnumerableExtensions.cs index 1651473..0b231fc 100644 --- a/csharp/Foundation.Data.Doublets.Cli/EnumerableExtensions.cs +++ b/csharp/Foundation.Data.Doublets.Cli/EnumerableExtensions.cs @@ -1,81 +1,84 @@ using System; using System.Collections.Generic; -public static class EnumerableExtensions +namespace Foundation.Data.Doublets.Cli { - public static void Deconstruct(this IEnumerable source, out T first) + public static class EnumerableExtensions { - using var enumerator = source.GetEnumerator(); - first = enumerator.MoveNext() ? enumerator.Current : default!; - } + public static void Deconstruct(this IEnumerable source, out T first) + { + using var enumerator = source.GetEnumerator(); + first = enumerator.MoveNext() ? enumerator.Current : default!; + } - public static void Deconstruct(this IEnumerable source, out T first, out T second) - { - using var enumerator = source.GetEnumerator(); - first = enumerator.MoveNext() ? enumerator.Current : default!; - second = enumerator.MoveNext() ? enumerator.Current : default!; - } + public static void Deconstruct(this IEnumerable source, out T first, out T second) + { + using var enumerator = source.GetEnumerator(); + first = enumerator.MoveNext() ? enumerator.Current : default!; + second = enumerator.MoveNext() ? enumerator.Current : default!; + } - public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third) - { - using var enumerator = source.GetEnumerator(); - first = enumerator.MoveNext() ? enumerator.Current : default!; - second = enumerator.MoveNext() ? enumerator.Current : default!; - third = enumerator.MoveNext() ? enumerator.Current : default!; - } + public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third) + { + using var enumerator = source.GetEnumerator(); + first = enumerator.MoveNext() ? enumerator.Current : default!; + second = enumerator.MoveNext() ? enumerator.Current : default!; + third = enumerator.MoveNext() ? enumerator.Current : default!; + } - public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third, out T fourth) - { - using var enumerator = source.GetEnumerator(); - first = enumerator.MoveNext() ? enumerator.Current : default!; - second = enumerator.MoveNext() ? enumerator.Current : default!; - third = enumerator.MoveNext() ? enumerator.Current : default!; - fourth = enumerator.MoveNext() ? enumerator.Current : default!; - } + public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third, out T fourth) + { + using var enumerator = source.GetEnumerator(); + first = enumerator.MoveNext() ? enumerator.Current : default!; + second = enumerator.MoveNext() ? enumerator.Current : default!; + third = enumerator.MoveNext() ? enumerator.Current : default!; + fourth = enumerator.MoveNext() ? enumerator.Current : default!; + } - public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third, out T fourth, out T fifth) - { - using var enumerator = source.GetEnumerator(); - first = enumerator.MoveNext() ? enumerator.Current : default!; - second = enumerator.MoveNext() ? enumerator.Current : default!; - third = enumerator.MoveNext() ? enumerator.Current : default!; - fourth = enumerator.MoveNext() ? enumerator.Current : default!; - fifth = enumerator.MoveNext() ? enumerator.Current : default!; - } + public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third, out T fourth, out T fifth) + { + using var enumerator = source.GetEnumerator(); + first = enumerator.MoveNext() ? enumerator.Current : default!; + second = enumerator.MoveNext() ? enumerator.Current : default!; + third = enumerator.MoveNext() ? enumerator.Current : default!; + fourth = enumerator.MoveNext() ? enumerator.Current : default!; + fifth = enumerator.MoveNext() ? enumerator.Current : default!; + } - public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third, out T fourth, out T fifth, out T sixth) - { - using var enumerator = source.GetEnumerator(); - first = enumerator.MoveNext() ? enumerator.Current : default!; - second = enumerator.MoveNext() ? enumerator.Current : default!; - third = enumerator.MoveNext() ? enumerator.Current : default!; - fourth = enumerator.MoveNext() ? enumerator.Current : default!; - fifth = enumerator.MoveNext() ? enumerator.Current : default!; - sixth = enumerator.MoveNext() ? enumerator.Current : default!; - } + public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third, out T fourth, out T fifth, out T sixth) + { + using var enumerator = source.GetEnumerator(); + first = enumerator.MoveNext() ? enumerator.Current : default!; + second = enumerator.MoveNext() ? enumerator.Current : default!; + third = enumerator.MoveNext() ? enumerator.Current : default!; + fourth = enumerator.MoveNext() ? enumerator.Current : default!; + fifth = enumerator.MoveNext() ? enumerator.Current : default!; + sixth = enumerator.MoveNext() ? enumerator.Current : default!; + } - public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third, out T fourth, out T fifth, out T sixth, out T seventh) - { - using var enumerator = source.GetEnumerator(); - first = enumerator.MoveNext() ? enumerator.Current : default!; - second = enumerator.MoveNext() ? enumerator.Current : default!; - third = enumerator.MoveNext() ? enumerator.Current : default!; - fourth = enumerator.MoveNext() ? enumerator.Current : default!; - fifth = enumerator.MoveNext() ? enumerator.Current : default!; - sixth = enumerator.MoveNext() ? enumerator.Current : default!; - seventh = enumerator.MoveNext() ? enumerator.Current : default!; - } + public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third, out T fourth, out T fifth, out T sixth, out T seventh) + { + using var enumerator = source.GetEnumerator(); + first = enumerator.MoveNext() ? enumerator.Current : default!; + second = enumerator.MoveNext() ? enumerator.Current : default!; + third = enumerator.MoveNext() ? enumerator.Current : default!; + fourth = enumerator.MoveNext() ? enumerator.Current : default!; + fifth = enumerator.MoveNext() ? enumerator.Current : default!; + sixth = enumerator.MoveNext() ? enumerator.Current : default!; + seventh = enumerator.MoveNext() ? enumerator.Current : default!; + } - public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third, out T fourth, out T fifth, out T sixth, out T seventh, out T eighth) - { - using var enumerator = source.GetEnumerator(); - first = enumerator.MoveNext() ? enumerator.Current : default!; - second = enumerator.MoveNext() ? enumerator.Current : default!; - third = enumerator.MoveNext() ? enumerator.Current : default!; - fourth = enumerator.MoveNext() ? enumerator.Current : default!; - fifth = enumerator.MoveNext() ? enumerator.Current : default!; - sixth = enumerator.MoveNext() ? enumerator.Current : default!; - seventh = enumerator.MoveNext() ? enumerator.Current : default!; - eighth = enumerator.MoveNext() ? enumerator.Current : default!; + public static void Deconstruct(this IEnumerable source, out T first, out T second, out T third, out T fourth, out T fifth, out T sixth, out T seventh, out T eighth) + { + using var enumerator = source.GetEnumerator(); + first = enumerator.MoveNext() ? enumerator.Current : default!; + second = enumerator.MoveNext() ? enumerator.Current : default!; + third = enumerator.MoveNext() ? enumerator.Current : default!; + fourth = enumerator.MoveNext() ? enumerator.Current : default!; + fifth = enumerator.MoveNext() ? enumerator.Current : default!; + sixth = enumerator.MoveNext() ? enumerator.Current : default!; + seventh = enumerator.MoveNext() ? enumerator.Current : default!; + eighth = enumerator.MoveNext() ? enumerator.Current : default!; + } } } \ No newline at end of file diff --git a/csharp/Foundation.Data.Doublets.Cli/Exceptions.cs b/csharp/Foundation.Data.Doublets.Cli/Exceptions.cs new file mode 100644 index 0000000..2732cfa --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli/Exceptions.cs @@ -0,0 +1,49 @@ +using System; + +namespace Foundation.Data.Doublets.Cli +{ + /// + /// Exception thrown when a link has an invalid format or structure. + /// + public class InvalidLinkFormatException : Exception + { + public InvalidLinkFormatException(string message) : base(message) + { + } + + public InvalidLinkFormatException(string message, Exception innerException) + : base(message, innerException) + { + } + } + + /// + /// Exception thrown when a link structure doesn't match expected patterns. + /// + public class UnexpectedLinkStructureException : InvalidOperationException + { + public UnexpectedLinkStructureException(string message) : base(message) + { + } + + public UnexpectedLinkStructureException(string message, Exception innerException) + : base(message, innerException) + { + } + } + + /// + /// Exception thrown when a query pattern is invalid or cannot be processed. + /// + public class InvalidQueryPatternException : Exception + { + public InvalidQueryPatternException(string message) : base(message) + { + } + + public InvalidQueryPatternException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli/ILinksUnrestricted.cs b/csharp/Foundation.Data.Doublets.Cli/ILinksUnrestricted.cs deleted file mode 100644 index 4638292..0000000 --- a/csharp/Foundation.Data.Doublets.Cli/ILinksUnrestricted.cs +++ /dev/null @@ -1,130 +0,0 @@ -namespace Foundation.Data.Doublets.Cli -{ - // TODO: support ILinksUnrestricted and TConstants - - // An unrestricted version of the ILinks interface, without the IUnsignedNumber constraint - public interface ILinksUnrestricted - { - } - - // An unrestricted version of the ILinks interface, without the IUnsignedNumber or LinksConstants constraints - public interface ILinksUnrestricted - { - #region Constants - - /// - /// Returns the set of constants that is necessary for effective communication with the methods of this interface. - /// Возвращает набор констант, который необходим для эффективной коммуникации с методами этого интерфейса. - /// - /// - /// These constants are not changed since the creation of the links storage access point. - /// Эти константы не меняются с момента создания точки доступа к хранилищу связей. - /// - TConstants Constants { get; } - - #endregion - - #region Read - - /// - /// Counts and returns the total number of links in the storage that meet the specified restriction. - /// Подсчитывает и возвращает общее число связей находящихся в хранилище, соответствующих указанному ограничению. - /// - /// Restriction on the contents of links.Ограничение на содержимое связей. - /// The total number of links in the storage that meet the specified restriction.Общее число связей находящихся в хранилище, соответствующих указанному ограничению. - TLinkAddress Count(IList? restriction); - - /// - /// Passes through all the links matching the pattern, invoking a handler for each matching link. - /// Выполняет проход по всем связям, соответствующим шаблону, вызывая обработчик (handler) для каждой подходящей связи. - /// - /// - /// Restriction on the contents of links. Each constraint can have values: Constants.Null - the 0th link denoting a reference to the void, Any - the absence of a constraint, 1..∞ a specific link index. - /// Ограничение на содержимое связей. Каждое ограничение может иметь значения: Constants.Null - 0-я связь, обозначающая ссылку на пустоту, Any - отсутствие ограничения, 1..∞ конкретный индекс связи. - /// - /// A handler for each matching link.Обработчик для каждой подходящей связи. - /// Constants.Continue, if the pass through the links was not interrupted, and Constants.Break otherwise.Constants.Continue, в случае если проход по связям не был прерван и Constants.Break в обратном случае. - TLinkAddress Each(IList? restriction, Platform.Delegates.ReadHandler? handler); - - #endregion - - #region Write - - /// - /// Creates a link. - /// Создаёт связь. - /// - /// The content of a new link. This argument is optional, if the null passed as value that means no content of a link is set. - /// Содержимое новой связи. Этот аргумент опционален, если null передан в качестве значения это означает, что никакого содержимого для связи не установлено. - /// - /// - /// A function to handle each executed change. This function can use Constants.Continue to continue proccess each change. Constants.Break can be used to stop receiving of executed changes. - /// Функция для обработки каждого выполненного изменения. Эта функция может использовать Constants.Continue чтобы продолжить обрабатывать каждое изменение. Constants.Break может быть использована для остановки получения выполненных изменений. - /// - /// - /// - /// - /// Constants.Continue if all executed changes are handled. - /// Constants.Break if proccessing of handled changes is stoped. - /// - /// - /// Constants.Continue если все выполненные изменения обработаны. - /// Constants.Break если обработака выполненных изменений остановлена. - /// - /// - TLinkAddress Create(IList? substitution, Platform.Delegates.WriteHandler? handler); - - /// - /// Обновляет связь с указанными restriction[Constants.IndexPart] в адресом связи - /// на связь с указанным новым содержимым. - /// - /// - /// Ограничение на содержимое связей. - /// Предполагается, что будет указан индекс связи (в restriction[Constants.IndexPart]) и далее за ним будет следовать содержимое связи. - /// Каждое ограничение может иметь значения: Constants.Null - 0-я связь, обозначающая ссылку на пустоту, - /// Constants.Itself - требование установить ссылку на себя, 1..∞ конкретный индекс другой связи. - /// - /// - /// - /// A function to handle each executed change. This function can use Constants.Continue to continue proccess each change. Constants.Break can be used to stop receiving of executed changes. - /// Функция для обработки каждого выполненного изменения. Эта функция может использовать Constants.Continue чтобы продолжить обрабатывать каждое изменение. Constants.Break может быть использована для остановки получения выполненных изменений. - /// - /// - /// - /// Constants.Continue if all executed changes are handled. - /// Constants.Break if proccessing of handled changes is stoped. - /// - /// - /// Constants.Continue если все выполненные изменения обработаны. - /// Constants.Break если обработака выполненных изменений остановлена. - /// - /// - TLinkAddress Update(IList? restriction, IList? substitution, Platform.Delegates.WriteHandler? handler); - - /// - /// Deletes links that match the specified restriction. - /// Удаляет связи соответствующие указанному ограничению. - /// - /// - /// Restriction on the content of a link. This argument is optional, if the null passed as value that means no restriction on the content of a link are set. - /// Ограничение на содержимое связи. Этот аргумент опционален, если null передан в качестве значения это означает, что никаких ограничений на содержимое связи не установлено. - /// - /// - /// A function to handle each executed change. This function can use Constants.Continue to continue proccess each change. Constants.Break can be used to stop receiving of executed changes. - /// Функция для обработки каждого выполненного изменения. Эта функция может использовать Constants.Continue чтобы продолжить обрабатывать каждое изменение. Constants.Break может быть использована для остановки получения выполненных изменений. - /// - /// - /// - /// Constants.Continue if all executed changes are handled. - /// Constants.Break if proccessing of handled changes is stoped. - /// - /// - /// Constants.Continue если все выполненные изменения обработаны. - /// Constants.Break если обработака выполненных изменений остановлена. - /// - /// - TLinkAddress Delete(IList? restriction, Platform.Delegates.WriteHandler? handler); - - #endregion - } -} diff --git a/csharp/Foundation.Data.Doublets.Cli/MixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli/MixedQueryProcessor.cs index 173cea1..34b3435 100644 --- a/csharp/Foundation.Data.Doublets.Cli/MixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli/MixedQueryProcessor.cs @@ -20,6 +20,9 @@ public class Options public static void ProcessQuery(ILinks links, Options options) { + ArgumentNullException.ThrowIfNull(links); + ArgumentNullException.ThrowIfNull(options); + var query = options.Query; var @null = links.Constants.Null; var any = links.Constants.Any; @@ -316,20 +319,32 @@ static DoubletLink ToDoubletLink(ILinks links, LinoLink linoLink, uint def return new DoubletLink(index, source, target); } - static void TryParseLinkId(string? id, LinksConstants constants, ref uint parsedValue) + static bool TryParseLinkId(string? id, LinksConstants constants, ref uint parsedValue) { if (string.IsNullOrEmpty(id)) { - return; + return false; } if (id == "*") { parsedValue = constants.Any; + return true; + } + else if (id.EndsWith(":")) + { + var trimmed = id.TrimEnd(':'); + if (uint.TryParse(trimmed, out uint linkId)) + { + parsedValue = linkId; + return true; + } } else if (uint.TryParse(id, out uint linkId)) { parsedValue = linkId; + return true; } + return false; } } } \ No newline at end of file diff --git a/csharp/Foundation.Data.Doublets.Cli/NamedLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli/NamedLinksDecorator.cs index 64abe8c..3eed47e 100644 --- a/csharp/Foundation.Data.Doublets.Cli/NamedLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli/NamedLinksDecorator.cs @@ -56,6 +56,11 @@ public NamedLinksDecorator(string databaseFilename, bool tracingEnabled = false) { } + /// + /// Gets the name associated with the specified link address. + /// + /// The link address to get the name for. + /// The name associated with the link, or null if no name is set. public string? GetName(TLinkAddress link) { if (_tracingEnabled) Console.WriteLine($"[Trace] GetName called for link: {link}"); @@ -64,6 +69,12 @@ public NamedLinksDecorator(string databaseFilename, bool tracingEnabled = false) return result; } + /// + /// Sets the name for the specified link address. + /// + /// The link address to name. + /// The name to assign to the link. + /// The link address representing the name assignment. public TLinkAddress SetName(TLinkAddress link, string name) { if (_tracingEnabled) Console.WriteLine($"[Trace] SetName called for link: {link} with name: '{name}'"); @@ -74,6 +85,11 @@ public TLinkAddress SetName(TLinkAddress link, string name) return result; } + /// + /// Gets the link address associated with the specified name. + /// + /// The name to look up. + /// The link address associated with the name, or Null if not found. public TLinkAddress GetByName(string name) { if (_tracingEnabled) Console.WriteLine($"[Trace] GetByName called for name: '{name}'"); @@ -82,6 +98,10 @@ public TLinkAddress GetByName(string name) return result; } + /// + /// Removes the name association for the specified link address. + /// + /// The link address whose name should be removed. public void RemoveName(TLinkAddress link) { if (_tracingEnabled) Console.WriteLine($"[Trace] RemoveName called for link: {link}"); diff --git a/csharp/Foundation.Data.Doublets.Cli/PinnedTypes.cs b/csharp/Foundation.Data.Doublets.Cli/PinnedTypes.cs index 027afac..6397cb6 100644 --- a/csharp/Foundation.Data.Doublets.Cli/PinnedTypes.cs +++ b/csharp/Foundation.Data.Doublets.Cli/PinnedTypes.cs @@ -35,15 +35,18 @@ IEnumerator IEnumerable.GetEnumerator() // Private custom enumerator private class PinnedTypesEnumerator : IEnumerator { + private const int MaxPinnedTypes = 6; // Type, UnicodeSymbolType, UnicodeSequenceType, StringType, EmptyStringType, NameType private readonly ILinks _links; private readonly TLinkAddress _initialSource; private TLinkAddress _currentAddress; + private int _count; public PinnedTypesEnumerator(ILinks links) { _links = links; _initialSource = TLinkAddress.One; _currentAddress = TLinkAddress.One; // Start with the first address + _count = 0; } public TLinkAddress Current { get; private set; } @@ -52,6 +55,12 @@ public PinnedTypesEnumerator(ILinks links) public bool MoveNext() { + // Stop after creating/verifying MaxPinnedTypes + if (_count >= MaxPinnedTypes) + { + return false; + } + if (_links.Exists(_currentAddress)) { var link = new Link(_links.GetLink(_currentAddress)); @@ -64,7 +73,7 @@ public bool MoveNext() else { // Link exists but does not match the expected structure - throw new InvalidOperationException($"Unexpected link found at address {_currentAddress}. Expected: {expectedLink}, Found: {link}."); + throw new UnexpectedLinkStructureException($"Unexpected link found at address {_currentAddress}. Expected: {expectedLink}, Found: {link}."); } } else @@ -75,6 +84,7 @@ public bool MoveNext() // Increment the current address for the next type _currentAddress++; + _count++; return true; } @@ -82,6 +92,7 @@ public bool MoveNext() public void Reset() { _currentAddress = TLinkAddress.One; + _count = 0; } public void Dispose() diff --git a/csharp/Foundation.Data.Doublets.Cli/QueryConstants.cs b/csharp/Foundation.Data.Doublets.Cli/QueryConstants.cs new file mode 100644 index 0000000..745b2fd --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli/QueryConstants.cs @@ -0,0 +1,23 @@ +namespace Foundation.Data.Doublets.Cli +{ + /// + /// Constants used in query processing for pattern matching and link notation. + /// + public static class QueryConstants + { + /// + /// Prefix for variable identifiers (e.g., "$variable"). + /// + public const string VariablePrefix = "$"; + + /// + /// Symbol representing a wildcard match (matches any value). + /// + public const string WildcardSymbol = "*"; + + /// + /// Suffix for explicit link index notation (e.g., "123:"). + /// + public const string IndexSuffix = ":"; + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli/SimpleLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli/SimpleLinksDecorator.cs index 5fe1fd1..fd7a427 100644 --- a/csharp/Foundation.Data.Doublets.Cli/SimpleLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli/SimpleLinksDecorator.cs @@ -40,7 +40,7 @@ public static string MakeNamesDatabaseFilename(string databaseFilename) public SimpleLinksDecorator(ILinks links, string namesDatabaseFilename, bool tracingEnabled = false) : base(links) { _tracingEnabled = tracingEnabled; - if (_tracingEnabled) Console.WriteLine($"[Trace] Constructing NamedLinksDecorator with names DB: {namesDatabaseFilename}"); + if (_tracingEnabled) Console.WriteLine($"[Trace] Constructing SimpleLinksDecorator with names DB: {namesDatabaseFilename}"); var namesConstants = new LinksConstants(enableExternalReferencesSupport: true); var namesMemory = new FileMappedResizableDirectMemory(namesDatabaseFilename, UnitedMemoryLinks.DefaultLinksSizeStep); var namesLinks = new UnitedMemoryLinks(namesMemory, UnitedMemoryLinks.DefaultLinksSizeStep, namesConstants, IndexTreeType.Default); diff --git a/csharp/Foundation.Data.Doublets.Cli/UnicodeStringStorage.cs b/csharp/Foundation.Data.Doublets.Cli/UnicodeStringStorage.cs index ef8fc6f..4b22103 100644 --- a/csharp/Foundation.Data.Doublets.Cli/UnicodeStringStorage.cs +++ b/csharp/Foundation.Data.Doublets.Cli/UnicodeStringStorage.cs @@ -147,7 +147,7 @@ public string GetString(TLinkAddress stringValue) } current = Links.GetTarget(current); } - throw new Exception("The passed link does not contain a string."); + throw new InvalidLinkFormatException("The passed link does not contain a string."); } } } \ No newline at end of file diff --git a/package.json b/package.json index c642c50..76182bf 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "build:web": "vite build --config web/vite.config.js", "dev": "npm run build:wasm && vite --config web/vite.config.js --host 0.0.0.0", "preview": "vite preview --config web/vite.config.js --host 0.0.0.0", - "test": "npm run test:wasm && npm run build", + "test": "npm run test:wasm && npm run test:js && npm run build", + "test:js": "node --test web/test/*.test.mjs", "test:wasm": "wasm-pack test --node", "clean": "rm -rf dist web/pkg pkg pkg-node pkg-bundler target" }, diff --git a/rust/changelog.d/20260509_053805_issue_62_review_coverage.md b/rust/changelog.d/20260509_053805_issue_62_review_coverage.md new file mode 100644 index 0000000..b72a590 --- /dev/null +++ b/rust/changelog.d/20260509_053805_issue_62_review_coverage.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Added issue 62 regression coverage for reversible explicit updates, finite pinned types, unsupported placeholders, and stale named-link mappings. diff --git a/rust/tests/issue62_review_coverage_tests.rs b/rust/tests/issue62_review_coverage_tests.rs new file mode 100644 index 0000000..198c056 --- /dev/null +++ b/rust/tests/issue62_review_coverage_tests.rs @@ -0,0 +1,82 @@ +use anyhow::Result; +use link_cli::{LinkStorage, NamedTypeLinks, PinnedTypes, QueryProcessor}; +use tempfile::NamedTempFile; + +#[test] +fn pinned_types_take_types_is_finite_and_deterministic() -> Result<()> { + let db_file = NamedTempFile::new()?; + let db_path = db_file.path().to_str().unwrap(); + let mut storage = LinkStorage::new(db_path, false)?; + let mut pinned_types = PinnedTypes::new(&mut storage); + + assert_eq!(Vec::::new(), pinned_types.take_types(0)?); + assert_eq!(vec![1, 2, 3, 4, 5, 6], pinned_types.take_types(6)?); + + Ok(()) +} + +#[test] +fn unsupported_any_reference_is_rejected_without_placeholder_creation() -> Result<()> { + let db_file = NamedTempFile::new()?; + let db_path = db_file.path().to_str().unwrap(); + let mut storage = LinkStorage::new(db_path, false)?; + + let error = storage.try_ensure_created(u32::MAX).unwrap_err(); + + assert!(error + .to_string() + .contains("Cannot ensure unsupported link address")); + assert!(storage.all().is_empty()); + + Ok(()) +} + +#[test] +fn explicit_numeric_id_update_can_be_reversed_with_another_update() -> Result<()> { + let db_file = NamedTempFile::new()?; + let db_path = db_file.path().to_str().unwrap(); + let mut storage = LinkStorage::new(db_path, false)?; + let processor = QueryProcessor::new(false).with_auto_create_missing_references(true); + + processor.process_query(&mut storage, "(() ((1: 1 1)))")?; + assert_link(&storage, 1, 1, 1); + + processor.process_query(&mut storage, "(((1: 1 1)) ((1: 2 2)))")?; + assert_link(&storage, 1, 2, 2); + + processor.process_query(&mut storage, "(((1: 2 2)) ((1: 1 1)))")?; + assert_link(&storage, 1, 1, 1); + + Ok(()) +} + +#[test] +fn named_link_create_delete_recreate_clears_stale_name_mapping() -> Result<()> { + let db_file = NamedTempFile::new()?; + let db_path = db_file.path().to_str().unwrap(); + let mut storage = LinkStorage::new(db_path, false)?; + let processor = QueryProcessor::new(false).with_auto_create_missing_references(true); + + processor.process_query(&mut storage, "(() ((child: father mother)))")?; + let first_child = storage.get_by_name("child").expect("child is named"); + + processor.process_query(&mut storage, "((child: father mother)) ()")?; + assert_eq!(None, storage.get_by_name("child")); + assert_eq!(None, storage.get_name(first_child)); + + processor.process_query(&mut storage, "(() ((child: father mother)))")?; + let recreated_child = storage.get_by_name("child").expect("child is recreated"); + assert_eq!( + Some("child"), + storage.get_name(recreated_child).map(String::as_str) + ); + + Ok(()) +} + +fn assert_link(storage: &LinkStorage, index: u32, source: u32, target: u32) { + let link = storage.get(index).expect("link should exist"); + assert_eq!(index, link.index); + assert_eq!(source, link.source); + assert_eq!(target, link.target); +} diff --git a/tests/web.rs b/tests/web.rs index a048410..c2629b5 100644 --- a/tests/web.rs +++ b/tests/web.rs @@ -43,3 +43,51 @@ fn reports_invalid_options() { .unwrap() .contains("Invalid options JSON")); } + +#[wasm_bindgen_test] +fn javascript_wasm_api_round_trips_create_update_delete_and_recreate() { + let mut clink = Clink::new(); + + let created = execute_json(&mut clink, "(() ((1: 1 1)))"); + assert_eq!(created["success"], true); + assert_link(&created, 1, 1, 1); + + let updated = execute_json(&mut clink, "(((1: 1 1)) ((1: 2 2)))"); + assert_eq!(updated["success"], true); + assert_link(&updated, 1, 2, 2); + + let reverted = execute_json(&mut clink, "(((1: 2 2)) ((1: 1 1)))"); + assert_eq!(reverted["success"], true); + assert_link(&reverted, 1, 1, 1); + + let deleted = execute_json(&mut clink, "((1: 1 1)) ()"); + assert_eq!(deleted["success"], true); + assert_link_missing(&deleted, 1); + + let recreated = execute_json(&mut clink, "(() ((1: 1 1)))"); + assert_eq!(recreated["success"], true); + assert_link(&recreated, 1, 1, 1); +} + +fn execute_json(clink: &mut Clink, query: &str) -> Value { + let raw = clink.execute( + query, + r#"{"changes":true,"after":true,"autoCreateMissingReferences":true}"#, + ); + serde_json::from_str(&raw).unwrap() +} + +fn assert_link(parsed: &Value, id: u64, source: u64, target: u64) { + let links = parsed["links"].as_array().unwrap(); + let link = links + .iter() + .find(|link| link["id"] == id) + .expect("link should exist"); + assert_eq!(link["source"], source); + assert_eq!(link["target"], target); +} + +fn assert_link_missing(parsed: &Value, id: u64) { + let links = parsed["links"].as_array().unwrap(); + assert!(!links.iter().any(|link| link["id"] == id)); +} diff --git a/web/src/App.jsx b/web/src/App.jsx index 8457ef0..1933e6f 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -12,6 +12,7 @@ import { SquareTerminal, } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { buildGraph } from './linkGraph.js'; const DOUBLETS_WEB_VERSION = '0.1.2'; @@ -398,57 +399,6 @@ function LinkGraph({ links }) { ); } -function buildGraph(links) { - const ids = new Set(); - for (const link of links) { - ids.add(link.id); - ids.add(link.source); - ids.add(link.target); - } - - const ordered = Array.from(ids) - .filter((id) => id > 0) - .sort((a, b) => a - b); - const names = new Map(links.map((link) => [link.id, link.name]).filter(([, name]) => name)); - const radius = Math.min(150, Math.max(90, ordered.length * 18)); - const center = { x: 380, y: 210 }; - const points = new Map(); - - ordered.forEach((id, index) => { - const angle = ordered.length === 1 ? -Math.PI / 2 : (index / ordered.length) * Math.PI * 2 - Math.PI / 2; - points.set(id, { - id, - x: center.x + Math.cos(angle) * radius, - y: center.y + Math.sin(angle) * radius, - label: names.get(id) || String(id), - named: names.has(id), - }); - }); - - const edges = links.flatMap((link) => [ - { - key: `${link.id}-source`, - from: link.source, - to: link.id, - kind: 'source', - self: link.source === link.id, - }, - { - key: `${link.id}-target`, - from: link.id, - to: link.target, - kind: 'target', - self: link.id === link.target, - }, - ]); - - return { - nodes: ordered.map((id) => points.get(id)), - points, - edges: edges.filter((edge) => points.has(edge.from) && points.has(edge.to)), - }; -} - function toggleOption(setOptions, key) { setOptions((current) => ({ ...current, [key]: !current[key] })); } diff --git a/web/src/linkGraph.js b/web/src/linkGraph.js new file mode 100644 index 0000000..1992274 --- /dev/null +++ b/web/src/linkGraph.js @@ -0,0 +1,50 @@ +export function buildGraph(links) { + const ids = new Set(); + for (const link of links) { + ids.add(link.id); + ids.add(link.source); + ids.add(link.target); + } + + const ordered = Array.from(ids) + .filter((id) => id > 0) + .sort((a, b) => a - b); + const names = new Map(links.map((link) => [link.id, link.name]).filter(([, name]) => name)); + const radius = Math.min(150, Math.max(90, ordered.length * 18)); + const center = { x: 380, y: 210 }; + const points = new Map(); + + ordered.forEach((id, index) => { + const angle = ordered.length === 1 ? -Math.PI / 2 : (index / ordered.length) * Math.PI * 2 - Math.PI / 2; + points.set(id, { + id, + x: center.x + Math.cos(angle) * radius, + y: center.y + Math.sin(angle) * radius, + label: names.get(id) || String(id), + named: names.has(id), + }); + }); + + const edges = links.flatMap((link) => [ + { + key: `${link.id}-source`, + from: link.source, + to: link.id, + kind: 'source', + self: link.source === link.id, + }, + { + key: `${link.id}-target`, + from: link.id, + to: link.target, + kind: 'target', + self: link.id === link.target, + }, + ]); + + return { + nodes: ordered.map((id) => points.get(id)), + points, + edges: edges.filter((edge) => points.has(edge.from) && points.has(edge.to)), + }; +} diff --git a/web/test/linkGraph.test.mjs b/web/test/linkGraph.test.mjs new file mode 100644 index 0000000..e25a945 --- /dev/null +++ b/web/test/linkGraph.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildGraph } from '../src/linkGraph.js'; + +test('buildGraph reflects create, delete, and recreate snapshots without stale nodes', () => { + const created = buildGraph([ + { id: 1, source: 1, target: 1, name: 'Type' }, + { id: 2, source: 1, target: 2, name: 'Child' }, + ]); + + assert.deepEqual(created.nodes.map((node) => node.id), [1, 2]); + assert.equal(created.points.get(1).label, 'Type'); + assert.equal(created.points.get(2).label, 'Child'); + + const deleted = buildGraph([{ id: 1, source: 1, target: 1, name: 'Type' }]); + assert.deepEqual(deleted.nodes.map((node) => node.id), [1]); + assert.equal(deleted.edges.length, 2); + + const recreated = buildGraph([ + { id: 1, source: 1, target: 1, name: 'Type' }, + { id: 2, source: 2, target: 2, name: 'Child' }, + ]); + + assert.deepEqual(recreated.nodes.map((node) => node.id), [1, 2]); + assert.equal(recreated.points.get(2).label, 'Child'); + assert.equal(recreated.edges.filter((edge) => edge.from === 2 || edge.to === 2).length, 2); +});