Files

365 lines
14 KiB
C#

using System.Xml.Linq;
using AtomUI.Controls.Primitives;
using AtomUI.Theme;
using AtomUI.Theme.Schema;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Shouldly;
using Xunit;
using AvaloniaWindow = Avalonia.Controls.Window;
namespace AtomUI.Desktop.Controls.Tests.Card;
public class CardSemanticPartTests
{
static CardSemanticPartTests()
{
AvaloniaTestApp.EnsureInitialized();
}
[Fact]
public void Registered_Descriptors_Expose_Only_The_Approved_Card_Family_Parts()
{
var registry = Application.Current.ShouldNotBeNull()
.GetThemeManager().ShouldNotBeNull()
.SemanticParts;
registry.TryGetControl(typeof(Desktop.Controls.Card), out var cardDescriptor).ShouldBeTrue();
cardDescriptor.ShouldNotBeNull();
cardDescriptor.Parts.Select(static part => part.Name)
.ShouldBe(["root", "actions", "body", "cover", "extra", "header", "title"]);
AssertRoot(cardDescriptor.Parts.Single(static part => part.Name == "root"), typeof(Desktop.Controls.Card));
AssertPart(
cardDescriptor.Parts.Single(static part => part.Name == "header"),
"semantic-header",
typeof(DashedBorder));
AssertPart(
cardDescriptor.Parts.Single(static part => part.Name == "title"),
"semantic-title",
typeof(ContentPresenter));
AssertPart(
cardDescriptor.Parts.Single(static part => part.Name == "extra"),
"semantic-extra",
typeof(ContentPresenter));
AssertPart(
cardDescriptor.Parts.Single(static part => part.Name == "cover"),
"semantic-cover",
typeof(Border));
AssertPart(
cardDescriptor.Parts.Single(static part => part.Name == "body"),
"semantic-body",
typeof(Border));
AssertPart(
cardDescriptor.Parts.Single(static part => part.Name == "actions"),
"semantic-actions",
typeof(TemplatedControl));
registry.TryGetControl(typeof(Desktop.Controls.CardMetaContent), out var metaDescriptor).ShouldBeTrue();
metaDescriptor.ShouldNotBeNull();
metaDescriptor.Parts.Select(static part => part.Name)
.ShouldBe(["root", "avatar", "description", "section", "title"]);
AssertRoot(
metaDescriptor.Parts.Single(static part => part.Name == "root"),
typeof(Desktop.Controls.CardMetaContent));
AssertPart(
metaDescriptor.Parts.Single(static part => part.Name == "section"),
"semantic-section",
typeof(Control));
AssertPart(
metaDescriptor.Parts.Single(static part => part.Name == "avatar"),
"semantic-avatar",
typeof(ContentPresenter));
AssertPart(
metaDescriptor.Parts.Single(static part => part.Name == "title"),
"semantic-title",
typeof(ContentPresenter));
AssertPart(
metaDescriptor.Parts.Single(static part => part.Name == "description"),
"semantic-description",
typeof(ContentPresenter));
registry.TryGetControl(typeof(Desktop.Controls.CardActionButton), out _).ShouldBeFalse();
registry.TryGetControl(typeof(Desktop.Controls.CardGridContent), out _).ShouldBeFalse();
registry.TryGetControl(typeof(Desktop.Controls.CardGridItem), out _).ShouldBeFalse();
registry.TryGetControl(typeof(Desktop.Controls.CardTabsContent), out _).ShouldBeFalse();
}
[Theory]
[InlineData(
"src/AtomUI.Desktop.Controls/Card/Themes/CardTheme.axaml",
"semantic-header:PixelAlignedBorder,semantic-title:ContentPresenter,semantic-extra:ContentPresenter,semantic-cover:Border,semantic-body:Border,semantic-actions:CardActionPanel")]
[InlineData(
"src/AtomUI.Desktop.Controls/Card/Themes/CardMetaContentTheme.axaml",
"semantic-section:DockPanel,semantic-avatar:ContentPresenter,semantic-title:ContentPresenter,semantic-description:ContentPresenter")]
public void Built_In_Templates_Implement_The_Approved_Static_Marker_Contract(
string relativePath,
string expectedMarkers)
{
var document = XDocument.Load(GetRepoFile(relativePath), LoadOptions.SetLineInfo);
var literalSemanticMarkers = document.Descendants()
.Attributes()
.Where(static attribute =>
attribute.Name.LocalName == "Classes" &&
attribute.Value.Split(
(char[]?)null,
StringSplitOptions.RemoveEmptyEntries)
.Any(static value => value.StartsWith(
"semantic-",
StringComparison.Ordinal)))
.ToArray();
var classPropertyMarkers = document.Descendants()
.SelectMany(static element => element.Attributes()
.Where(static attribute => attribute.Name.LocalName.StartsWith(
"Classes.semantic-",
StringComparison.Ordinal))
.Select(attribute => (Element: element, Attribute: attribute)))
.ToArray();
literalSemanticMarkers.ShouldBeEmpty();
classPropertyMarkers.ShouldAllBe(static marker =>
string.Equals(marker.Attribute.Value, "true", StringComparison.OrdinalIgnoreCase));
classPropertyMarkers.Select(static marker =>
$"{marker.Attribute.Name.LocalName["Classes.".Length..]}:{marker.Element.Name.LocalName}")
.OrderBy(static value => value, StringComparer.Ordinal)
.ShouldBe(expectedMarkers.Split(',').OrderBy(static value => value, StringComparer.Ordinal));
classPropertyMarkers.ShouldNotContain(static marker =>
marker.Attribute.Name.LocalName == "Classes.semantic-root");
}
[Theory]
[InlineData("default")]
[InlineData("headerless")]
[InlineData("loading")]
[InlineData("meta")]
[InlineData("grid")]
[InlineData("tabs")]
public void Card_Template_Preserves_One_Owner_Scoped_Marker_Per_Declared_Part(string scenario)
{
var card = CreateCardScenario(scenario);
ShowInWindow(card, () =>
{
var markers = GetOwnerMarkers(card);
markers.SelectMany(static control => control.Classes)
.Where(static className => className.StartsWith("semantic-", StringComparison.Ordinal))
.OrderBy(static className => className, StringComparer.Ordinal)
.ShouldBe([
"semantic-actions",
"semantic-body",
"semantic-cover",
"semantic-extra",
"semantic-header",
"semantic-title"
]);
});
}
[Fact]
public void Actions_Collection_Changes_Do_Not_Recreate_The_Semantic_Actions_Part()
{
var card = new Desktop.Controls.Card
{
Header = "Actions",
Content = "Body"
};
ShowInWindow(card, () =>
{
var actionsPart = FindOwnerMarker(card, "semantic-actions");
card.Actions.Add(new Desktop.Controls.CardActionButton());
Dispatcher.UIThread.RunJobs();
FindOwnerMarker(card, "semantic-actions").ShouldBeSameAs(actionsPart);
card.Actions.Clear();
Dispatcher.UIThread.RunJobs();
FindOwnerMarker(card, "semantic-actions").ShouldBeSameAs(actionsPart);
});
}
[Fact]
public void Card_And_Nested_Meta_Title_Selectors_Remain_Isolated_By_Their_Owner()
{
var meta = new Desktop.Controls.CardMetaContent
{
Header = "Meta title",
Content = "Meta description"
};
meta.Classes.Add("meta-owner");
var card = new Desktop.Controls.Card
{
Header = "Card title",
Content = meta
};
card.Classes.Add("card-owner");
card.Styles.Add(new Style(selector =>
selector.OfType<Desktop.Controls.Card>().Class("card-owner").Template().Class("semantic-title"))
{
Setters =
{
new Setter(Control.TagProperty, "card-title")
}
});
meta.Styles.Add(new Style(selector =>
selector.OfType<Desktop.Controls.CardMetaContent>().Class("meta-owner").Template().Class("semantic-title"))
{
Setters =
{
new Setter(Control.TagProperty, "meta-title")
}
});
ShowInWindow(card, () =>
{
FindOwnerMarker(card, "semantic-title").Tag.ShouldBe("card-title");
FindOwnerMarker(meta, "semantic-title").Tag.ShouldBe("meta-title");
GetOwnerMarkers(card).Length.ShouldBe(6);
GetOwnerMarkers(meta).Length.ShouldBe(4);
});
}
private static Desktop.Controls.Card CreateCardScenario(string scenario)
{
var card = new Desktop.Controls.Card
{
Header = "Title",
Extra = "Extra",
Cover = new Border { Height = 32 },
Content = "Body"
};
card.Actions.Add(new Desktop.Controls.CardActionButton());
switch (scenario)
{
case "default":
break;
case "headerless":
card.Header = null;
card.Extra = null;
card.Cover = null;
card.Actions.Clear();
break;
case "loading":
card.IsLoading = true;
break;
case "meta":
card.Content = new Desktop.Controls.CardMetaContent
{
Header = "Meta",
Content = "Description"
};
break;
case "grid":
card.Content = new Desktop.Controls.CardGridContent
{
Items =
{
new Desktop.Controls.CardGridItem { Content = "Grid" }
}
};
break;
case "tabs":
card.Content = new Desktop.Controls.CardTabsContent
{
Items =
{
new Desktop.Controls.TabItem { Header = "Tab", Content = "Tab content" }
}
};
break;
default:
throw new ArgumentOutOfRangeException(nameof(scenario), scenario, null);
}
return card;
}
private static Control[] GetOwnerMarkers(TemplatedControl owner)
{
return owner.GetVisualDescendants()
.OfType<Control>()
.Where(control => ReferenceEquals(control.TemplatedParent, owner))
.Where(static control => control.Classes.Any(
className => className.StartsWith("semantic-", StringComparison.Ordinal)))
.ToArray();
}
private static Control FindOwnerMarker(TemplatedControl owner, string marker)
{
return GetOwnerMarkers(owner).Single(control => control.Classes.Contains(marker));
}
private static void ShowInWindow(Control content, Action assertion)
{
var window = new AvaloniaWindow
{
Width = 640,
Height = 480,
Content = content
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
assertion();
}
finally
{
window.Close();
}
}
private static void AssertRoot(SemanticPartDescriptor part, Type ownerType)
{
part.Path.ShouldBe("root");
part.SelectorClass.ShouldBeNull();
part.ContractType.ShouldBe(ownerType);
part.Cardinality.ShouldBe(SemanticPartCardinality.Single);
part.Customization.ShouldBe(SemanticPartCustomization.Root);
part.CrossVisualRoot.ShouldBeFalse();
part.RuntimeCreated.ShouldBeFalse();
}
private static void AssertPart(
SemanticPartDescriptor part,
string selectorClass,
Type contractType)
{
part.Path.ShouldBe(part.Name);
part.SelectorClass.ShouldBe(selectorClass);
part.ContractType.ShouldBe(contractType);
part.Cardinality.ShouldBe(SemanticPartCardinality.Single);
part.Customization.ShouldBe(SemanticPartCustomization.Selector);
part.CrossVisualRoot.ShouldBeFalse();
part.RuntimeCreated.ShouldBeFalse();
part.Since.ShouldBe("6.2.0");
}
private static string GetRepoFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}