diff --git a/NET10/BlazorInteractiveServer/wwwroot/input.css b/NET10/BlazorInteractiveServer/wwwroot/input.css index 63870d7..135d5ae 100644 --- a/NET10/BlazorInteractiveServer/wwwroot/input.css +++ b/NET10/BlazorInteractiveServer/wwwroot/input.css @@ -3,6 +3,7 @@ @custom-variant dark (&:is(.dark *)); :root { + color-scheme: light; --background: oklch(0.9900 0 0); --foreground: oklch(0 0 0); --card: oklch(1 0 0); @@ -58,6 +59,7 @@ } .dark { + color-scheme: dark; --background: oklch(0 0 0); --foreground: oklch(1 0 0); --card: oklch(0.1400 0 0); diff --git a/ShellUI.Tests/ColorSchemeTests.cs b/ShellUI.Tests/ColorSchemeTests.cs new file mode 100644 index 0000000..5a72c61 --- /dev/null +++ b/ShellUI.Tests/ColorSchemeTests.cs @@ -0,0 +1,42 @@ +using System.IO; +using ShellUI.CLI.Services; +using ShellUI.Templates; +using Xunit; + +namespace ShellUI.Tests; + +public class ColorSchemeTests +{ + [Fact] + public void InitTheme_SetsColorSchemeForBothThemes() + { + Assert.Contains("color-scheme: light;", CssTemplates.InputCss); + Assert.Contains("color-scheme: dark;", CssTemplates.InputCss); + } + + [Fact] + public void AppliedTheme_SetsColorSchemeForBothThemes() + { + var css = ThemeService.BuildThemeCss(new TweakcnTheme + { + Name = "t", + ThemeVars = new(), + LightVars = new() { ["background"] = "#fff" }, + DarkVars = new() { ["background"] = "#000" }, + }); + + Assert.Contains(":root {\n color-scheme: light;", css.Replace("\r\n", "\n")); + Assert.Contains(".dark {\n color-scheme: dark;", css.Replace("\r\n", "\n")); + } + + [Fact] + public void PackageTheme_SetsColorSchemeForBothThemes() + { + var root = new DirectoryInfo(System.AppContext.BaseDirectory); + while (root != null && !File.Exists(Path.Combine(root.FullName, "ShellUI.slnx"))) root = root.Parent; + var css = File.ReadAllText(Path.Combine(root!.FullName, "src", "ShellUI.Components", "wwwroot", "shellui-theme.css")); + + Assert.Contains("color-scheme: light;", css); + Assert.Contains("color-scheme: dark;", css); + } +} diff --git a/ShellUI.Tests/PackageBehaviorTests.cs b/ShellUI.Tests/PackageBehaviorTests.cs index f1931dc..f7427d6 100644 --- a/ShellUI.Tests/PackageBehaviorTests.cs +++ b/ShellUI.Tests/PackageBehaviorTests.cs @@ -62,4 +62,14 @@ public void Dropdown_ClosesOnEscapeButNotOnOtherKeys() dropdown.Find("div.relative").KeyDown(new KeyboardEventArgs { Key = "Escape" }); Assert.Equal(new[] { false }, changes); } + + [Fact] + public void TimePicker_ShowsASetValue() + { + using var ctx = new BunitContext(); + + var picker = ctx.Render(p => p.Add(c => c.Value, new TimeSpan(9, 5, 0))); + + Assert.Contains("09:05", picker.Markup); + } } diff --git a/ShellUI.Tests/PackageRenderTests.cs b/ShellUI.Tests/PackageRenderTests.cs index 260d626..099c0c8 100644 --- a/ShellUI.Tests/PackageRenderTests.cs +++ b/ShellUI.Tests/PackageRenderTests.cs @@ -57,6 +57,78 @@ public void EveryPackageComponent_RendersWithoutParameterBindingErrors() Assert.True(offenders.Count == 0, "Package components that fail parameter binding:\n " + string.Join("\n ", offenders)); } + // A component that renders empty but throws once it has a value fails here (the TimePicker "HH:mm" bug). + [Fact] + public void ValueBearingComponents_RenderWithASampleValue() + { + var types = typeof(Shell).Assembly.GetExportedTypes() + .Where(t => typeof(IComponent).IsAssignableFrom(t) && !t.IsAbstract && t.Namespace == "ShellUI.Components") + .Select(t => t.IsGenericTypeDefinition ? Close(t) : t) + .OfType() + .ToList(); + + var tried = 0; + var offenders = new List(); + foreach (var type in types) + { + var values = SampleValues(type); + if (values.Count == 0 || !Renders(type, new Dictionary(), out _)) continue; + + tried++; + if (!Renders(type, values, out var error)) + offenders.Add($"{type.Name} ({string.Join(", ", values.Keys)}): {error}"); + } + + Assert.True(tried >= 10, $"Only {tried} components had a value to set; the sample types may need extending."); + Assert.True(offenders.Count == 0, "Package components that throw once a value is set:\n " + string.Join("\n ", offenders)); + } + + private static readonly System.Text.RegularExpressions.Regex ValueLike = new("Value|Date|Time|Selected"); + + private static Dictionary SampleValues(Type type) + { + var values = new Dictionary(); + foreach (var p in type.GetProperties()) + { + if (!p.IsDefined(typeof(ParameterAttribute), true) || !p.CanWrite || !ValueLike.IsMatch(p.Name)) continue; + var t = Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType; + object? sample = + t == typeof(string) ? "b" : + t == typeof(DateTime) ? new DateTime(2026, 3, 14) : + t == typeof(DateOnly) ? new DateOnly(2026, 3, 14) : + t == typeof(TimeSpan) ? new TimeSpan(9, 5, 0) : + t == typeof(TimeOnly) ? new TimeOnly(9, 5) : + t == typeof(int) ? 42 : + t == typeof(double) ? 42.5 : + t == typeof(decimal) ? 42.5m : + t == typeof(bool) ? true : + t == typeof(List) ? new List { "a", "b" } : + t == typeof(IEnumerable) ? new List { "a", "b" } : + null; + if (sample != null) values[p.Name] = sample; + } + return values; + } + + private static bool Renders(Type type, Dictionary parameters, out string error) + { + using var ctx = new BunitContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + ctx.Services.AddShellUISonner(); + ctx.Services.AddScoped(); + try + { + ctx.Render(p => p.Add(c => c.Type, type).Add(c => c.Parameters, parameters)); + error = ""; + return true; + } + catch (Exception ex) + { + error = Flatten(ex); + return false; + } + } + private static Type? Close(Type definition) { foreach (var candidate in new[] { typeof(Row), typeof(string), typeof(int) }) diff --git a/ShellUI.Tests/UpdateTests.cs b/ShellUI.Tests/UpdateTests.cs new file mode 100644 index 0000000..e898a45 --- /dev/null +++ b/ShellUI.Tests/UpdateTests.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.IO; +using ShellUI.CLI.Services; +using ShellUI.Core.Models; +using ShellUI.Templates; +using Xunit; + +namespace ShellUI.Tests; + +public class UpdateDependencyTests +{ + [Fact] + public void MissingDependencies_FindsDependenciesAddedSinceInstall() + { + // A 0.3 install of tabs and breadcrumb, before tab-models and the breadcrumb parts existed. + var installed = new List { "tabs", "tabs-list", "tabs-trigger", "tabs-content", "breadcrumb", "breadcrumb-item" }; + + var missing = ComponentManager.MissingDependencies(new[] { "tabs", "breadcrumb" }, installed); + + Assert.Contains("tab-models", missing); + Assert.Contains("breadcrumb-list", missing); + Assert.Contains("breadcrumb-ellipsis", missing); + Assert.DoesNotContain("tabs-list", missing); + Assert.Equal(missing.Count, new HashSet(missing).Count); + } + + [Fact] + public void MissingDependencies_IsEmptyWhenEverythingIsInstalled() + { + var tabs = ComponentRegistry.GetMetadata("tabs")!; + var installed = new List(tabs.Dependencies) { "tabs" }; + + Assert.Empty(ComponentManager.MissingDependencies(new[] { "tabs" }, installed)); + } +} + +public class UpdateLocalChangesTests +{ + private const string Template = "
@ChildContent
\n"; + + private static InstalledComponent Entry(string name, string? hash) => new() { Name = name, Version = "0.4.0", Hash = hash }; + + [Fact] + public void FileMatchingTheRecordedHash_HasNoLocalChanges() + { + var entry = Entry("button", ComponentInstaller.ContentHash(Template)); + + Assert.False(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template, Template)); + } + + [Fact] + public void EditedFile_HasLocalChanges() + { + var entry = Entry("button", ComponentInstaller.ContentHash(Template)); + + Assert.True(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template + "", Template)); + } + + [Fact] + public void LineEndingChangesAreNotLocalChanges() + { + var entry = Entry("button", ComponentInstaller.ContentHash(Template)); + + Assert.False(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template.Replace("\n", "\r\n"), Template)); + } + + [Theory] + [InlineData("app-sidebar")] + [InlineData("dashboard-02")] + [InlineData("auth-01")] + public void OlderInstall_EditableBlockThatDiffersFromTheTemplate_HasLocalChanges(string name) + { + Assert.True(ComponentManager.HasLocalChanges(Entry(name, null), ComponentRegistry.GetMetadata(name)!, "edited", Template)); + } + + [Fact] + public void OlderInstall_EditableBlockMatchingTheTemplate_HasNoLocalChanges() + { + Assert.False(ComponentManager.HasLocalChanges(Entry("app-sidebar", null), ComponentRegistry.GetMetadata("app-sidebar")!, Template, Template)); + } + + [Fact] + public void OlderInstall_OrdinaryComponentIsUpdatedAsBefore() + { + Assert.False(ComponentManager.HasLocalChanges(Entry("button", null), ComponentRegistry.GetMetadata("button")!, "edited", Template)); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 66b0800..5ea66e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -80,7 +80,7 @@ The implemented command tree is: | `shellui add ` | Install one or more direct targets and recursively install their registry dependencies. | | `shellui list` | List direct targets, with installed/available filtering. | | `shellui remove ` | Remove selected installed source files and update `shellui.json`. | -| `shellui update [components...]` | Rewrite selected installed source files, or all installed components with `--all`. | +| `shellui update [components...]` | Rewrite selected installed source files, or all installed components with `--all`; skips files changed since install unless `--force`, and installs new dependencies. | | `shellui theme init ` | Initialize a project and apply a fetched theme in one operation. | | `shellui theme apply ` | Apply a theme to `wwwroot/input.css` or emit a standalone override. | | `shellui theme update` | Re-fetch the theme recorded in `shellui.theme.lock`. | diff --git a/docs/CLI_SYNTAX.md b/docs/CLI_SYNTAX.md index 67a2e79..1f6e1a1 100644 --- a/docs/CLI_SYNTAX.md +++ b/docs/CLI_SYNTAX.md @@ -20,7 +20,7 @@ shellui init [--force] [--style