Changed all icons of AI Studio to Lucide (#1187)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-11 14:20:49 +01:00
1 parent 9a676101f7
commit 7ef26cd5c8
352 files changed
+6261 -1620

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
using System.Reflection;
using AIStudio.Tools;
namespace AIStudio.Tests.Icons;
/// <summary>
/// Checks that every icon of the app is a glyph of the Lucide font, apart from the few that keep an icon of MudBlazor.
/// </summary>
/// <remarks>
/// AppIcons is the one place where the app picks its icons. A constant that takes its value from anywhere
/// but LucideIcons would bring back an icon of another set, which looks out of place next to all the others.
/// </remarks>
[TestFixture]
public sealed class AppIconsTests
{
/// <summary>
/// The icons that keep the SVG of MudBlazor: brand logos keep their own marks, and the numbered steps keep
/// their numbers, because Lucide has no numbered icons.
/// </summary>
private static readonly HashSet<string> KEPT_MUDBLAZOR_ICONS =
[
nameof(AppIcons.GITHUB),
nameof(AppIcons.STEP_1),
nameof(AppIcons.STEP_2),
nameof(AppIcons.STEP_3),
nameof(AppIcons.STEP_4),
nameof(AppIcons.STEP_5),
nameof(AppIcons.STEP_6)
];
[Test]
public void EveryIconIsALucideGlyphOrAKeptMudBlazorIcon()
{
var lucideGlyphs = Constants(typeof(LucideIcons)).Values.ToHashSet(StringComparer.Ordinal);
var foreign = Constants(typeof(AppIcons))
.Where(icon => !KEPT_MUDBLAZOR_ICONS.Contains(icon.Key) && !lucideGlyphs.Contains(icon.Value))
.Select(icon => icon.Key)
.ToList();
Assert.That(foreign, Is.Empty, "These icons are no glyph of the Lucide font.");
}
[Test]
public void EveryKeptMudBlazorIconStaysAnSvg()
{
var icons = Constants(typeof(AppIcons));
using (Assert.EnterMultipleScope())
{
foreach (var icon in KEPT_MUDBLAZOR_ICONS)
Assert.That(icons[icon], Does.StartWith("<"), $"{icon} has to stay an SVG of MudBlazor.");
}
}
private static Dictionary<string, string> Constants(Type type) => type
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(field => field.IsLiteral)
.ToDictionary(field => field.Name, field => (string)field.GetRawConstantValue()!);
}
@@ -0,0 +1,67 @@
using AIStudio.Tools;
using AIStudio.Tools.PluginSystem.Assistants.DataModel;
namespace AIStudio.Tests.Icons;
/// <summary>
/// Checks how the icons an assistant plugin names turn into icons of the app.
/// </summary>
/// <remarks>
/// Assistant plugins name a Lucide icon as "lucide:" followed by its name on lucide.dev, and an icon of MudBlazor
/// by its C# name. Both stay public API: plugins written before Lucide keep their icons.
/// </remarks>
[TestFixture]
public sealed class AssistantPluginIconTests
{
[Test]
public void ALucideNameGivesItsGlyph()
{
using (Assert.EnterMultipleScope())
{
Assert.That(AssistantComponentPropHelper.GetIcon("lucide:house"), Is.EqualTo(LucideIcons.HOUSE));
Assert.That(AssistantComponentPropHelper.GetIcon("lucide:trash"), Is.EqualTo(LucideIcons.TRASH));
Assert.That(AssistantComponentPropHelper.GetIcon("@lucide:house"), Is.EqualTo(LucideIcons.HOUSE));
}
}
[Test]
public void AnAliasOrDeprecatedLucideNameKeepsWorking()
{
using (Assert.EnterMultipleScope())
{
Assert.That(AssistantComponentPropHelper.GetIcon("lucide:home"), Is.EqualTo(LucideIcons.HOUSE));
Assert.That(AssistantComponentPropHelper.GetIcon("lucide:trash-2"), Is.EqualTo(LucideIcons.TRASH_2));
}
}
[Test]
public void AMudBlazorNameKeepsItsIcon()
{
using (Assert.EnterMultipleScope())
{
Assert.That(AssistantComponentPropHelper.GetIcon("Icons.Material.Filled.Home"), Is.EqualTo(MudBlazor.Icons.Material.Filled.Home));
Assert.That(AssistantComponentPropHelper.GetIcon("@Icons.Material.Filled.Home"), Is.EqualTo(MudBlazor.Icons.Material.Filled.Home));
}
}
[TestCase("")]
[TestCase("lucide:")]
[TestCase("lucide:House")]
[TestCase("lucide: house")]
[TestCase("lucide:no-such-icon")]
[TestCase("Lucide:house")]
[TestCase("house")]
[TestCase("Icons.Material.Filled.NoSuchIcon")]
public void AnUnknownNameGivesNoIcon(string name) => Assert.That(AssistantComponentPropHelper.GetIcon(name), Is.Empty);
[Test]
public void TheIconOfASwitchFollowsTheSameNames()
{
using (Assert.EnterMultipleScope())
{
Assert.That(new AssistantSwitch { Icon = "lucide:house" }.GetIcon(), Is.EqualTo(LucideIcons.HOUSE));
Assert.That(new AssistantSwitch { Icon = "Icons.Material.Filled.Home" }.GetIcon(), Is.EqualTo(MudBlazor.Icons.Material.Filled.Home));
Assert.That(new AssistantSwitch { Icon = "lucide:no-such-icon" }.GetIcon(), Is.Empty);
}
}
}
+196
View File
@@ -0,0 +1,196 @@
using System.Collections.Immutable;
using AIStudio.Tests.Models.Generation;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using SourceGeneratedMappings;
namespace AIStudio.Tests.Icons;
/// <summary>
/// Checks that the code points of the Lucide font become icon strings which MudBlazor shows as font icons.
/// </summary>
/// <remarks>
/// The generator runs against code points written for the purpose. Whether it also ran while the app was
/// built, against the file the app ships, is asked in LucideIconsTests.
/// </remarks>
[TestFixture]
public sealed class LucideIconGeneratorTests
{
private const string CODEPOINTS_PATH = "/repository/app/SourceGeneratedMappings/Lucide/codepoints.json";
/// <summary>
/// Three names in the wrong order, two of which share their glyph, as aliases do in the real file.
/// </summary>
private const string THREE_ICONS =
"""
{
"trash-2": 57742,
"house": 57589,
"home": 57589
}
""";
[Test]
public void EveryNameBecomesAConstantHoldingItsGlyph()
{
Generate(THREE_ICONS, CODEPOINTS_PATH, out var updated, out _);
using (Assert.EnterMultipleScope())
{
Assert.That(ConstantOf(updated, "TRASH_2"), Is.EqualTo(FontIcon(0xE18E)));
Assert.That(ConstantOf(updated, "HOUSE"), Is.EqualTo(FontIcon(0xE0F5)));
Assert.That(ConstantOf(updated, "HOME"), Is.EqualTo(FontIcon(0xE0F5)), "An alias is a name of its own.");
}
}
[Test]
public void AnAliasNamesTheIconsItSharesItsGlyphWith()
{
var generated = Generate(THREE_ICONS, CODEPOINTS_PATH, out _, out _);
using (Assert.EnterMultipleScope())
{
Assert.That(generated["LucideIcons.g.cs"], Does.Contain("The Lucide icon \"home\", the same glyph as \"house\"."));
Assert.That(generated["LucideIcons.g.cs"], Does.Contain("The Lucide icon \"trash-2\"."));
}
}
[Test]
public void TheRegistryKnowsEveryNameAliasesIncluded()
{
var generated = Generate(THREE_ICONS, CODEPOINTS_PATH, out _, out _);
using (Assert.EnterMultipleScope())
{
Assert.That(generated["LucideIconRegistry.g.cs"], Does.Contain("[\"trash-2\"] = LucideIcons.TRASH_2,"));
Assert.That(generated["LucideIconRegistry.g.cs"], Does.Contain("[\"house\"] = LucideIcons.HOUSE,"));
Assert.That(generated["LucideIconRegistry.g.cs"], Does.Contain("[\"home\"] = LucideIcons.HOME,"));
}
}
[Test]
public void TheIconsAreWrittenInTheSameOrderEveryTime()
{
//
// The order of the file is not something the generated code may depend on: the same icons have
// to produce the same bytes, or an update of the font shows up as a reshuffled class.
//
var icons = Generate(THREE_ICONS, CODEPOINTS_PATH, out _, out _)["LucideIcons.g.cs"];
using (Assert.EnterMultipleScope())
{
Assert.That(icons.IndexOf(" HOME = ", StringComparison.Ordinal), Is.LessThan(icons.IndexOf(" HOUSE = ", StringComparison.Ordinal)));
Assert.That(icons.IndexOf(" HOUSE = ", StringComparison.Ordinal), Is.LessThan(icons.IndexOf(" TRASH_2 = ", StringComparison.Ordinal)));
}
}
[Test]
public void WhatIsGeneratedCompiles()
{
Generate(THREE_ICONS, CODEPOINTS_PATH, out var updated, out _);
Assert.That(CompilationHarness.ErrorsOf(updated), Is.Empty);
}
[Test]
public void WithoutTheFileNothingIsGeneratedAndTheReasonIsReported()
{
var generated = Generate(null, CODEPOINTS_PATH, out _, out var diagnostics);
using (Assert.EnterMultipleScope())
{
Assert.That(generated, Is.Empty);
Assert.That(diagnostics.Select(diagnostic => diagnostic.Id), Is.EqualTo(new[] { "LUC001" }));
}
}
[Test]
public void AFileOfTheSameNameInAnotherFolderIsNotTaken()
{
var generated = Generate(THREE_ICONS, "/repository/app/SomethingElse/codepoints.json", out _, out var diagnostics);
using (Assert.EnterMultipleScope())
{
Assert.That(generated, Is.Empty);
Assert.That(diagnostics.Select(diagnostic => diagnostic.Id), Is.EqualTo(new[] { "LUC001" }));
}
}
[Test]
public void AFileWithoutIconsIsReported()
{
var generated = Generate("{}", CODEPOINTS_PATH, out _, out var diagnostics);
using (Assert.EnterMultipleScope())
{
Assert.That(generated, Is.Empty);
Assert.That(diagnostics.Select(diagnostic => diagnostic.Id), Is.EqualTo(new[] { "LUC002" }));
}
}
[Test]
public void AnEntryWhichCannotBeAnIconIsReportedRatherThanSkippedQuietly()
{
const string CODEPOINTS =
"""
{
"Not A Name": 57400,
"3d-box": 57401,
"plain-letter": 65,
"trash-2": 57742
}
""";
var generated = Generate(CODEPOINTS, CODEPOINTS_PATH, out var updated, out var diagnostics);
var skipped = diagnostics.Where(diagnostic => diagnostic.Id is "LUC003").Select(diagnostic => diagnostic.GetMessage()).ToList();
using (Assert.EnterMultipleScope())
{
Assert.That(skipped, Has.Count.EqualTo(3));
Assert.That(skipped, Has.Some.Contains("'Not A Name'"));
Assert.That(skipped, Has.Some.Contains("'3d-box'"));
Assert.That(skipped, Has.Some.Contains("'plain-letter'").And.Contains("private use"));
Assert.That(ConstantOf(updated, "TRASH_2"), Is.EqualTo(FontIcon(0xE18E)), "The valid entries still count.");
Assert.That(generated["LucideIcons.g.cs"], Does.Not.Contain("PLAIN_LETTER"));
}
}
private static string FontIcon(int codepoint) => "lucide/" + char.ConvertFromUtf32(codepoint);
//
// Asked of the snippet's own assembly: the app assembly, which the snippet references, has a class of
// the same name, generated from the real file.
//
private static object? ConstantOf(Compilation compilation, string name) => compilation.Assembly
.GetTypeByMetadataName("AIStudio.Tools.LucideIcons")!
.GetMembers(name)
.OfType<IFieldSymbol>()
.Single()
.ConstantValue;
/// <summary>
/// Runs the generator, with the code points as the only additional file, or without any file at all.
/// </summary>
/// <returns>The generated sources by their hint names.</returns>
private static IReadOnlyDictionary<string, string> Generate(string? codepoints, string path, out Compilation updated, out ImmutableArray<Diagnostic> diagnostics)
{
var compilation = CompilationHarness.Compile("namespace Sample;\n\npublic sealed class NothingToDoWithIcons;");
AdditionalText[] additionalTexts = codepoints is null ? [] : [new InMemoryAdditionalText(path, codepoints)];
var driver = CSharpGeneratorDriver.Create([new LucideIconGenerator().AsSourceGenerator()], additionalTexts);
var afterwards = driver.RunGeneratorsAndUpdateCompilation(compilation, out updated, out diagnostics);
return afterwards.GetRunResult().Results.Single().GeneratedSources.ToDictionary(source => source.HintName, source => source.SourceText.ToString());
}
private sealed class InMemoryAdditionalText(string path, string content) : AdditionalText
{
public override string Path => path;
public override SourceText GetText(CancellationToken cancellationToken = default) => SourceText.From(content);
}
}
+109
View File
@@ -0,0 +1,109 @@
using System.Globalization;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using AIStudio.Tools;
namespace AIStudio.Tests.Icons;
/// <summary>
/// Checks the Lucide icons the app was built with against the font it ships.
/// </summary>
/// <remarks>
/// LucideIconGeneratorTests runs the generator by hand. These tests ask whether it also ran while the app
/// was built, against the code points in the repository, and whether the font those code points belong to
/// ships with the app.
/// </remarks>
[TestFixture]
public sealed class LucideIconsTests
{
private const string FONT_ICON_PREFIX = "lucide/";
[Test]
public void TheAppKnowsEveryIconOfTheShippedCodepointsUnderItsName()
{
var codepoints = ReadCodepoints();
var wrong = codepoints
.Where(entry => !LucideIconRegistry.TryGet(entry.Key, out var icon) || icon != FONT_ICON_PREFIX + char.ConvertFromUtf32(entry.Value))
.Select(entry => entry.Key)
.ToList();
using (Assert.EnterMultipleScope())
{
Assert.That(codepoints, Has.Count.GreaterThan(1000), "The code points of the font were not found or not read.");
Assert.That(wrong, Is.Empty, "These icons are missing from the app or point to another glyph.");
Assert.That(IconConstants(), Has.Count.EqualTo(codepoints.Count), "Every name, aliases included, gets a constant of its own.");
}
}
[Test]
public void EveryIconIsAFontIconOfOneGlyph()
{
var malformed = IconConstants()
.Where(icon => !IsFontIconOfOneGlyph(icon.Value))
.Select(icon => icon.Key)
.ToList();
Assert.That(malformed, Is.Empty);
}
[Test]
public void TheRegistryFindsAnIconOnlyByItsExactName()
{
using (Assert.EnterMultipleScope())
{
Assert.That(LucideIconRegistry.TryGet("trash-2", out var trash), Is.True);
Assert.That(trash, Is.EqualTo(LucideIcons.TRASH_2));
Assert.That(LucideIconRegistry.TryGet("home", out var home), Is.True, "An alias is a name of its own.");
Assert.That(home, Is.EqualTo(LucideIcons.HOUSE));
Assert.That(LucideIconRegistry.TryGet("Trash-2", out _), Is.False);
Assert.That(LucideIconRegistry.TryGet("trash 2", out _), Is.False);
Assert.That(LucideIconRegistry.TryGet("lucide:trash-2", out _), Is.False, "The prefix of plugins is not part of the name.");
Assert.That(LucideIconRegistry.TryGet(string.Empty, out _), Is.False);
}
}
[Test]
public void TheFontShipsWithTheAppAlongWithItsLicense()
{
var webRoot = Path.Combine(AppSourceDirectory(), "wwwroot");
var css = File.ReadAllText(Path.Combine(webRoot, "app.css"));
using (Assert.EnterMultipleScope())
{
Assert.That(File.Exists(Path.Combine(webRoot, "fonts", "lucide.woff2")), Is.True);
Assert.That(File.Exists(Path.Combine(webRoot, "fonts", "LICENSE-lucide.txt")), Is.True);
Assert.That(css, Does.Contain("url('fonts/lucide.woff2')"), "app.css has to declare the font.");
Assert.That(css, Does.Contain(".mud-icon-root.lucide"), "app.css has to apply the font to the class every icon string names.");
}
}
private static bool IsFontIconOfOneGlyph(string icon)
{
if (!icon.StartsWith(FONT_ICON_PREFIX, StringComparison.Ordinal))
return false;
var glyph = icon[FONT_ICON_PREFIX.Length..];
var isOneCharacter = glyph.Length is 1 && !char.IsSurrogate(glyph[0]) || glyph.Length is 2 && char.IsSurrogatePair(glyph, 0);
return isOneCharacter && CharUnicodeInfo.GetUnicodeCategory(glyph, 0) is UnicodeCategory.PrivateUse;
}
private static Dictionary<string, string> IconConstants() => typeof(LucideIcons)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(field => field.IsLiteral)
.ToDictionary(field => field.Name, field => (string)field.GetRawConstantValue()!);
private static Dictionary<string, int> ReadCodepoints()
{
var path = Path.Combine(AppSourceDirectory(), "..", "SourceGeneratedMappings", "Lucide", "codepoints.json");
using var document = JsonDocument.Parse(File.ReadAllText(path));
return document.RootElement.EnumerateObject().ToDictionary(property => property.Name, property => property.Value.GetInt32());
}
private static string AppSourceDirectory([CallerFilePath] string sourceFilePath = "") => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "MindWork AI Studio"));
}