Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,12 @@ URLs follow `/reqif/{header-identifier}/<entity>/<entity-identifier>`. The `Spec

### Layout / UI stack

- `App.razor` → `Shared/MainLayout.razor` (Radzen layout + a `BSOffCanvas` from BlazorStrap V5 for the `SideMenu`).
- UI libraries: **Radzen.Blazor**, **BlazorStrap.V5**, plus **Blazor-Analytics** (Google Analytics ID hard-coded in `Program.cs`).
- Hosting is the Blazor Web App model: `App.razor` is the host page, `Routes.razor` the router, and the whole app runs with the `InteractiveServer` render mode (`MapRazorComponents<App>().AddInteractiveServerRenderMode()` in `Program.cs`).
- The UI is moving from **Radzen.Blazor** to **[Blazor Blueprint](https://blazorblueprintui.com)** (`BlazorBlueprint.Components`, Lucide icons). The shell is already Blueprint: `Shared/MainLayout.razor` (`BbSidebarProvider`, header with `BbDarkModeToggle`) and `Shared/SideMenu.razor` (`BbSidebar`). The pages still use Radzen components and the Bootstrap grid.
- Both libraries define types with the same names (`DialogService`, `ButtonSize`, ...), so Blueprint is imported per folder: `Shared/_Imports.razor`. Add the same usings to a folder when you migrate its pages; move them to the root `_Imports.razor` once Radzen is removed.
- Theme: `wwwroot/css/theme.css` holds the Blueprint CSS variables for light (`:root`) and dark (`.dark`). The "bridge" section at the end of `wwwroot/css/app.css` maps the Radzen theme variables onto them and undoes Bootstrap's global link and list styles in the sidebar; delete it when Bootstrap and Radzen are gone.
- There is no Tailwind build: `blazorblueprint.css` only contains `bb:`-prefixed utilities used by the components, so layout CSS for our own markup goes in scoped `.razor.css` files.
- **Blazor-Analytics** (Google Analytics ID hard-coded in `Program.cs`).
- Logging: **Serilog** configured from `appsettings.json`; rolling file sink writes to `logs/log-reqifviewer-*.txt`. Use `Log.ForContext<T>()` rather than injecting `ILogger` (this is the established pattern).
- The Dockerfile runs as a non-root user (`$APP_UID`) on Alpine; anything that needs to write must go under `/app` (e.g. `/app/logs`, which is created in the image).

Expand Down
91 changes: 91 additions & 0 deletions reqifviewer.Tests/HostingTestFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// -------------------------------------------------------------------------------------------------
// <copyright file="HostingTestFixture.cs" company="Starion Group S.A.">
//
// 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.
//
// </copyright>
// -------------------------------------------------------------------------------------------------

namespace ReqifViewer.Tests
{
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;

using Microsoft.AspNetCore.Mvc.Testing;

using NUnit.Framework;

using reqifviewer;

/// <summary>
/// Starts the whole application in memory and checks the host page and the not-found page
/// </summary>
[TestFixture]
public class HostingTestFixture
{
private WebApplicationFactory<Program> factory;

private HttpClient client;

[OneTimeSetUp]
public void OneTimeSetUp()
{
this.factory = new WebApplicationFactory<Program>();
this.client = this.factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
}

[OneTimeTearDown]
public void OneTimeTearDown()
{
this.client.Dispose();
this.factory.Dispose();
}

[Test]
public async Task Verify_that_the_root_returns_the_host_page()
{
var response = await this.client.GetAsync("/");
var html = await response.Content.ReadAsStringAsync();

Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(html, Does.Contain("_framework/blazor.web.js"));
Assert.That(html, Does.Contain("ReqIF Viewer"));
}

[Test]
public async Task Verify_that_the_theme_is_loaded_before_the_Blazor_Blueprint_stylesheet()
{
var html = await this.client.GetStringAsync("/");

var theme = html.IndexOf("css/theme.", System.StringComparison.Ordinal);
var blueprint = html.IndexOf("blazorblueprint.css", System.StringComparison.Ordinal);

Assert.That(theme, Is.GreaterThanOrEqualTo(0), "theme.css is not referenced");
Assert.That(blueprint, Is.GreaterThan(theme), "theme.css must be loaded before blazorblueprint.css");
}

[Test]
public async Task Verify_that_an_unknown_address_returns_the_not_found_page()
{
var response = await this.client.GetAsync("/this-page-does-not-exist");
var html = await response.Content.ReadAsStringAsync();

Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(html, Does.Contain("Sorry, there&#x27;s nothing at this address.").Or.Contain("Sorry, there's nothing at this address."));
Assert.That(html, Does.Contain("_framework/blazor.web.js"), "the not-found page is rendered inside the app");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,39 @@ public void Verify_that_ExtractSpecificationName_returns_expected_result()
specObject = this.reqIf.CoreContent.SpecObjects.Single(x => x.Identifier == "_DqMpsKddEeafNduaIhMwQg");
Assert.That(specObject.ExtractDisplayName(), Is.EqualTo("REQ-6"));
}

[Test]
public void Verify_that_ExtractDisplayText_returns_plain_text_in_the_same_order_as_ExtractDisplayName()
{
// The first attribute value is XHTML: its text is returned without markup
var specObject = this.reqIf.CoreContent.SpecObjects.Single(x => x.Identifier == "_o7scQ6dbEeafNduaIhMwQg");
Assert.That(specObject.ExtractDisplayText(), Is.EqualTo("Stakeholder Requirements"));

var specification = this.reqIf.CoreContent.Specifications.Single(x => x.Identifier == "_o7scS6dbEeafNduaIhMwQg");
Assert.That(specification.ExtractDisplayText(), Is.EqualTo("Requirements Document"));

specObject = this.reqIf.CoreContent.SpecObjects.Single(x => x.Identifier == "_DqMpsKddEeafNduaIhMwQg");
Assert.That(specObject.ExtractDisplayText(), Is.EqualTo("REQ-6"));
}

[Test]
public void Verify_that_ExtractDisplayText_falls_back_to_the_identifier()
{
var withoutValues = new Specification { Identifier = "spec-1" };
Assert.That(withoutValues.ExtractDisplayText(), Is.EqualTo("spec-1"));

var withEmptyXhtml = new SpecObject { Identifier = "object-1" };
withEmptyXhtml.Values.Add(new AttributeValueXHTML { TheValue = "<xhtml:div xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"> </xhtml:div>" });
Assert.That(withEmptyXhtml.ExtractDisplayText(), Is.EqualTo("object-1"));
}

[Test]
public void Verify_that_ExtractDisplayText_collapses_whitespace()
{
var specObject = new SpecObject { Identifier = "object-1" };
specObject.Values.Add(new AttributeValueXHTML { TheValue = "<xhtml:div xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n First line\n <xhtml:b>bold</xhtml:b>\n</xhtml:div>" });

Assert.That(specObject.ExtractDisplayText(), Is.EqualTo("First line bold"));
}
}
}
172 changes: 172 additions & 0 deletions reqifviewer.Tests/Shared/MainLayoutTestFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// -------------------------------------------------------------------------------------------------
// <copyright file="MainLayoutTestFixture.cs" company="Starion Group S.A.">
//
// 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.
//
// </copyright>
// -------------------------------------------------------------------------------------------------

namespace ReqifViewer.Tests.Shared
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

using BlazorBlueprint.Components;

using Bunit;
using Bunit.TestDoubles;

using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;

using Moq;

using NUnit.Framework;

using Radzen;

using ReqIFSharp;
using ReqIFSharp.Extensions.Services;

using reqifviewer.Pages.Index;
using reqifviewer.Shared;

using TestContext = Bunit.TestContext;

/// <summary>
/// Suite of tests for the <see cref="MainLayout"/> component
/// </summary>
[TestFixture]
public class MainLayoutTestFixture
{
private TestContext context;

[SetUp]
public void SetUp()
{
this.context = new TestContext();
this.context.JSInterop.Mode = JSRuntimeMode.Loose;
this.context.Services.AddBlazorBlueprintComponents();

this.context.Services.AddScoped<Radzen.DialogService>();

// The About dialog downloads the license text; answer it without going to the network
this.context.Services.AddSingleton(new HttpClient(new StubHttpMessageHandler()));
this.context.Services.AddScoped<NotificationService>();
this.context.Services.AddScoped<TooltipService>();
this.context.Services.AddScoped<ContextMenuService>();

var reqIfLoaderService = new Mock<IReqIFLoaderService>();
reqIfLoaderService.Setup(x => x.ReqIFData).Returns([]);
this.context.Services.AddSingleton(reqIfLoaderService.Object);
}

[TearDown]
public void TearDown()
{
this.context.Dispose();
}

/// <summary>
/// Answers every request with a short text, so components that download files do not reach the network
/// </summary>
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("license text") });
}
}

private IRenderedComponent<MainLayout> RenderLayout()
{
return this.context.RenderComponent<MainLayout>(parameters => parameters.Add(x => x.Body, (RenderFragment)(builder => builder.AddContent(0, "page content"))));
}

[Test]
public void Verify_that_MainLayout_renders_the_page_body()
{
var renderer = this.RenderLayout();

Assert.That(renderer.Find("main.app-content").TextContent, Does.Contain("page content"));
}

[Test]
public void Verify_that_About_button_opens_the_About_dialog()
{
string openedTitle = null;
Type openedType = null;

var renderer = this.RenderLayout();

this.context.Services.GetRequiredService<Radzen.DialogService>().OnOpen += (title, type, _, _) =>
{
openedTitle = title;
openedType = type;
};

renderer.FindAll("header button").Single(x => x.TextContent.Trim() == "About").Click();

Assert.That(openedTitle, Is.EqualTo("About ReqIF Viewer"));
Assert.That(openedType, Is.EqualTo(typeof(AboutComponent)));
}

[Test]
public void Verify_that_the_GitHub_link_opens_in_a_new_tab_without_opener_or_referrer()
{
var renderer = this.RenderLayout();

var link = renderer.Find("a[href=\"https://github.com/STARIONGROUP/reqifviewer\"]");

Assert.That(link.GetAttribute("target"), Is.EqualTo("_blank"));
Assert.That(link.GetAttribute("rel")?.Split(' '), Is.SupersetOf(new[] { "noopener", "noreferrer" }));

Check warning on line 139 in reqifviewer.Tests/Shared/MainLayoutTestFixture.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array

See more on https://sonarcloud.io/project/issues?id=STARIONGROUP_reqifviewer&issues=AaFPGWm2idhKYWVJpdIZ&open=AaFPGWm2idhKYWVJpdIZ&pullRequest=56
}

[Test]
public void Verify_that_the_mobile_sidebar_closes_after_navigating()
{
var renderer = this.RenderLayout();
var provider = renderer.FindComponent<BbSidebarProvider>();

Assert.That(provider.Instance.OpenMobile, Is.False);

// The user opens the drawer on a small screen
renderer.InvokeAsync(() => provider.Instance.OpenMobileChanged.InvokeAsync(true)).GetAwaiter().GetResult();
renderer.Render();
Assert.That(renderer.FindComponent<BbSidebarProvider>().Instance.OpenMobile, Is.True);

// ... and follows a link
this.context.Services.GetRequiredService<FakeNavigationManager>().NavigateTo("reqifstatistics/doc-a");

renderer.WaitForAssertion(() => Assert.That(renderer.FindComponent<BbSidebarProvider>().Instance.OpenMobile, Is.False));
}

[Test]
public void Verify_that_MainLayout_stops_listening_to_navigation_when_disposed()
{
this.RenderLayout();
this.context.DisposeComponents();

var navigationManager = this.context.Services.GetRequiredService<FakeNavigationManager>();

Assert.DoesNotThrow(() => navigationManager.NavigateTo("reqifstatistics/doc-a"));
}
}
}
Loading
Loading