diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 424ba64..b9857ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,19 @@ jobs: dotnet build -c Debug + # init --dashboard wires the layout: Routes default, stock MainLayout/NavMenu removed, + # error bar moved to App.razor, sidebar links built from the app's pages. + cd "$TMPDIR/app" + dotnet new blazor -o DashApp --no-restore + cd DashApp + shellui init --tailwind standalone --yes --dashboard 02 + grep -q 'typeof(Layout.DashboardLayout02)' Components/Routes.razor || (echo "init --dashboard 02 did not switch Routes.razor"; exit 1) + test ! -e Components/Layout/MainLayout.razor || (echo "stock MainLayout.razor was not removed"; exit 1) + test ! -e Components/Layout/NavMenu.razor || (echo "stock NavMenu.razor was not removed"; exit 1) + grep -q 'id="blazor-error-ui"' Components/App.razor || (echo "the error bar was not added to App.razor"; exit 1) + grep -q '"/counter"' Components/UI/AppSidebar.razor || (echo "AppSidebar links were not built from the pages"; exit 1) + dotnet build -c Debug + # Every direct target must install and compile together; this catches missing # dependencies and template compile errors that per-template tests can't see. ALL=$(python3 - "$GITHUB_WORKSPACE" <<'PY' diff --git a/ShellUI.Tests/DashboardSetupTests.cs b/ShellUI.Tests/DashboardSetupTests.cs new file mode 100644 index 0000000..0b6f486 --- /dev/null +++ b/ShellUI.Tests/DashboardSetupTests.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using ShellUI.CLI.Services; +using ShellUI.Core.Models; +using ShellUI.Templates; +using Xunit; + +namespace ShellUI.Tests; + +public class DashboardSetupTests +{ + [Theory] + [InlineData("02", "dashboard-02")] + [InlineData("dashboard-01", "dashboard-01")] + [InlineData("1", "dashboard-01")] + [InlineData("None", "none")] + [InlineData(null, null)] + public void ParseDashboardOption_AcceptsShortAndLongForms(string? value, string? expected) + { + Assert.Equal(expected, DashboardSetup.ParseDashboardOption(value)); + } + + [Fact] + public void ParseDashboardOption_RejectsUnknownValues() + { + Assert.Throws(() => DashboardSetup.ParseDashboardOption("03")); + } + + [Fact] + public void RewriteDefaultLayout_SwitchesStockRoutes() + { + var routes = Fixture("net10", "Routes.razor"); + Assert.Equal("Layout.MainLayout", DashboardSetup.FindDefaultLayout(routes)); + + var rewritten = DashboardSetup.RewriteDefaultLayout(routes, "Layout.DashboardLayout02"); + + Assert.Contains(@"DefaultLayout=""typeof(Layout.DashboardLayout02)""", rewritten); + Assert.Contains("NotFoundPage=\"typeof(Pages.NotFound)\"", rewritten); + } + + [Fact] + public void FindRoutesFile_AcceptsAuthorizeRouteView() + { + var root = Path.Combine(Path.GetTempPath(), "shellui-routes-" + Guid.NewGuid().ToString("N")); + try + { + Write(root, "Components/Routes.razor", @""); + + Assert.Equal(Path.Combine(root, "Components", "Routes.razor"), DashboardSetup.FindRoutesFile(root)); + } + finally + { + Directory.Delete(root, true); + } + } + + [Fact] + public void RewriteDefaultLayout_KeepsRazorExpressionPrefix() + { + const string wasm = @""; + + Assert.Equal("MainLayout", DashboardSetup.FindDefaultLayout(wasm)); + Assert.Contains(@"DefaultLayout=""@typeof(X.Y)""", DashboardSetup.RewriteDefaultLayout(wasm, "X.Y")); + } + + [Theory] + [InlineData("Components", "Components/Layout", "Layout.DashboardLayout02")] + [InlineData("", "Components/Layout", "Components.Layout.DashboardLayout02")] + [InlineData("Components", "Components", "DashboardLayout02")] + [InlineData("Components", "Shared/Layouts", "global::MyApp.Shared.Layouts.DashboardLayout02")] + public void LayoutTypeReference_IsRelativeToTheRoutesNamespace(string routesDir, string layoutPath, string expected) + { + var cwd = Path.Combine(Path.GetTempPath(), "shellui-app"); + var dir = routesDir.Length == 0 ? cwd : Path.Combine(cwd, routesDir); + + Assert.Equal(expected, DashboardSetup.LayoutTypeReference(dir, cwd, "MyApp", layoutPath, "DashboardLayout02")); + } + + [Theory] + [InlineData("net10", "MainLayout.razor")] + [InlineData("net10", "MainLayout.razor.css")] + [InlineData("net10", "NavMenu.razor")] + [InlineData("net10", "NavMenu.razor.css")] + [InlineData("net8", "MainLayout.razor")] + [InlineData("net8", "NavMenu.razor")] + public void IsStockContent_RecognizesTemplateOutput_ForAnyProjectName(string framework, string file) + { + foreach (var name in new[] { "Contoso.Shop", "A", "App" }) + { + var content = Fixture(framework, file).Replace("\r\n", "\n").Replace(DashboardSetup.StockProjectName, name); + + Assert.True(DashboardSetup.IsStockContent(file, content), name); + Assert.True(DashboardSetup.IsStockContent(file, content.Replace("\n", "\r\n")), name); + } + } + + [Theory] + [InlineData("MainLayout.razor")] + [InlineData("NavMenu.razor")] + public void IsStockContent_RejectsModifiedFiles(string file) + { + var content = Fixture("net10", file) + "\n

