diff --git a/.gitignore b/.gitignore index 0bb915c..e65ff38 100644 --- a/.gitignore +++ b/.gitignore @@ -354,3 +354,4 @@ MigrationBackup/ .ionide/ /.claude/settings.local.json +/review-feedback.md diff --git a/reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs b/reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs new file mode 100644 index 0000000..19d0727 --- /dev/null +++ b/reqifviewer.Tests/Components/RelationMatrixComponentTestFixture.cs @@ -0,0 +1,209 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.Tests.Components +{ + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Reflection; + using System.Threading; + using System.Threading.Tasks; + + using Bunit; + + using Microsoft.AspNetCore.Components; + using Microsoft.AspNetCore.Components.Web.Virtualization; + using Microsoft.Extensions.DependencyInjection; + + using NUnit.Framework; + + using ReqIFSharp; + using ReqIFSharp.Extensions.Services; + + using reqifviewer.Components; + + using TestContext = Bunit.TestContext; + + /// + /// Tests for : the self-contained matrix view that owns + /// the async build, render caches, scroll-restoration JS interop, and the dismissible + /// large-matrix tip. + /// + [TestFixture] + public class RelationMatrixComponentTestFixture + { + private TestContext context; + private ReqIF reqIf; + + [SetUp] + public async Task SetUp() + { + this.context = new TestContext(); + this.context.JSInterop.Mode = JSRuntimeMode.Loose; + + var reqifPath = Path.Combine(NUnit.Framework.TestContext.CurrentContext.TestDirectory, "TestData", "ProR_Traceability-Template-v1.0.reqif"); + var cts = new CancellationTokenSource(); + + await using var fileStream = new FileStream(reqifPath, FileMode.Open); + var loader = new ReqIFLoaderService(new ReqIFDeserializer()); + await loader.LoadAsync(fileStream, SupportedFileExtensionKind.Reqif, cts.Token); + this.reqIf = loader.ReqIFData.Single(); + } + + [TearDown] + public void TearDown() + { + this.context.Dispose(); + } + + [Test] + public void Verify_that_component_renders_Virtualize_for_a_loaded_relation_set() + { + var relation = this.reqIf.CoreContent.SpecRelations.First(r => r.Source != null && r.Target != null); + + var renderer = this.context.RenderComponent(p => p + .Add(x => x.ReqIf, this.reqIf) + .Add(x => x.RowType, relation.Source.Type) + .Add(x => x.ColumnType, relation.Target.Type) + .Add(x => x.RelationType, relation.Type) + .Add(x => x.ShowOnlyRelated, false)); + + // OnParametersSetAsync runs the build via Task.Run; wait for it to commit + // visible rows before asserting on the rendered Virtualize element. + renderer.WaitForState(() => !renderer.Instance.IsBusy && renderer.Instance.VisibleRows.Count > 0); + renderer.Render(); + + var virtualizeComponents = renderer.FindComponents>(); + Assert.That(virtualizeComponents, Is.Not.Empty, + "Row axis must be rendered through Virtualize for large-matrix performance"); + } + + [Test] + public void Verify_that_component_calls_matrixScroll_attach_when_a_matrix_renders() + { + var relation = this.reqIf.CoreContent.SpecRelations.First(r => r.Source != null && r.Target != null); + + var expectedKey = + $"matrix-scroll:{this.reqIf.TheHeader.Identifier}" + + $":{relation.Source.Type.Identifier}" + + $":{relation.Target.Type.Identifier}" + + $":{relation.Type.Identifier}" + + ":all"; + + var renderer = this.context.RenderComponent(p => p + .Add(x => x.ReqIf, this.reqIf) + .Add(x => x.RowType, relation.Source.Type) + .Add(x => x.ColumnType, relation.Target.Type) + .Add(x => x.RelationType, relation.Type) + .Add(x => x.ShowOnlyRelated, false) + .Add(x => x.ScrollKey, expectedKey)); + + renderer.WaitForState(() => !renderer.Instance.IsBusy && renderer.Instance.VisibleRows.Count > 0); + + // After the matrix commits, OnAfterRenderAsync should have called matrixScroll.attach. + renderer.WaitForState(() => this.context.JSInterop.Invocations.Any(i => i.Identifier == "matrixScroll.attach")); + + var attach = this.context.JSInterop.Invocations + .Where(i => i.Identifier == "matrixScroll.attach") + .Last(); + + Assert.Multiple(() => + { + Assert.That(attach.Arguments[0], Is.Not.EqualTo(".relation-matrix-wrapper"), + "First argument must no longer be the legacy shared class selector"); + Assert.That(attach.Arguments[0], Is.InstanceOf(), + "First argument must be the component instance's wrapper ElementReference"); + Assert.That(attach.Arguments[1], Is.EqualTo(expectedKey), + "Scroll key must come from the ScrollKey parameter"); + Assert.That(attach.Arguments[2], Is.EqualTo(32), + "Third argument is the row-height-in-px used by JS for index <-> scrollTop math"); + Assert.That(attach.Arguments[3], Is.EqualTo(36), + "Fourth argument is the cell-width-in-px used by JS for index <-> scrollLeft math"); + Assert.That(attach.Arguments[4], Is.EqualTo( + renderer.Instance.VisibleRows.Select(o => o.Identifier).ToArray()), + "Fifth argument is the ordered row SpecObject identifiers for index<->id mapping"); + Assert.That(attach.Arguments[5], Is.EqualTo( + renderer.Instance.VisibleColumns.Select(o => o.Identifier).ToArray()), + "Sixth argument is the ordered column SpecObject identifiers for index<->id mapping"); + }); + } + + [Test] + public void Verify_that_component_dismisses_the_large_matrix_hint_after_OnDismiss() + { + // A loaded ReqIF satisfies the component's "matrix is loaded" gating; we don't + // care which selectors — we'll overwrite VisibleRows/Columns via reflection to + // force the hint into the "would render" state regardless of the real fixture size. + var relation = this.reqIf.CoreContent.SpecRelations.First(r => r.Source != null && r.Target != null); + + var renderer = this.context.RenderComponent(p => p + .Add(x => x.ReqIf, this.reqIf) + .Add(x => x.RowType, relation.Source.Type) + .Add(x => x.ColumnType, relation.Target.Type) + .Add(x => x.RelationType, relation.Type) + .Add(x => x.ShowOnlyRelated, false)); + + renderer.WaitForState(() => !renderer.Instance.IsBusy); + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + var componentType = typeof(RelationMatrixComponent); + + // Force a state where the hint *would* render: 80 x 80 = 6_400 visible cells + // (above the 5_000 LargeMatrixCellCount soft cap) with Show-only-related off. + var largeList = (ICollection)Enumerable.Repeat(null, 80).ToList(); + componentType.GetProperty("VisibleRows", flags)!.SetValue(renderer.Instance, largeList); + componentType.GetProperty("VisibleColumns", flags)!.SetValue(renderer.Instance, largeList); + + var showHint = componentType.GetProperty("ShowLargeMatrixHint", flags)!; + Assert.That((bool)showHint.GetValue(renderer.Instance)!, Is.True, + "Pre-condition: with 6_400 cells visible and Show-only-related off, the hint must be shown"); + + var dismiss = componentType.GetMethod("OnDismissLargeMatrixHint", flags)!; + dismiss.Invoke(renderer.Instance, null); + + Assert.That((bool)showHint.GetValue(renderer.Instance)!, Is.False, + "After OnDismissLargeMatrixHint fires, ShowLargeMatrixHint must stay false even though the matrix is still large"); + } + + [Test] + public void Verify_that_component_fits_the_wrapper_to_the_viewport_when_a_matrix_renders() + { + var relation = this.reqIf.CoreContent.SpecRelations.First(r => r.Source != null && r.Target != null); + + var renderer = this.context.RenderComponent(p => p + .Add(x => x.ReqIf, this.reqIf) + .Add(x => x.RowType, relation.Source.Type) + .Add(x => x.ColumnType, relation.Target.Type) + .Add(x => x.RelationType, relation.Type) + .Add(x => x.ShowOnlyRelated, false) + .Add(x => x.ScrollKey, "k")); + + renderer.WaitForState(() => !renderer.Instance.IsBusy && renderer.Instance.VisibleRows.Count > 0); + renderer.WaitForState(() => this.context.JSInterop.Invocations.Any(i => i.Identifier == "matrixScroll.fit")); + + var fit = this.context.JSInterop.Invocations + .Last(i => i.Identifier == "matrixScroll.fit"); + + Assert.That(fit.Arguments[0], Is.InstanceOf(), + "fit must target the component's wrapper ElementReference"); + } + } +} diff --git a/reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs b/reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs new file mode 100644 index 0000000..a94bc1b --- /dev/null +++ b/reqifviewer.Tests/Pages/RelationMatrix/RelationMatrixPageTestFixture.cs @@ -0,0 +1,224 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.Tests.Pages.RelationMatrix +{ + using System.IO; + using System.Linq; + using System.Reflection; + using System.Threading; + using System.Threading.Tasks; + + using Bunit; + + using Microsoft.AspNetCore.Components; + using Microsoft.Extensions.DependencyInjection; + + using Moq; + + using NUnit.Framework; + + using Radzen.Blazor; + + using ReqIFSharp; + using ReqIFSharp.Extensions.Services; + + using reqifviewer.Components; + using reqifviewer.Pages.RelationMatrix; + + using TestContext = Bunit.TestContext; + + /// + /// Suite of tests for the Blazor component. Tests that + /// exercise the matrix itself (Virtualize, scroll-restoration JS interop, large-matrix tip + /// dismissal) live in . + /// + [TestFixture] + public class RelationMatrixPageTestFixture + { + private TestContext context; + private Mock reqIfLoaderService; + private ReqIF reqIf; + + [SetUp] + public async Task SetUp() + { + this.context = new TestContext(); + this.context.JSInterop.Mode = JSRuntimeMode.Loose; + this.reqIfLoaderService = new Mock(); + + var reqifPath = Path.Combine(NUnit.Framework.TestContext.CurrentContext.TestDirectory, "TestData", "ProR_Traceability-Template-v1.0.reqif"); + var cts = new CancellationTokenSource(); + + await using var fileStream = new FileStream(reqifPath, FileMode.Open); + var loader = new ReqIFLoaderService(new ReqIFDeserializer()); + await loader.LoadAsync(fileStream, SupportedFileExtensionKind.Reqif, cts.Token); + this.reqIf = loader.ReqIFData.Single(); + + this.reqIfLoaderService.Setup(x => x.ReqIFData).Returns(loader.ReqIFData); + + this.context.Services.AddSingleton(this.reqIfLoaderService.Object); + } + + [TearDown] + public void TearDown() + { + this.context.Dispose(); + } + + [Test] + public void Verify_that_page_renders_pickers_and_hosts_the_matrix_component() + { + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); + + var dropDowns = renderer.FindComponents>(); + Assert.That(dropDowns, Has.Count.EqualTo(2), "Expected one row and one column SpecObjectType picker"); + + var relationDropDowns = renderer.FindComponents>(); + Assert.That(relationDropDowns, Has.Count.EqualTo(1), "Expected exactly one SpecRelationType picker"); + + var swapButtons = renderer.FindComponents(); + Assert.That(swapButtons, Is.Not.Empty, "Swap-axes button should render"); + + var matrixComponents = renderer.FindComponents(); + Assert.That(matrixComponents, Has.Count.EqualTo(1), + "Page must host exactly one RelationMatrixComponent below the picker card"); + } + + [Test] + public void Verify_that_changing_the_ReqIF_identifier_resets_the_picker_selectors() + { + // Blazor reuses the page instance across /reqif/A/... -> /reqif/B/...; the + // selectors must follow the new document, not keep A's type instances + // (the builder compares SpecObject.Type by reference). + var reqIfA = this.reqIf; + var reqIfB = new ReqIFDeserializer().Deserialize( + Path.Combine(NUnit.Framework.TestContext.CurrentContext.TestDirectory, "TestData", "ProR_Traceability-Template-v1.0.reqif")).Single(); + reqIfB.TheHeader.Identifier = "reqif-switch-B"; + + this.reqIfLoaderService.Setup(x => x.ReqIFData).Returns(new[] { reqIfA, reqIfB }); + + var aObjectTypes = reqIfA.CoreContent.SpecTypes.OfType().ToList(); + var bObjectTypes = reqIfB.CoreContent.SpecTypes.OfType().ToList(); + var bRelationTypes = reqIfB.CoreContent.SpecTypes.OfType().ToList(); + + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, reqIfA.TheHeader.Identifier)); + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var pageType = typeof(RelationMatrixPage); + var rowProp = pageType.GetProperty("RowType", flags)!; + var colProp = pageType.GetProperty("ColumnType", flags)!; + var relProp = pageType.GetProperty("RelationType", flags)!; + + Assert.That(rowProp.GetValue(renderer.Instance), Is.SameAs(aObjectTypes.First()), + "Pre-condition: page initially selects the first SpecObjectType of ReqIF A"); + + renderer.SetParametersAndRender(p => p.Add(x => x.Identifier, reqIfB.TheHeader.Identifier)); + + Assert.Multiple(() => + { + Assert.That(bObjectTypes, Has.Member(rowProp.GetValue(renderer.Instance)), + "RowType must be reset to an instance from ReqIF B after the identifier changed"); + Assert.That(bObjectTypes, Has.Member(colProp.GetValue(renderer.Instance)), + "ColumnType must be reset to an instance from ReqIF B"); + Assert.That(bRelationTypes, Has.Member(relProp.GetValue(renderer.Instance)), + "RelationType must be reset to an instance from ReqIF B"); + Assert.That(aObjectTypes, Has.No.Member(rowProp.GetValue(renderer.Instance)), + "RowType must no longer reference a type instance from the previous ReqIF A"); + }); + } + + [Test] + public void Verify_that_page_shows_a_friendly_message_when_the_ReqIF_identifier_is_unknown() + { + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, "no-such-id")); + + Assert.That(renderer.Markup, Does.Contain("No ReqIF with identifier")); + } + + [Test] + public void Verify_that_query_string_seeds_picker_state_on_initial_render() + { + var content = this.reqIf.CoreContent; + var relation = content.SpecRelations.First(r => r.Source != null && r.Target != null); + + var nav = this.context.Services.GetRequiredService(); + nav.NavigateTo( + $"/reqif/{this.reqIf.TheHeader.Identifier}/relationmatrix" + + $"?row={relation.Source.Type.Identifier}" + + $"&col={relation.Target.Type.Identifier}" + + $"&rel={relation.Type.Identifier}" + + "&related=false"); + + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var pageType = typeof(RelationMatrixPage); + + Assert.Multiple(() => + { + Assert.That(pageType.GetProperty("RowType", flags)!.GetValue(renderer.Instance), + Is.SameAs(relation.Source.Type), "row query parameter must seed RowType"); + Assert.That(pageType.GetProperty("ColumnType", flags)!.GetValue(renderer.Instance), + Is.SameAs(relation.Target.Type), "col query parameter must seed ColumnType"); + Assert.That(pageType.GetProperty("RelationType", flags)!.GetValue(renderer.Instance), + Is.SameAs(relation.Type), "rel query parameter must seed RelationType"); + Assert.That(pageType.GetProperty("ShowOnlyRelated", flags)!.GetValue(renderer.Instance), + Is.EqualTo(false), "related=false in the URL must turn the ShowOnlyRelated filter off"); + }); + } + + [Test] + public async Task Verify_that_changing_a_picker_pushes_query_string_to_the_URL() + { + var renderer = this.context.RenderComponent(p => + p.Add(x => x.Identifier, this.reqIf.TheHeader.Identifier)); + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var pageType = typeof(RelationMatrixPage); + + var onAxisChanged = pageType.GetMethod("OnAxisChanged", flags)!; + await renderer.InvokeAsync(() => onAxisChanged.Invoke(renderer.Instance, null)); + + var nav = this.context.Services.GetRequiredService(); + var rowType = (SpecObjectType)pageType.GetProperty("RowType", flags)!.GetValue(renderer.Instance)!; + var colType = (SpecObjectType)pageType.GetProperty("ColumnType", flags)!.GetValue(renderer.Instance)!; + var relType = (SpecRelationType)pageType.GetProperty("RelationType", flags)!.GetValue(renderer.Instance)!; + + Assert.Multiple(() => + { + Assert.That(nav.Uri, Does.Contain($"/reqif/{this.reqIf.TheHeader.Identifier}/relationmatrix?"), + "URL must point back at the matrix page route"); + Assert.That(nav.Uri, Does.Contain($"row={rowType.Identifier}"), + "Row type identifier must be encoded in the URL"); + Assert.That(nav.Uri, Does.Contain($"col={colType.Identifier}"), + "Column type identifier must be encoded in the URL"); + Assert.That(nav.Uri, Does.Contain($"rel={relType.Identifier}"), + "Relation type identifier must be encoded in the URL"); + Assert.That(nav.Uri, Does.Contain("related="), + "ShowOnlyRelated flag must always be encoded after a picker has been touched"); + }); + } + } +} diff --git a/reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs b/reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs new file mode 100644 index 0000000..0615bfb --- /dev/null +++ b/reqifviewer.Tests/ReqIFExtensions/RelationMatrixExtensionsTestFixture.cs @@ -0,0 +1,225 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.Tests.ReqIFExtensions +{ + using System; + using System.IO; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + + using NUnit.Framework; + + using ReqIFSharp; + using ReqIFSharp.Extensions.Services; + + using ReqifViewer.ReqIFExtensions; + + /// + /// Suite of tests for . + /// + [TestFixture] + public class RelationMatrixExtensionsTestFixture + { + private ReqIF reqIf; + + [SetUp] + public async Task SetUp() + { + var reqIfDeserializer = new ReqIFDeserializer(); + var cts = new CancellationTokenSource(); + var reqifPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", "ProR_Traceability-Template-v1.0.reqif"); + + await using var fileStream = new FileStream(reqifPath, FileMode.Open); + var reqIfLoaderService = new ReqIFLoaderService(reqIfDeserializer); + await reqIfLoaderService.LoadAsync(fileStream, SupportedFileExtensionKind.Reqif, cts.Token); + + this.reqIf = reqIfLoaderService.ReqIFData.Single(); + } + + [Test] + public void Verify_that_BuildRelationMatrix_returns_empty_when_inputs_are_null() + { + var matrix = this.reqIf.CoreContent.BuildRelationMatrix(null, null, null); + + Assert.Multiple(() => + { + Assert.That(matrix.Rows, Is.Empty); + Assert.That(matrix.Columns, Is.Empty); + Assert.That(matrix.Cells, Is.Empty); + }); + } + + [Test] + public void Verify_that_BuildRelationMatrix_filters_rows_and_columns_by_type() + { + var content = this.reqIf.CoreContent; + var rowType = content.SpecTypes.OfType().First(); + var columnType = content.SpecTypes.OfType().First(); + var relationType = content.SpecTypes.OfType().FirstOrDefault(); + + var matrix = content.BuildRelationMatrix(rowType, columnType, relationType); + + var expectedRowCount = content.SpecObjects.Count(o => o.Type == rowType); + + Assert.Multiple(() => + { + Assert.That(matrix.Rows, Has.Count.EqualTo(expectedRowCount)); + Assert.That(matrix.Columns, Has.Count.EqualTo(expectedRowCount)); + Assert.That(matrix.Rows, Is.All.Matches(o => o.Type == rowType)); + }); + } + + [Test] + public void Verify_that_BuildRelationMatrix_marks_Forward_for_existing_relation_and_Backward_when_axes_are_swapped() + { + var content = this.reqIf.CoreContent; + + var sampleRelation = content.SpecRelations.FirstOrDefault(r => r.Source != null && r.Target != null); + Assume.That(sampleRelation, Is.Not.Null, "Sample ReqIF must contain at least one fully-formed SpecRelation"); + + var rowType = sampleRelation.Source.Type; + var columnType = sampleRelation.Target.Type; + var relationType = sampleRelation.Type; + + var matrix = content.BuildRelationMatrix(rowType, columnType, relationType); + + Assert.That( + matrix.Cells.TryGetValue((sampleRelation.Source.Identifier, sampleRelation.Target.Identifier), out var cell), + Is.True, + "Expected a cell for the sample relation's (source, target) pair"); + Assert.That(cell.Direction, Is.EqualTo(RelationDirection.Forward)); + Assert.That(cell.Forward, Is.SameAs(sampleRelation)); + + var swapped = content.BuildRelationMatrix(columnType, rowType, relationType); + + Assert.That( + swapped.Cells.TryGetValue((sampleRelation.Target.Identifier, sampleRelation.Source.Identifier), out var swappedCell), + Is.True, + "After swapping axes, the same relation must populate the (target, source) cell"); + Assert.That(swappedCell.Direction, Is.EqualTo(RelationDirection.Backward)); + Assert.That(swappedCell.Backward, Is.SameAs(sampleRelation)); + } + + [Test] + public void Verify_that_ShowOnlyRelated_filter_keeps_exactly_the_rows_and_columns_present_in_cells() + { + var content = this.reqIf.CoreContent; + + var sampleRelation = content.SpecRelations.FirstOrDefault(r => r.Source != null && r.Target != null); + Assume.That(sampleRelation, Is.Not.Null); + + var rowType = sampleRelation.Source.Type; + var columnType = sampleRelation.Target.Type; + var relationType = sampleRelation.Type; + + var matrix = content.BuildRelationMatrix(rowType, columnType, relationType); + + // Mirror the page's "Show only related" filter exactly. + var connectedRowIds = new System.Collections.Generic.HashSet(matrix.Cells.Keys.Select(k => k.rowId)); + var connectedColIds = new System.Collections.Generic.HashSet(matrix.Cells.Keys.Select(k => k.columnId)); + var visibleRows = matrix.Rows.Where(r => connectedRowIds.Contains(r.Identifier)).ToList(); + var visibleColumns = matrix.Columns.Where(c => connectedColIds.Contains(c.Identifier)).ToList(); + + Assert.Multiple(() => + { + Assert.That(visibleRows.Count, Is.EqualTo(connectedRowIds.Count), + "Number of visible rows must equal number of distinct row identifiers across all cells"); + Assert.That(visibleColumns.Count, Is.EqualTo(connectedColIds.Count), + "Number of visible columns must equal number of distinct column identifiers across all cells"); + + foreach (var row in visibleRows) + { + Assert.That( + matrix.Cells.Keys.Any(k => k.rowId == row.Identifier), + Is.True, + $"Visible row {row.Identifier} must appear in at least one populated cell"); + } + + foreach (var column in visibleColumns) + { + Assert.That( + matrix.Cells.Keys.Any(k => k.columnId == column.Identifier), + Is.True, + $"Visible column {column.Identifier} must appear in at least one populated cell"); + } + + // No row that is *not* connected sneaks through the filter. + Assert.That(visibleRows.All(r => connectedRowIds.Contains(r.Identifier)), Is.True); + Assert.That(visibleColumns.All(c => connectedColIds.Contains(c.Identifier)), Is.True); + + // Sanity bound: visible row count cannot exceed the count of distinct sources/targets that fall on the row axis. + var sourcesOfRowType = content.SpecRelations + .Where(r => r.Type == relationType && r.Source != null && r.Source.Type == rowType) + .Select(r => r.Source.Identifier); + var targetsOfRowType = content.SpecRelations + .Where(r => r.Type == relationType && r.Target != null && r.Target.Type == rowType) + .Select(r => r.Target.Identifier); + var expectedRowCap = new System.Collections.Generic.HashSet(sourcesOfRowType.Concat(targetsOfRowType)).Count; + Assert.That(visibleRows.Count, Is.LessThanOrEqualTo(expectedRowCap), + "Filter must not produce more visible rows than there are distinct row-typed SpecObjects participating in any relation of the chosen type"); + }); + } + + [Test] + public void Verify_that_BuildRelationMatrix_throws_when_cancellation_is_requested() + { + var content = this.reqIf.CoreContent; + var rowType = content.SpecTypes.OfType().First(); + var columnType = rowType; + var relationType = content.SpecTypes.OfType().FirstOrDefault(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.That( + () => content.BuildRelationMatrix(rowType, columnType, relationType, cts.Token), + Throws.InstanceOf()); + } + + [Test] + public void Verify_that_BuildRelationMatrix_only_includes_relations_of_the_requested_type() + { + var content = this.reqIf.CoreContent; + var relationTypes = content.SpecTypes.OfType().ToList(); + Assume.That(relationTypes, Is.Not.Empty); + + var pickedType = relationTypes.First(); + var rowType = content.SpecTypes.OfType().First(); + var columnType = rowType; + + var matrix = content.BuildRelationMatrix(rowType, columnType, pickedType); + + foreach (var cell in matrix.Cells.Values) + { + if (cell.Forward != null) + { + Assert.That(cell.Forward.Type, Is.SameAs(pickedType)); + } + + if (cell.Backward != null) + { + Assert.That(cell.Backward.Type, Is.SameAs(pickedType)); + } + } + } + } +} diff --git a/reqifviewer/Components/RelationMatrixComponent.razor b/reqifviewer/Components/RelationMatrixComponent.razor new file mode 100644 index 0000000..1a8475d --- /dev/null +++ b/reqifviewer/Components/RelationMatrixComponent.razor @@ -0,0 +1,88 @@ +@*------------------------------------------------------------------------------ + Copyright 2021-2026 Starion Group S.A. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +------------------------------------------------------------------------------*@ + +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using ReqIFSharp + +@if (this.IsBusy) +{ +
+ + +
+} + +@if (this.matrix == null || this.RowType == null || this.ColumnType == null || this.RelationType == null) +{ +

Pick a row Spec Object Type, a column Spec Object Type and a Spec Relation Type to view the matrix.

+} +else if (this.VisibleRows.Count == 0 || this.VisibleColumns.Count == 0) +{ +

No SpecObjects match the current selection@(this.ShowOnlyRelated ? " with at least one relation" : "").

+} +else +{ + @if (this.ShowLargeMatrixHint) + { + + Rendering @(this.VisibleRows.Count) × @(this.VisibleColumns.Count) cells. Tip: enable + Show only related to focus on the cells that actually carry a relation. + + } + + var gridTemplate = $"var(--row-header-width) repeat({this.VisibleColumns.Count}, var(--cell-width))"; +
+
+
+ @foreach (var column in this.VisibleColumns) + { + + } +
+ +
+ + @foreach (var column in this.VisibleColumns) + { + if (this.RenderedCells.TryGetValue((row.Identifier, column.Identifier), out var rendered)) + { +
+ @if (rendered.TargetUrl != null) + { + @rendered.Glyph + } + else + { + @rendered.Glyph + } +
+ } + else + { +
+ } + } +
+
+
+} diff --git a/reqifviewer/Components/RelationMatrixComponent.razor.cs b/reqifviewer/Components/RelationMatrixComponent.razor.cs new file mode 100644 index 0000000..ec5674c --- /dev/null +++ b/reqifviewer/Components/RelationMatrixComponent.razor.cs @@ -0,0 +1,457 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace reqifviewer.Components +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + + using Microsoft.AspNetCore.Components; + using Microsoft.JSInterop; + + using ReqIFSharp; + + using ReqifViewer.ReqIFExtensions; + + using Serilog; + + /// + /// Renders a SpecObject × SpecObject relation matrix for a given ReqIF + selectors. Owns the + /// async build (run on the threadpool with a ), the busy + /// indicator, the render caches, the scroll-restoration JS interop, and the dismissible + /// large-matrix tip. The hosting page just supplies parameters and re-renders on changes. + /// + public sealed partial class RelationMatrixComponent : ComponentBase, IDisposable + { + /// Soft cap for the cell count above which the user is nudged toward "Show only related". + private const int LargeMatrixCellCount = 5_000; + + /// Pixel height of a single matrix row. Must stay in sync with the Virtualize ItemSize attribute and the .matrix-row height in scoped CSS — used to translate between scrollTop and row index. + private const int RowHeightPx = 32; + + /// Pixel width of a single matrix data cell. Must stay in sync with --cell-width in scoped CSS — used to translate between scrollLeft and column index. + private const int CellWidthPx = 36; + + /// How often the per-axis/label/cell loops in the builder check the cancellation token. Power-of-two so the check is a bitmask, not a modulo. + private const int BuilderCancellationCheckEvery = 256; + + /// The scrollable matrix wrapper element, handed to the matrixScroll JS module so attach/detach are scoped to this component instance rather than a shared selector. + private ElementReference wrapperRef; + + /// The JS runtime used to drive the matrixScroll module (viewport fit + scroll-anchor restore). + [Inject] + public IJSRuntime JSRuntime { get; set; } + + /// The loaded ReqIF whose the matrix is built from. + [Parameter] + public ReqIF ReqIf { get; set; } + + /// The whose s form the matrix rows. + [Parameter] + public SpecObjectType RowType { get; set; } + + /// The whose s form the matrix columns. + [Parameter] + public SpecObjectType ColumnType { get; set; } + + /// The whose relations populate the matrix cells. + [Parameter] + public SpecRelationType RelationType { get; set; } + + /// When true, rows/columns with no relation in the current selection are hidden. + [Parameter] + public bool ShowOnlyRelated { get; set; } + + /// Per-picker-view identity supplied by the host page; used to wire the scroll/fit JS interop exactly once per distinct selection. + [Parameter] + public string ScrollKey { get; set; } + + /// True while a build is running on the threadpool; drives the progress bar + Cancel button. + public bool IsBusy { get; private set; } + + /// The most recently committed build output, or null before the first successful build. + private RelationMatrixData matrix; + + /// The row s actually rendered (after the show-only-related filter), in display order. + public ICollection VisibleRows { get; private set; } = Array.Empty(); + + /// The column s actually rendered (after the show-only-related filter), in display order. + public ICollection VisibleColumns { get; private set; } = Array.Empty(); + + /// Cache of row → display label. + public Dictionary RowLabels { get; private set; } = new(); + + /// Cache of column → display label. + public Dictionary ColumnLabels { get; private set; } = new(); + + /// Cache of row → drill-down URL. + public Dictionary RowUrls { get; private set; } = new(); + + /// Cache of column → drill-down URL. + public Dictionary ColumnUrls { get; private set; } = new(); + + /// Cache of (rowId, columnId) → the pre-rendered cell bundle; absence means an empty cell. + public Dictionary<(string rowId, string columnId), RenderedCell> RenderedCells { get; private set; } = new(); + + /// Set to true once the user clicks the X on the large-matrix tip. Per-mount only — resets when the component is remounted. + private bool isLargeMatrixHintDismissed; + + /// Cancellation source for the in-flight build; its reference identity is the build "generation" used to gate commits. + private CancellationTokenSource cts; + + /// The the matrixScroll JS module was last wired for. Gates the (potentially large) identifier-array interop to once per matrix view rather than once per render. + private string attachedScrollKey; + + /// True when the matrix is large enough to nudge the user toward "Show only related" and the tip has not been dismissed. + public bool ShowLargeMatrixHint => !this.ShowOnlyRelated + && !this.isLargeMatrixHintDismissed + && (long)this.VisibleRows.Count * this.VisibleColumns.Count > LargeMatrixCellCount; + + /// + /// On any parameter change: snapshots the inputs, cancels any in-flight build, runs a + /// fresh build on the threadpool, and commits it only if this run still owns the current + /// generation (so a superseded build never clobbers newer state or the busy indicator). + /// + protected override async Task OnParametersSetAsync() + { + // Snapshot inputs — the build runs on the threadpool and must not race a fresh + // parameter set arriving while it is mid-flight. + var reqIf = this.ReqIf; + var rowType = this.RowType; + var columnType = this.ColumnType; + var relationType = this.RelationType; + var showOnlyRelated = this.ShowOnlyRelated; + + // Cancel any in-flight build and start a new generation we own a handle to. + if (this.cts != null) + { + await this.cts.CancelAsync(); + this.cts.Dispose(); + } + + var operationCts = new CancellationTokenSource(); + this.cts = operationCts; + var ct = operationCts.Token; + + this.IsBusy = true; + await this.InvokeAsync(this.StateHasChanged); + + try + { + await Task.Yield(); + var result = await Task.Run( + () => BuildResult(reqIf, rowType, columnType, relationType, showOnlyRelated, ct), + ct); + + // Commit only if we still own the current generation — a newer + // OnParametersSetAsync run will have replaced this.cts. + if (ReferenceEquals(this.cts, operationCts)) + { + this.ApplyResult(result); + } + } + catch (OperationCanceledException e) + { + Log.ForContext().Information(e,"Matrix build cancelled (parameters changed or user clicked Cancel)"); + } + catch (Exception e) + { + Log.ForContext().Error(e, "Matrix build failed"); + } + finally + { + // Only the owning generation clears IsBusy; a stale finally must not hide + // the progress bar while a newer build is still running. + if (ReferenceEquals(this.cts, operationCts)) + { + this.IsBusy = false; + await this.InvokeAsync(this.StateHasChanged); + } + } + } + + /// + /// Once a matrix has rendered for a new , wires the matrixScroll + /// JS module: fits the wrapper to the viewport, then attaches scroll-anchor restoration + /// (passing the ordered identifier arrays). Runs at most once per distinct view. + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (this.matrix != null + && this.VisibleRows.Count > 0 + && !string.IsNullOrEmpty(this.ScrollKey) + && this.ScrollKey != this.attachedScrollKey) + { + var rowIds = this.VisibleRows.Select(o => o.Identifier).ToArray(); + var colIds = this.VisibleColumns.Select(o => o.Identifier).ToArray(); + + try + { + // Fit before attach so the wrapper has its final clientHeight + // when the scroll-restore math runs. + await this.JSRuntime.InvokeVoidAsync("matrixScroll.fit", this.wrapperRef); + + await this.JSRuntime.InvokeVoidAsync( + "matrixScroll.attach", + this.wrapperRef, + this.ScrollKey, + RowHeightPx, + CellWidthPx, + rowIds, + colIds); + + // Only mark the key wired once both interop calls succeeded, so a + // failed setup is retried on the next render rather than suppressed. + this.attachedScrollKey = this.ScrollKey; + } + catch (Exception e) + { + Log.ForContext().Warning(e, "Failed to wire matrix scroll restoration"); + } + } + } + + /// Cancels the in-flight build in response to the user clicking the Cancel button. + private void OnCancel() + { + Log.ForContext().Information("Cancel clicked"); + this.cts?.Cancel(); + } + + /// Permanently hides the large-matrix tip for this component mount. + private void OnDismissLargeMatrixHint() + { + this.isLargeMatrixHintDismissed = true; + } + + /// + /// Pure, threadpool-safe build. Produces the matrix and every per-cell cache from the + /// snapshotted inputs only — reads no this.* — so the renderer can commit the + /// result back onto the component only when this operation still owns the generation. + /// Observes in every loop that grows with the ReqIF. + /// + private static MatrixBuildResult BuildResult(ReqIF reqIf, SpecObjectType rowType, SpecObjectType columnType, SpecRelationType relationType, bool showOnlyRelated, CancellationToken ct) + { + if (reqIf == null || rowType == null || columnType == null || relationType == null) + { + return MatrixBuildResult.Empty; + } + + var matrix = reqIf.CoreContent.BuildRelationMatrix(rowType, columnType, relationType, ct); + ct.ThrowIfCancellationRequested(); + + var visibleRows = FilterVisible(matrix.Rows, matrix.Cells.Keys, k => k.rowId, showOnlyRelated, matrix.Cells.Count, ct); + var visibleColumns = FilterVisible(matrix.Columns, matrix.Cells.Keys, k => k.columnId, showOnlyRelated, matrix.Cells.Count, ct); + + var (rowLabels, rowUrls) = BuildLabelsAndUrls(visibleRows, ct); + var (columnLabels, columnUrls) = BuildLabelsAndUrls(visibleColumns, ct); + + var renderedCells = BuildRenderedCells(matrix.Cells, rowLabels, columnLabels, relationType, ct); + + return new MatrixBuildResult(matrix, visibleRows, visibleColumns, rowLabels, columnLabels, rowUrls, columnUrls, renderedCells); + } + + /// + /// Filters the matrix axis to the visible s, honouring the + /// "show only related" toggle, checking periodically. + /// + private static ICollection FilterVisible(IReadOnlyList all, IEnumerable<(string rowId, string columnId)> cellKeys, Func<(string rowId, string columnId), string> idSelector, bool showOnlyRelated, int cellCount, CancellationToken ct) + { + if (showOnlyRelated && cellCount == 0) + { + return Array.Empty(); + } + + HashSet connected = null; + if (showOnlyRelated) + { + connected = new HashSet(); + var keyIndex = 0; + foreach (var key in cellKeys) + { + if ((++keyIndex & (BuilderCancellationCheckEvery - 1)) == 0) + { + ct.ThrowIfCancellationRequested(); + } + + connected.Add(idSelector(key)); + } + } + + var result = new List(all.Count); + var i = 0; + foreach (var o in all) + { + if ((++i & (BuilderCancellationCheckEvery - 1)) == 0) + { + ct.ThrowIfCancellationRequested(); + } + + if (connected == null || connected.Contains(o.Identifier)) + { + result.Add(o); + } + } + + return result; + } + + /// + /// Builds the display-name and drill-down URL caches for an axis, checking + /// periodically. + /// + private static (Dictionary labels, Dictionary urls) BuildLabelsAndUrls(ICollection objects, CancellationToken ct) + { + var labels = new Dictionary(objects.Count); + var urls = new Dictionary(objects.Count); + var i = 0; + foreach (var o in objects) + { + if ((++i & (BuilderCancellationCheckEvery - 1)) == 0) + { + ct.ThrowIfCancellationRequested(); + } + + labels[o.Identifier] = ExtractDisplayNameOf(o); + urls[o.Identifier] = o.CreateUrl(); + } + + return (labels, urls); + } + + /// + /// Builds the per-cell render bundles (glyph, css class, tooltip, drill URL), checking + /// periodically. + /// + private static Dictionary<(string, string), RenderedCell> BuildRenderedCells(IReadOnlyDictionary<(string rowId, string columnId), MatrixCell> cells, Dictionary rowLabels, Dictionary columnLabels, SpecRelationType relationType, CancellationToken ct) + { + var rendered = new Dictionary<(string, string), RenderedCell>(cells.Count); + var relName = relationType.LongName ?? relationType.Identifier; + var i = 0; + foreach (var ((rowId, colId), cell) in cells) + { + if ((++i & (BuilderCancellationCheckEvery - 1)) == 0) + { + ct.ThrowIfCancellationRequested(); + } + + if (!rowLabels.TryGetValue(rowId, out var rowLabel) + || !columnLabels.TryGetValue(colId, out var colLabel)) + { + continue; + } + + var (glyph, css, tooltip) = cell.Direction switch + { + RelationDirection.Forward => ("→", "matrix-cell forward", $"{rowLabel} —{relName}→ {colLabel}"), + RelationDirection.Backward => ("←", "matrix-cell backward", $"{rowLabel} ←{relName}— {colLabel}"), + RelationDirection.Both => ("↔", "matrix-cell both", $"{rowLabel} ↔{relName}↔ {colLabel}"), + _ => (string.Empty, "matrix-cell empty", string.Empty) + }; + + var target = (cell.Forward ?? cell.Backward)?.CreateUrl(); + + rendered[(rowId, colId)] = new RenderedCell(glyph, css, tooltip, target); + } + + return rendered; + } + + /// + /// Copies a completed onto the component. Called only + /// when the producing operation still owns the current generation, so a cancelled or + /// superseded build never mutates component state. + /// + private void ApplyResult(MatrixBuildResult result) + { + this.matrix = result.Matrix; + this.VisibleRows = result.VisibleRows; + this.VisibleColumns = result.VisibleColumns; + this.RowLabels = result.RowLabels; + this.ColumnLabels = result.ColumnLabels; + this.RowUrls = result.RowUrls; + this.ColumnUrls = result.ColumnUrls; + this.RenderedCells = result.RenderedCells; + } + + /// + /// Cancels any in-flight build and tears down the matrixScroll JS wiring (scroll listener + /// + viewport-fit observers) for this component's wrapper element. + /// + public void Dispose() + { + this.cts?.Cancel(); + this.cts?.Dispose(); + this.cts = null; + + try + { + _ = this.JSRuntime?.InvokeVoidAsync("matrixScroll.detach", this.wrapperRef).AsTask(); + _ = this.JSRuntime?.InvokeVoidAsync("matrixScroll.unfit", this.wrapperRef).AsTask(); + } + catch + { + // circuit may be gone; nothing to do + } + } + + /// Resolves a 's display label, falling back to its identifier. + private static string ExtractDisplayNameOf(SpecObject specObject) + { + return specObject.ExtractDisplayName()?.ToString() ?? specObject.Identifier; + } + + /// + /// Per-cell rendering bundle: glyph (→ ← ↔), css class, tooltip text, and optional drill URL. + /// Built once in ; the template just emits these. + /// + public sealed record RenderedCell(string Glyph, string CssClass, string Tooltip, string TargetUrl); + + /// + /// Immutable output of . Produced entirely from snapshotted + /// inputs so the renderer can commit it back onto the component (via + /// ) only when the build still owns the current generation. + /// + private sealed record MatrixBuildResult( + RelationMatrixData Matrix, + ICollection VisibleRows, + ICollection VisibleColumns, + Dictionary RowLabels, + Dictionary ColumnLabels, + Dictionary RowUrls, + Dictionary ColumnUrls, + Dictionary<(string rowId, string columnId), RenderedCell> RenderedCells) + { + /// The committed-empty result used when inputs are incomplete (no rows/columns/cells). + public static MatrixBuildResult Empty { get; } = new( + null, + Array.Empty(), + Array.Empty(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary<(string, string), RenderedCell>()); + } + } +} diff --git a/reqifviewer/Components/RelationMatrixComponent.razor.css b/reqifviewer/Components/RelationMatrixComponent.razor.css new file mode 100644 index 0000000..09d4337 --- /dev/null +++ b/reqifviewer/Components/RelationMatrixComponent.razor.css @@ -0,0 +1,108 @@ +.matrix-busy-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.matrix-busy-row > :first-child { + flex: 1; +} + +.relation-matrix-wrapper { + overflow: auto; + /* Precise height is set at runtime by matrixScroll.fit (component-local JS, + measured against the viewport & footer). This calc is only a pre-JS + fallback so the grid is roughly sized before the first frame. */ + height: calc(100dvh - 220px); + border: 1px solid var(--rz-border-color, #dee2e6); + border-radius: var(--rz-border-radius, 4px); + background: var(--rz-base-50, #fff); + font-size: 0.85rem; + --row-header-width: 320px; + --cell-width: 36px; + --row-height: 32px; +} + +.matrix-header-row, +.matrix-row { + display: grid; +} + +.matrix-header-row { + position: sticky; + top: 0; + z-index: 2; + height: var(--row-height); +} + +.matrix-row { + height: var(--row-height); +} + +.col-header, +.row-header, +.corner, +.matrix-cell { + border-right: 1px solid var(--rz-border-color, #dee2e6); + border-bottom: 1px solid var(--rz-border-color, #dee2e6); + background: var(--rz-base-50, #fff); + box-sizing: border-box; + height: var(--row-height); + overflow: hidden; +} + +.col-header, +.row-header { + background: var(--rz-base-100, #f6f8fa); + font-weight: 600; + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; + padding: 4px 8px; + display: flex; + align-items: center; +} + +.row-header { + position: sticky; + left: 0; + z-index: 1; +} + +.corner { + position: sticky; + top: 0; + left: 0; + z-index: 3; + background: var(--rz-base-200, #e9ecef); +} + +.matrix-cell { + text-align: center; + font-size: 1.1rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + content-visibility: auto; + contain-intrinsic-size: var(--row-height) var(--cell-width); +} + +.matrix-cell.forward { color: #1b6ec2; } +.matrix-cell.backward { color: #b35900; } +.matrix-cell.both { color: #2e7d32; font-weight: 700; } +.matrix-cell.empty { color: var(--rz-base-400, #adb5bd); } + +.matrix-header-row a, +.matrix-row a { + color: inherit; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; +} + +.matrix-header-row a:hover, +.matrix-row a:hover { + text-decoration: underline; +} diff --git a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor new file mode 100644 index 0000000..3fd5dc9 --- /dev/null +++ b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor @@ -0,0 +1,69 @@ +@page "/reqif/{Identifier}/relationmatrix" + + +@using ReqIFSharp +@using reqifviewer.Components + +@if (this.IsLoading) +{ + +} +else if (this.reqIf == null) +{ + +

No ReqIF with identifier @this.Identifier is loaded.

+} +else +{ + + + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +} diff --git a/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs new file mode 100644 index 0000000..2350f8b --- /dev/null +++ b/reqifviewer/Pages/RelationMatrix/RelationMatrixPage.razor.cs @@ -0,0 +1,282 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace reqifviewer.Pages.RelationMatrix +{ + using System; + using System.Collections.Generic; + using System.Linq; + + using Microsoft.AspNetCore.Components; + using Microsoft.AspNetCore.Components.Routing; + using Microsoft.AspNetCore.WebUtilities; + + using ReqIFSharp; + using ReqIFSharp.Extensions.Services; + + using ReqifViewer.Navigation; + + using Serilog; + + /// + /// Hosts the picker shell + URL state for the Relation Matrix view. The matrix itself — + /// async build, busy indicator, cancellation, render caches, scroll restoration, dismissible + /// tip — lives in , which receives + /// the selectors as parameters and rebuilds whenever they change. + /// + public sealed partial class RelationMatrixPage : ComponentBase, IDisposable + { + /// The ReqIF header identifier from the route; selects which loaded document to show. + [Parameter] + public string Identifier { get; set; } + + /// The scoped service that owns the parsed ReqIF documents. + [Inject] + public IReqIFLoaderService ReqIfLoaderService { get; set; } + + /// Used to project picker state into the URL query string and to observe location changes. + [Inject] + public NavigationManager NavigationManager { get; set; } + + /// True during the brief ReqIF lookup window before the page resolves its document. + public bool IsLoading { get; private set; } = true; + + /// The ReqIF matching , or null if none is loaded under that id. + private ReqIF reqIf; + + /// The ReqIF resolved on the previous parameter set; used to detect a document change and reset the pickers. + private ReqIF previousReqIf; + + /// The current document's s, offered as the row/column picker options. + private IReadOnlyList specObjectTypes = Array.Empty(); + + /// The current document's s, offered as the relation picker options. + private IReadOnlyList specRelationTypes = Array.Empty(); + + /// The selected row , passed to the matrix component. + private SpecObjectType RowType { get; set; } + + /// The selected column , passed to the matrix component. + private SpecObjectType ColumnType { get; set; } + + /// The selected , passed to the matrix component. + private SpecRelationType RelationType { get; set; } + + /// Whether the matrix should hide rows/columns that carry no relation in the current selection. + private bool ShowOnlyRelated { get; set; } = true; + + /// Subscribes to location changes so address-bar edits re-seed the pickers. + protected override void OnInitialized() + { + this.NavigationManager.LocationChanged += this.OnLocationChanged; + } + + /// + /// Resolves the ReqIF for the current , rebuilds the picker option + /// lists, resets the selectors when the document changed, seeds defaults, and applies any + /// picker state carried in the query string. + /// + protected override void OnParametersSet() + { + try + { + this.IsLoading = true; + + this.reqIf = this.ReqIfLoaderService.ReqIFData? + .SingleOrDefault(x => x.TheHeader.Identifier == this.Identifier); + + if (this.reqIf == null) + { + return; + } + + this.specObjectTypes = this.reqIf.CoreContent.SpecTypes + .OfType().ToList(); + + this.specRelationTypes = this.reqIf.CoreContent.SpecTypes + .OfType().ToList(); + + if (!ReferenceEquals(this.reqIf, this.previousReqIf)) + { + this.previousReqIf = this.reqIf; + this.RowType = null; + this.ColumnType = null; + this.RelationType = null; + } + + this.RowType ??= this.specObjectTypes.Count > 0 ? this.specObjectTypes[0] : null; + this.ColumnType ??= this.specObjectTypes.Count > 0 ? this.specObjectTypes[0] : null; + this.RelationType ??= this.specRelationTypes.Count > 0 ? this.specRelationTypes[0] : null; + + this.ApplyPickerStateFromQueryString(); + } + catch (Exception e) + { + Log.ForContext().Error(e, "OnParametersSet Failed"); + } + finally + { + this.IsLoading = false; + } + } + + /// Projects the current picker selection into the URL (replacing the history entry) after a dropdown change. + private void OnAxisChanged() + { + this.NavigationManager.NavigateTo(this.BuildMatrixUrl(), new NavigationOptions { ReplaceHistoryEntry = true }); + } + + /// Swaps the row and column types and projects the new selection into the URL. + private void OnSwapAxes() + { + (this.RowType, this.ColumnType) = (this.ColumnType, this.RowType); + this.NavigationManager.NavigateTo(this.BuildMatrixUrl(), new NavigationOptions { ReplaceHistoryEntry = true }); + } + + /// + /// Reacts to URL changes that arrive *without* a remount (typically: user edits the address bar, + /// or another component on the page issues a NavigateTo). Picker changes don't need this path — + /// they mutate state directly and Blazor's automatic re-render passes the new selectors to the + /// matrix component. + /// + private void OnLocationChanged(object sender, LocationChangedEventArgs args) + { + if (this.Identifier == null) + { + return; + } + + var uri = this.NavigationManager.ToAbsoluteUri(args.Location); + if (!uri.AbsolutePath.EndsWith($"/reqif/{this.Identifier}/relationmatrix", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var oldRow = this.RowType; + var oldCol = this.ColumnType; + var oldRel = this.RelationType; + var oldShow = this.ShowOnlyRelated; + + this.ApplyPickerStateFromQueryString(); + + if (!ReferenceEquals(oldRow, this.RowType) + || !ReferenceEquals(oldCol, this.ColumnType) + || !ReferenceEquals(oldRel, this.RelationType) + || oldShow != this.ShowOnlyRelated) + { + _ = this.InvokeAsync(this.StateHasChanged); + } + } + + /// + /// Overlays picker state from the row/col/rel/related query-string + /// parameters, only when a value is present and resolves to a type in the current document. + /// + private void ApplyPickerStateFromQueryString() + { + if (this.NavigationManager.TryGetQueryString("row", out var rowId)) + { + var match = this.specObjectTypes.FirstOrDefault(t => t.Identifier == rowId); + if (match != null) + { + this.RowType = match; + } + } + + if (this.NavigationManager.TryGetQueryString("col", out var colId)) + { + var match = this.specObjectTypes.FirstOrDefault(t => t.Identifier == colId); + if (match != null) + { + this.ColumnType = match; + } + } + + if (this.NavigationManager.TryGetQueryString("rel", out var relId)) + { + var match = this.specRelationTypes.FirstOrDefault(t => t.Identifier == relId); + if (match != null) + { + this.RelationType = match; + } + } + + if (this.NavigationManager.TryGetQueryString("related", out var relatedStr) + && bool.TryParse(relatedStr, out var related)) + { + this.ShowOnlyRelated = related; + } + } + + /// + /// Builds a per-picker-view attachment identity for the matrix component. It is not a + /// storage key — the top-left-corner anchor itself lives in the URL query string + /// (anchorRow/anchorCol, written by the matrixScroll JS module). The + /// component uses this string only to decide whether the current view is already + /// wired (re-attaching scroll/fit once per distinct picker selection). + /// + private string BuildScrollKey() + { + return $"matrix-scroll:{this.Identifier}" + + $":{this.RowType?.Identifier ?? "_"}" + + $":{this.ColumnType?.Identifier ?? "_"}" + + $":{this.RelationType?.Identifier ?? "_"}" + + $":{(this.ShowOnlyRelated ? "rel" : "all")}"; + } + + /// + /// Builds the matrix route URL carrying the current picker selection as query parameters. + /// Deliberately omits the scroll anchor (anchorRow/anchorCol) so a picker + /// change resets the new view to the top-left corner. + /// + private string BuildMatrixUrl() + { + var query = new Dictionary(); + + if (this.RowType != null) + { + query["row"] = this.RowType.Identifier; + } + + if (this.ColumnType != null) + { + query["col"] = this.ColumnType.Identifier; + } + + if (this.RelationType != null) + { + query["rel"] = this.RelationType.Identifier; + } + + query["related"] = this.ShowOnlyRelated ? "true" : "false"; + + return QueryHelpers.AddQueryString($"/reqif/{this.Identifier}/relationmatrix", query); + } + + /// Unsubscribes from location changes. + public void Dispose() + { + if (this.NavigationManager != null) + { + this.NavigationManager.LocationChanged -= this.OnLocationChanged; + } + } + } +} diff --git a/reqifviewer/Pages/ReqIF/ReqIFStatistics.razor b/reqifviewer/Pages/ReqIF/ReqIFStatistics.razor index 03ae028..369eb96 100644 --- a/reqifviewer/Pages/ReqIF/ReqIFStatistics.razor +++ b/reqifviewer/Pages/ReqIF/ReqIFStatistics.razor @@ -129,6 +129,25 @@ else + +
diff --git a/reqifviewer/Pages/_Host.cshtml b/reqifviewer/Pages/_Host.cshtml index e59fa45..849b59d 100644 --- a/reqifviewer/Pages/_Host.cshtml +++ b/reqifviewer/Pages/_Host.cshtml @@ -36,8 +36,8 @@ ReqIF Viewer - - + + @@ -56,6 +56,7 @@ + diff --git a/reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs b/reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs new file mode 100644 index 0000000..71cb66a --- /dev/null +++ b/reqifviewer/ReqIFExtensions/RelationMatrixExtensions.cs @@ -0,0 +1,222 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.ReqIFExtensions +{ + using System.Collections.Generic; + using System.Threading; + + using ReqIFSharp; + + /// + /// Direction of the (s) in a relative to the + /// row and the column . + /// + public enum RelationDirection + { + /// No relation between row and column. + None, + + /// A relation exists with row as and column as . + Forward, + + /// A relation exists with column as and row as . + Backward, + + /// Relations exist in both directions. + Both + } + + /// + /// Single cell of a : the direction(s) of the underlying (s) + /// between a row and a column . + /// + public sealed class MatrixCell + { + /// The relation with row as , column as ; null if absent. + public SpecRelation Forward { get; set; } + + /// The relation with column as , row as ; null if absent. + public SpecRelation Backward { get; set; } + + /// Direction summary derived from and . + public RelationDirection Direction => + (this.Forward, this.Backward) switch + { + (null, null) => RelationDirection.None, + (not null, null) => RelationDirection.Forward, + (null, not null) => RelationDirection.Backward, + _ => RelationDirection.Both + }; + } + + /// + /// The result of : row and column + /// s and the populated s. Cells with no relation + /// are not stored — absence in means . + /// + public sealed class RelationMatrixData + { + public IReadOnlyList Rows { get; init; } + + public IReadOnlyList Columns { get; init; } + + public IReadOnlyDictionary<(string rowId, string columnId), MatrixCell> Cells { get; init; } + } + + /// + /// Provides the matrix computation that backs the relation-matrix page: for a chosen row + /// , column and + /// it returns the row/column s and the directional s. + /// + public static class RelationMatrixExtensions + { + /// + /// How often the relation scan checks the . Power-of-two so the + /// loop can use a bitmask instead of modulo — keeps the inner check ~1 cycle on a 1 M-relation file. + /// + private const int CancellationCheckEvery = 512; + + /// + /// Build a row × column matrix of s for the given types. + /// + /// + /// The relation scan checks every + /// iterations so a caller running this on the threadpool can interrupt it on a long ReqIF. + /// + public static RelationMatrixData BuildRelationMatrix(this ReqIFContent content, SpecObjectType rowType, SpecObjectType columnType, SpecRelationType relationType, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var rows = new List(); + var columns = new List(); + + if (rowType != null || columnType != null) + { + var objectIndex = 0; + foreach (var o in content.SpecObjects) + { + if ((++objectIndex & (CancellationCheckEvery - 1)) == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + if (rowType != null && o.Type == rowType) + { + rows.Add(o); + } + + if (columnType != null && o.Type == columnType) + { + columns.Add(o); + } + } + } + + var cells = new Dictionary<(string, string), MatrixCell>(); + + if (relationType == null || rows.Count == 0 || columns.Count == 0) + { + return new RelationMatrixData + { + Rows = rows, + Columns = columns, + Cells = cells + }; + } + + var rowIds = new HashSet(); + var idIndex = 0; + foreach (var r in rows) + { + if ((++idIndex & (CancellationCheckEvery - 1)) == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + rowIds.Add(r.Identifier); + } + + var columnIds = new HashSet(); + foreach (var c in columns) + { + if ((++idIndex & (CancellationCheckEvery - 1)) == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + columnIds.Add(c.Identifier); + } + + var index = 0; + foreach (var relation in content.SpecRelations) + { + if ((++index & (CancellationCheckEvery - 1)) == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + if (relation.Type != relationType) + { + continue; + } + + var sourceId = relation.Source?.Identifier; + var targetId = relation.Target?.Identifier; + + if (sourceId == null || targetId == null) + { + continue; + } + + if (rowIds.Contains(sourceId) && columnIds.Contains(targetId)) + { + var key = (sourceId, targetId); + if (!cells.TryGetValue(key, out var cell)) + { + cell = new MatrixCell(); + cells[key] = cell; + } + + cell.Forward ??= relation; + } + + if (rowIds.Contains(targetId) && columnIds.Contains(sourceId)) + { + var key = (targetId, sourceId); + if (!cells.TryGetValue(key, out var cell)) + { + cell = new MatrixCell(); + cells[key] = cell; + } + + cell.Backward ??= relation; + } + } + + return new RelationMatrixData + { + Rows = rows, + Columns = columns, + Cells = cells + }; + } + } +} diff --git a/reqifviewer/Shared/SideMenu.razor b/reqifviewer/Shared/SideMenu.razor index bb25021..f088dc2 100644 --- a/reqifviewer/Shared/SideMenu.razor +++ b/reqifviewer/Shared/SideMenu.razor @@ -44,6 +44,7 @@ + diff --git a/reqifviewer/wwwroot/css/app.css b/reqifviewer/wwwroot/css/app.css index 8beedfd..ba5c365 100644 --- a/reqifviewer/wwwroot/css/app.css +++ b/reqifviewer/wwwroot/css/app.css @@ -48,3 +48,14 @@ a, .btn-link { right: 0.75rem; top: 0.5rem; } + +.rz-footer { + min-height: 0; + padding: 4px 16px; +} + +.rz-footer p { + margin: 0; + font-size: 0.75rem; + line-height: 1.3; +} diff --git a/reqifviewer/wwwroot/js/matrix-scroll.js b/reqifviewer/wwwroot/js/matrix-scroll.js new file mode 100644 index 0000000..9a1150e --- /dev/null +++ b/reqifviewer/wwwroot/js/matrix-scroll.js @@ -0,0 +1,202 @@ +globalThis.matrixScroll = (() => { + const debounceMs = 250; + // Wall-clock budget for the restore retry. The matrix builds asynchronously and + // Virtualize sizes its scroll spacers lazily over several frames (longer on a big + // ReqIF), so the container is not tall enough to honor a large scrollTop right + // away. We keep re-asserting until it is, bounded so we never loop forever. + const restoreBudgetMs = 4000; + const handlers = new Map(); // element -> { key, listener, timer } + + function restore(element, targetTop, targetLeft, entry) { + if (targetTop === 0 && targetLeft === 0) { + return; + } + + const deadline = performance.now() + restoreBudgetMs; + let goodFrames = 0; + + const step = () => { + // Bail if this view was detached or superseded by a newer attach + // (entry-object identity is the generation token). + if (handlers.get(element) !== entry) { + return; + } + + const maxTop = Math.max(0, element.scrollHeight - element.clientHeight); + const maxLeft = Math.max(0, element.scrollWidth - element.clientWidth); + + element.scrollTop = Math.min(targetTop, maxTop); + element.scrollLeft = Math.min(targetLeft, maxLeft); + + // Only count as settled once the container can actually accommodate the + // target (Virtualize has sized its spacers) AND the browser accepted the + // assignment. Re-assert for a few extra frames afterwards because + // Virtualize re-renders its row window on the programmatic scroll and can + // nudge layout once more. + const heightReady = maxTop >= targetTop - 1; + const widthReady = maxLeft >= targetLeft - 1; + const topApplied = Math.abs(element.scrollTop - targetTop) <= 1; + const leftApplied = Math.abs(element.scrollLeft - targetLeft) <= 1; + + if (heightReady && widthReady && topApplied && leftApplied) { + goodFrames++; + } else { + goodFrames = 0; + } + + if (goodFrames < 3 && performance.now() < deadline) { + requestAnimationFrame(step); + } + }; + + requestAnimationFrame(step); + } + + function attach(element, key, rowHeight, cellWidth, rowIds, colIds) { + if (!element || !rowHeight || !cellWidth) { + return; + } + + const previous = handlers.get(element); + if (previous) { + if (previous.key === key) { + // Already wired for this view — do not re-restore or duplicate the + // listener, otherwise re-renders would yank the viewport. + return; + } + element.removeEventListener('scroll', previous.listener); + if (previous.timer) { + clearTimeout(previous.timer); + } + } + + const rows = rowIds || []; + const cols = colIds || []; + + // Create + register the entry before restore so its identity is the + // generation token a stale restore loop / debounce checks against. + const entry = { key, listener: null, timer: null }; + handlers.set(element, entry); + + const params = new URL(globalThis.location.href).searchParams; + const savedRow = Math.max(0, rows.indexOf(params.get('anchorRow'))); + const savedCol = Math.max(0, cols.indexOf(params.get('anchorCol'))); + restore(element, savedRow * rowHeight, savedCol * cellWidth, entry); + + const listener = () => { + if (entry.timer) { + clearTimeout(entry.timer); + } + entry.timer = setTimeout(() => { + entry.timer = null; + + // Superseded/detached: do not touch the URL for an obsolete view. + if (handlers.get(element) !== entry || rows.length === 0 || cols.length === 0) { + return; + } + + const row = Math.min(rows.length - 1, Math.max(0, Math.floor(element.scrollTop / rowHeight))); + const col = Math.min(cols.length - 1, Math.max(0, Math.floor(element.scrollLeft / cellWidth))); + + const url = new URL(globalThis.location.href); + url.searchParams.set('anchorRow', rows[row]); + url.searchParams.set('anchorCol', cols[col]); + // replaceState (not pushState) so scrolling adds no history entries; + // pass history.state through so Blazor's client router state survives. + history.replaceState(history.state, '', url); + }, debounceMs); + }; + entry.listener = listener; + element.addEventListener('scroll', listener, { passive: true }); + } + + function detach(element) { + const previous = handlers.get(element); + if (!previous) { + return; + } + element.removeEventListener('scroll', previous.listener); + if (previous.timer) { + clearTimeout(previous.timer); + } + handlers.delete(element); + } + + // ---- viewport fitting ------------------------------------------------- + // Size the wrapper so it exactly fills the space between its own top and + // the bottom of the viewport (above the Radzen footer). Component-local: + // no global CSS / body class. Recomputed on resize + parent reflow. + const fitGap = 8; + const fitters = new Map(); // element -> { onResize, ro, rafId, timeoutId } + + function applyHeight(element) { + // Bail if the component was disposed (unfit ran) before this + // scheduled callback fired — don't touch a detached element. + if (!fitters.has(element)) { + return; + } + + const rect = element.getBoundingClientRect(); + // Distance from the document top — stable regardless of any current + // page scroll, so the value converges (no measurement feedback loop). + const absoluteTop = rect.top + globalThis.scrollY; + const footer = document.querySelector('.rz-footer'); + const footerH = footer ? footer.getBoundingClientRect().height : 0; + const height = Math.max(120, globalThis.innerHeight - absoluteTop - footerH - fitGap); + element.style.height = height + 'px'; + } + + function fit(element) { + if (!element) { + return; + } + + if (fitters.has(element)) { + applyHeight(element); + return; + } + + let scheduled = false; + const onResize = () => { + if (scheduled) { + return; + } + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + applyHeight(element); + }); + }; + + const rafId = requestAnimationFrame(() => applyHeight(element)); + // Catch late Radzen / web-font layout shifts. + const timeoutId = setTimeout(() => applyHeight(element), 100); + + globalThis.addEventListener('resize', onResize, { passive: true }); + + let ro = null; + if (typeof ResizeObserver !== 'undefined' && element.parentElement) { + ro = new ResizeObserver(onResize); + ro.observe(element.parentElement); + } + + fitters.set(element, { onResize, ro, rafId, timeoutId }); + } + + function unfit(element) { + const entry = fitters.get(element); + if (!entry) { + return; + } + cancelAnimationFrame(entry.rafId); + clearTimeout(entry.timeoutId); + globalThis.removeEventListener('resize', entry.onResize); + if (entry.ro) { + entry.ro.disconnect(); + } + element.style.height = ''; + fitters.delete(element); + } + + return { attach, detach, fit, unfit }; +})();