custom

"; + + Assert.False(DashboardSetup.IsStockContent(file, content)); + } + + [Fact] + public void AddErrorUi_InsertsBeforeBodyOnce() + { + var app = Fixture("net10", "App.razor"); + + var patched = DashboardSetup.AddErrorUi(app); + + Assert.Contains(@"id=""blazor-error-ui""", patched); + Assert.True(patched.IndexOf("blazor-error-ui", StringComparison.Ordinal) < patched.IndexOf("", StringComparison.Ordinal)); + Assert.Contains("class=\"dismiss", patched); + Assert.Contains("class=\"reload", patched); + Assert.Equal(patched, DashboardSetup.AddErrorUi(patched)); + } + + [Theory] + [InlineData("@page \"/not-found\"\r\n@layout MainLayout\r\n\r\n

Not Found

", "@page \"/not-found\"\r\n@layout DashboardLayout02\r\n\r\n

Not Found

")] + [InlineData("@layout Layout.MainLayout\n", "@layout Layout.DashboardLayout02\n")] + [InlineData("@layout MainLayoutV2\n", "@layout MainLayoutV2\n")] + [InlineData("

MainLayout

", "

MainLayout

")] + public void RetargetLayoutDirective_OnlyTouchesMainLayoutDirectives(string content, string expected) + { + Assert.Equal(expected, DashboardSetup.RetargetLayoutDirective(content, "MainLayout", "DashboardLayout02")); + } + + [Fact] + public void RetargetLayoutDirective_FollowsADashboardSwitch() + { + Assert.Equal("@layout DashboardLayout01\n", DashboardSetup.RetargetLayoutDirective("@layout DashboardLayout02\n", "DashboardLayout02", "DashboardLayout01")); + } + + [Theory] + [InlineData("/", true)] + [InlineData("/counter", true)] + [InlineData("/items/{id:int}", false)] + [InlineData("/Error", false)] + [InlineData("/not-found", false)] + [InlineData("/Account/Login", false)] + [InlineData("/accounting", true)] + public void IsNavigableRoute_SkipsParameterizedAndSystemPages(string href, bool expected) + { + Assert.Equal(expected, DashboardSetup.IsNavigableRoute(href)); + } + + [Fact] + public void ScanPages_BuildsLinksFromPageDirectives() + { + var root = Path.Combine(Path.GetTempPath(), "shellui-scan-" + Guid.NewGuid().ToString("N")); + try + { + Write(root, "Components/Pages/Home.razor", "@page \"/\"\nHome"); + Write(root, "Components/Pages/Counter.razor", "@page \"/counter\"\n@rendermode InteractiveServer\nCounter"); + Write(root, "Components/Pages/OrderHistory.razor", "@page \"/orders\""); + Write(root, "Components/Pages/Error.razor", "@page \"/Error\""); + Write(root, "Components/Pages/Item.razor", "@page \"/items/{id}\""); + Write(root, "Components/Account/Pages/Login.razor", "@page \"/Account/Login\""); + Write(root, "Components/UI/Demo.razor", "@page \"/ui-demo\""); + Write(root, "obj/Debug/Stale.razor", "@page \"/stale\""); + + var links = DashboardSetup.ScanPages(root, new ShellUIConfig()); + + Assert.Equal(new[] { "/", "/counter", "/orders" }, links.Select(l => l.Href)); + Assert.Equal(new[] { "Home", "Counter", "Order History" }, links.Select(l => l.Title)); + Assert.Equal(new[] { "home", "counter", "page" }, links.Select(l => l.Icon)); + } + finally + { + Directory.Delete(root, true); + } + } + + [Fact] + public void AppSidebarTemplate_LinksAreRewrittenFromPages() + { + var sidebar = ComponentRegistry.GetComponentContent("app-sidebar")!; + var links = new List + { + new("Home", "/", "home"), + new("Weather \"Now\"", "/weather", "weather") + }; + + var rewritten = DashboardSetup.RewriteSidebarLinks(sidebar, links); + + Assert.NotNull(rewritten); + Assert.Contains(@"new(""Weather \""Now\"""", ""/weather"", ""weather""),", rewritten); + Assert.DoesNotContain("/settings", rewritten); + Assert.DoesNotContain("\"/dashboard\"", rewritten); + Assert.Null(DashboardSetup.RewriteSidebarLinks(rewritten!, links)); + } + + private static void Write(string root, string relative, string content) + { + var path = Path.Combine(root, relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private static string Fixture(string framework, string file, [CallerFilePath] string thisFile = "") => + File.ReadAllText(Path.Combine(Path.GetDirectoryName(thisFile)!, "Fixtures", "StockBlazor", framework, file + ".txt")); +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/App.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/App.razor.txt new file mode 100644 index 0000000..2c0cb52 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/App.razor.txt @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/Counter.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/Counter.razor.txt new file mode 100644 index 0000000..1a4f8e7 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/Counter.razor.txt @@ -0,0 +1,19 @@ +@page "/counter" +@rendermode InteractiveServer + +Counter + +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/Error.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/Error.razor.txt new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/Error.razor.txt @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/Home.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/Home.razor.txt new file mode 100644 index 0000000..9001e0b --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/Home.razor.txt @@ -0,0 +1,7 @@ +@page "/" + +Home + +

Hello, world!

+ +Welcome to your new app. diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/MainLayout.razor.css.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/MainLayout.razor.css.txt new file mode 100644 index 0000000..38d1f25 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/MainLayout.razor.css.txt @@ -0,0 +1,98 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/MainLayout.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/MainLayout.razor.txt new file mode 100644 index 0000000..7cd63fe --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/MainLayout.razor.txt @@ -0,0 +1,17 @@ +@inherits LayoutComponentBase + +
+ + +
+
+ About +
+ +
+ @Body +
+
+
diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/NavMenu.razor.css.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/NavMenu.razor.css.txt new file mode 100644 index 0000000..a2aeace --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/NavMenu.razor.css.txt @@ -0,0 +1,105 @@ +.navbar-toggler { + appearance: none; + cursor: pointer; + width: 3.5rem; + height: 2.5rem; + color: white; + position: absolute; + top: 0.5rem; + right: 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); +} + +.navbar-toggler:checked { + background-color: rgba(255, 255, 255, 0.5); +} + +.top-row { + min-height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep .nav-link { + color: #d7d7d7; + background: none; + border: none; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + width: 100%; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep .nav-link:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +.nav-scrollable { + display: none; +} + +.navbar-toggler:checked ~ .nav-scrollable { + display: block; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .nav-scrollable { + /* Never collapse the sidebar for wide screens */ + display: block; + + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/NavMenu.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/NavMenu.razor.txt new file mode 100644 index 0000000..c5e5ada --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/NavMenu.razor.txt @@ -0,0 +1,24 @@ + + + + + + diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/Routes.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/Routes.razor.txt new file mode 100644 index 0000000..105855d --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/Routes.razor.txt @@ -0,0 +1,6 @@ + + + + + + diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/Weather.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/Weather.razor.txt new file mode 100644 index 0000000..f437e5e --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/Weather.razor.txt @@ -0,0 +1,64 @@ +@page "/weather" +@attribute [StreamRendering] + +Weather + +

Weather

+ +

This component demonstrates showing data.

+ +@if (forecasts == null) +{ +

Loading...

+} +else +{ + + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
+} + +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() + { + // Simulate asynchronous loading to demonstrate streaming rendering + await Task.Delay(500); + + var startDate = DateOnly.FromDateTime(DateTime.Now); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; + forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = startDate.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = summaries[Random.Shared.Next(summaries.Length)] + }).ToArray(); + } + + private class WeatherForecast + { + public DateOnly Date { get; set; } + public int TemperatureC { get; set; } + public string? Summary { get; set; } + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net8/MainLayout.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net8/MainLayout.razor.txt new file mode 100644 index 0000000..7cd63fe --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net8/MainLayout.razor.txt @@ -0,0 +1,17 @@ +@inherits LayoutComponentBase + +
+ + +
+
+ About +
+ +
+ @Body +
+
+
diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net8/NavMenu.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net8/NavMenu.razor.txt new file mode 100644 index 0000000..c6ba2a1 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net8/NavMenu.razor.txt @@ -0,0 +1,24 @@ + + + + + + diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net8/Weather.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net8/Weather.razor.txt new file mode 100644 index 0000000..43a1ecb --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net8/Weather.razor.txt @@ -0,0 +1,64 @@ +@page "/weather" +@attribute [StreamRendering] + +Weather + +

Weather

+ +

This component demonstrates showing data.

+ +@if (forecasts == null) +{ +

Loading...

+} +else +{ + + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
+} + +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() + { + // Simulate asynchronous loading to demonstrate streaming rendering + await Task.Delay(500); + + var startDate = DateOnly.FromDateTime(DateTime.Now); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; + forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = startDate.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = summaries[Random.Shared.Next(summaries.Length)] + }).ToArray(); + } + + private class WeatherForecast + { + public DateOnly Date { get; set; } + public int TemperatureC { get; set; } + public string? Summary { get; set; } + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/ShellUI.Tests/InitBootstrapTests.cs b/ShellUI.Tests/InitBootstrapTests.cs index bedaecd..4394e8e 100644 --- a/ShellUI.Tests/InitBootstrapTests.cs +++ b/ShellUI.Tests/InitBootstrapTests.cs @@ -1,4 +1,5 @@ using ShellUI.CLI.Services; +using ShellUI.Templates; using Xunit; namespace ShellUI.Tests; @@ -28,6 +29,27 @@ public class InitBootstrapTests "; + [Theory] + [InlineData(@" ")] + [InlineData(@" ")] + public void RewriteAppRazor_RemovesTheDeletedLocalBootstrapLink(string link) + { + var app = FreshAppRazor.Replace(" ", link + "\n "); + + var result = InitService.RewriteAppRazor(app); + + Assert.DoesNotContain("bootstrap.min.css", result); + Assert.Contains("@Assets[\"app.css\"]", result); + } + + [Fact] + public void RewriteAppRazor_KeepsCdnBootstrap() + { + const string cdn = @""; + + Assert.Contains(cdn, InitService.RewriteAppRazor(FreshAppRazor.Replace("", cdn + "\n "))); + } + [Fact] public void RewriteAppRazor_AddsRenderModeToHeadOutletAndRoutes() { @@ -138,7 +160,7 @@ public class RequiredImportsTests [Fact] public void VariantsAndModelsFiles_AddTheirNamespaces() { - var imports = ComponentInstaller.RequiredImports("App", new[] { "Button.razor", "Variants/ButtonVariants.cs", "Models/CommandModels.cs" }).ToList(); + var imports = ComponentInstaller.RequiredImports("App", Contents("button", "button-variants", "command-models")).ToList(); Assert.Equal(new[] { "@using App.Components.UI.Variants", "@using App.Components.Models" }, imports); } @@ -146,8 +168,18 @@ public void VariantsAndModelsFiles_AddTheirNamespaces() [Fact] public void PlainComponents_AddNothing() { - Assert.Empty(ComponentInstaller.RequiredImports("App", new[] { "Kbd.razor", "../../wwwroot/shellui.js" })); + Assert.Empty(ComponentInstaller.RequiredImports("App", Contents("kbd", "shellui-js"))); } + + [Fact] + public void VariantsFileInTheUiNamespace_AddsNothing() + { + // AvatarVariants sits in Variants/ but declares Components.UI; a Variants using would not compile. + Assert.Empty(ComponentInstaller.RequiredImports("App", Contents("avatar", "avatar-variants"))); + } + + private static IEnumerable Contents(params string[] names) => + names.Select(n => ComponentRegistry.GetComponentContent(n) ?? throw new InvalidOperationException(n)); } public class InputCssTests diff --git a/ShellUI.Tests/LoadersTests.cs b/ShellUI.Tests/LoadersTests.cs new file mode 100644 index 0000000..e6f8bbb --- /dev/null +++ b/ShellUI.Tests/LoadersTests.cs @@ -0,0 +1,43 @@ +using System.Linq; +using ShellUI.CLI.Services; +using Xunit; + +namespace ShellUI.Tests; + +public class LoadersTests +{ + [Fact] + public void SnakeSpinner_WalksTheGridEdgeWithAThreeDotTrail() + { + var frames = SnakeSpinner.Instance.Frames; + + Assert.Equal(8, frames.Count); + Assert.Equal(8, frames.Distinct().Count()); + foreach (var frame in frames) + { + Assert.Equal(2, frame.Length); + Assert.All(frame, ch => Assert.InRange(ch, '⠀', '⣿')); + Assert.Equal(3, frame.Sum(ch => System.Numerics.BitOperations.PopCount((uint)(ch - 0x2800)))); + } + + // The centre cell (row 1, column 1 → first character, dot 0x10) is never part of the snake path. + Assert.DoesNotContain(frames, f => ((f[0] - 0x2800) & 0x10) != 0); + } + + [Fact] + public void Braille_MapsGridCellsToDots() + { + Assert.Equal("⠉⠁", SnakeSpinner.Braille(i => i < 3)); + Assert.Equal("⠇⠀", SnakeSpinner.Braille(i => i % 3 == 0)); + } + + [Fact] + public void LogoLoader_BuildsTheMarkAlongTheDiagonalThenClearsIt() + { + Assert.Equal(new[] { "X....", ".....", ".....", ".....", "....." }, LogoLoader.Frame(0)); + Assert.Equal(new[] { "X...X", ".X..X", "..X.X", ".X..X", "X...X" }, LogoLoader.Frame(8)); + Assert.Equal(LogoLoader.Frame(8), LogoLoader.Frame(14)); + Assert.Equal(new[] { ".....", ".....", ".....", ".....", "....." }, LogoLoader.Frame(29)); + Assert.Equal(LogoLoader.Frame(0), LogoLoader.Frame(30)); + } +} diff --git a/ShellUI.Tests/StockPagesTests.cs b/ShellUI.Tests/StockPagesTests.cs new file mode 100644 index 0000000..292e361 --- /dev/null +++ b/ShellUI.Tests/StockPagesTests.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using ShellUI.CLI.Services; +using Xunit; + +namespace ShellUI.Tests; + +public class StockPagesTests +{ + [Theory] + [InlineData("net10", "Home.razor")] + [InlineData("net10", "Counter.razor")] + [InlineData("net10", "Weather.razor")] + [InlineData("net10", "Error.razor")] + [InlineData("net8", "Weather.razor")] + public void Restyle_ReplacesBootstrapInStockPages(string framework, string file) + { + var content = Fixture(framework, file); + Assert.True(StockPages.IsStock(file, content)); + + var restyled = StockPages.Restyle(file, content); + + Assert.False(StockPages.UsesBootstrap(restyled)); + Assert.DoesNotContain("class=\"table\"", restyled); + Assert.DoesNotContain("

", restyled); + Assert.False(StockPages.IsStock(file, restyled)); + if (file != "Home.razor") + Assert.Equal(restyled, StockPages.Restyle(file, restyled)); + } + + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public void Restyle_TurnsStockHomeIntoThemeCard_KeepingLineEndings(string newline) + { + var stock = Fixture("net10", "Home.razor").Replace("\r\n", "\n").Replace("\n", newline); + + var home = StockPages.Restyle("Home.razor", stock); + + Assert.StartsWith("@page \"/\"", home); + Assert.Contains("Home", home); + Assert.Contains("shellui theme apply https://tweakcn.com/themes/<id>", home); + Assert.Contains("href=\"https://tweakcn.com\"", home); + Assert.Equal(newline == "\r\n", home.Contains("\r\n")); + } + + [Fact] + public void Restyle_KeepsCounterBehavior() + { + var restyled = StockPages.Restyle("Counter.razor", Fixture("net10", "Counter.razor")); + + Assert.Contains("@rendermode InteractiveServer", restyled); + Assert.Contains("@onclick=\"IncrementCount\"", restyled); + Assert.Contains("bg-primary", restyled); + } + + [Fact] + public void Apply_RestylesStockPagesAndReportsEditedOnes() + { + var root = Path.Combine(Path.GetTempPath(), "shellui-pages-" + Guid.NewGuid().ToString("N")); + try + { + Write(root, "Components/Pages/Counter.razor", Fixture("net10", "Counter.razor")); + Write(root, "Components/Pages/Weather.razor", Fixture("net10", "Weather.razor") + "\n

mine

"); + Write(root, "Components/Account/Pages/Login.razor", ""); + Write(root, "Components/UI/Button.razor", ""); + + var result = StockPages.Apply(root, new[] { "Components/UI", "Components/Layout" }); + + Assert.Equal(new[] { "Components/Pages/Counter.razor" }, result.Restyled); + Assert.False(StockPages.UsesBootstrap(File.ReadAllText(Path.Combine(root, "Components/Pages/Counter.razor")))); + Assert.Equal(2, result.Notes.Count); + Assert.Contains(result.Notes, n => n.Contains("Components/Pages/Weather.razor") && n.Contains("modified")); + Assert.Contains(result.Notes, n => n.StartsWith("1 Identity page")); + Assert.Contains("

mine

", File.ReadAllText(Path.Combine(root, "Components/Pages/Weather.razor"))); + } + finally + { + Directory.Delete(root, true); + } + } + + private static void Write(string root, string relative, string content) + { + var path = Path.Combine(root, relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private static string Fixture(string framework, string file, [CallerFilePath] string thisFile = "") => + File.ReadAllText(Path.Combine(Path.GetDirectoryName(thisFile)!, "Fixtures", "StockBlazor", framework, file + ".txt")); +} diff --git a/docs/CLI_SYNTAX.md b/docs/CLI_SYNTAX.md index f9138c3..70338df 100644 --- a/docs/CLI_SYNTAX.md +++ b/docs/CLI_SYNTAX.md @@ -16,12 +16,12 @@ shellui --version ## Command overview ```text -shellui init [--force] [--style