Add ::: team block rendering board members as person cards with initials avatars

This commit is contained in:
tleininger committed 2026-10-01 15:55:28 +02:00
1 parent efb6fa9109
commit a5a68ed122
8 files changed
+815 -10

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
using Elternbeirat.Web.Shared;
using Markdig.Syntax;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the person cards of the "::: team" block: initials, avatar colour,
/// reading the list-item convention and the rendered card. Pure logic, no PocketBase
/// needed.
/// </summary>
public class TeamMemberTests
{
[Theory]
[InlineData("Jenny Reger-Stilgenbauer", "JR")] // a double name is one word
[InlineData("Özlem Ünal", "ÖÜ")] // umlauts kept, not folded to O/U
[InlineData("ömer şahin", "ÖŞ")] // lower case is raised
[InlineData("Isabell", "I")] // a single word gives one letter
[InlineData("Anna Maria Müller", "AM")] // first and last word only
[InlineData(" Maik Palm ", "MP")] // extra whitespace ignored
[InlineData("", "")]
[InlineData("123", "")] // no letter, no initials
public void Initials_are_first_and_last_word(string name, string expected) =>
TeamMember.InitialsOf(name).ShouldBe(expected);
[Fact]
public void Color_is_stable_and_in_range()
{
// Pinned values: the colour must not change between restarts or releases, so a
// change to the hash shows up here instead of recolouring the board.
TeamMember.ColorOf("Maik Palm").ShouldBe(TeamMember.ColorOf(" Maik Palm "));
TeamMember.ColorOf("Maik Palm").ShouldBe(4);
TeamMember.ColorOf("Gentiana Erol").ShouldBe(5);
new[] { "Maik Palm", "Gentiana Erol", "Raul Chira", "Marion Leininger", "Isabell Vesper", "" }
.ShouldAllBe(name => TeamMember.ColorOf(name) >= 1 && TeamMember.ColorOf(name) <= TeamMember.ColorCount);
}
[Fact]
public void Item_with_role_duties_and_note_is_read_in_full()
{
var (member, note) = Read("- **Maik Palm** (Vorsitzender) \n *Schulkonferenz, Mensarat , Homepage* \n Mitglied im Schulbeirat.");
member.Name.ShouldBe("Maik Palm");
member.Role.ShouldBe("Vorsitzender");
member.Duties.ShouldBe(["Schulkonferenz", "Mensarat", "Homepage"]);
Markdown.PlainText(note).ShouldBe("Mitglied im Schulbeirat.");
}
[Fact]
public void Item_without_role_still_reads_the_duties()
{
var (member, note) = Read("- **Jenny Reger-Stilgenbauer** \n *Schulkonferenz, Schaukasten*");
member.Role.ShouldBeNull();
member.Duties.ShouldBe(["Schulkonferenz", "Schaukasten"]);
note.ShouldBeEmpty();
}
[Fact]
public void Item_with_only_a_name_has_no_role_and_no_duties()
{
var (member, note) = Read("- **Isabell Vesper**");
member.Name.ShouldBe("Isabell Vesper");
member.Role.ShouldBeNull();
member.Duties.ShouldBeEmpty();
note.ShouldBeEmpty();
}
[Fact]
public void Soft_line_breaks_count_like_hard_ones()
{
// Editors forget the two trailing spaces; the card must not depend on them.
var (member, _) = Read("- **Raul Chira** (2. Stellvertreter)\n *Mensarat*");
member.Role.ShouldBe("2. Stellvertreter");
member.Duties.ShouldBe(["Mensarat"]);
}
[Fact]
public void Second_line_that_is_not_only_italic_becomes_the_note()
{
var (member, note) = Read("- **Rebecca Goldmann** \n Neu im Team, *herzlich willkommen*.");
member.Duties.ShouldBeEmpty();
Markdown.PlainText(note).ShouldBe("Neu im Team, herzlich willkommen.");
}
[Theory]
[InlineData("- Michael Weber, Beisitz")] // no bold at all
[InlineData("- Beisitz: **Michael Weber**")] // bold, but not at the start
[InlineData("- *Michael Weber*")] // italic, not bold
public void Item_without_convention_is_not_read(string markdown) =>
TeamMember.Read(FirstParagraph(markdown)).ShouldBeNull();
[Fact]
public void Team_block_renders_person_cards()
{
var html = Markdown.ToHtml(
"::: team\n- **Maik Palm** (Vorsitzender) \n *Schulkonferenz, Mensarat* \n Siehe [Kontakt](/contact).\n:::");
html.ShouldContain("<div class=\"team\">");
html.ShouldContain($"<li class=\"{TeamMember.CardClass}\">");
html.ShouldContain("<span class=\"team-avatar team-avatar-4\" aria-hidden=\"true\">MP</span>");
html.ShouldContain("<strong class=\"team-name\">Maik Palm</strong>");
html.ShouldContain("<span class=\"team-role\">Vorsitzender</span>");
html.ShouldContain("<span class=\"team-duty\">Schulkonferenz</span>");
html.ShouldContain("<span class=\"team-duty\">Mensarat</span>");
html.ShouldContain("<span class=\"team-note\">Siehe <a href=\"/contact\">Kontakt</a>.</span>"); // links survive
html.ShouldNotContain("(Vorsitzender)");
}
[Fact]
public void Item_without_convention_renders_as_plain_card_with_its_text()
{
var html = Markdown.ToHtml("::: team\n- Michael Weber, Beisitz\n:::");
html.ShouldContain($"<li class=\"{TeamMember.CardClass} {TeamMember.PlainCardClass}\">Michael Weber, Beisitz</li>");
html.ShouldNotContain("team-avatar");
}
[Fact]
public void Editor_text_in_a_card_is_escaped()
{
var html = Markdown.ToHtml("::: team\n- **<b>Name</b>** (Rolle \"x\")\n:::");
html.ShouldNotContain("<b>");
html.ShouldContain("&lt;b&gt;Name&lt;/b&gt;");
}
[Theory]
[InlineData("::: team gross")]
[InlineData("::: team groß")]
[InlineData("::: team GROSS")]
public void Large_modifier_adds_its_class(string opening) =>
Markdown.ToHtml($"{opening}\n- **Maik Palm**\n:::").ShouldContain("<div class=\"team gross\">");
[Fact]
public void Mistyped_modifier_keeps_the_grid()
{
// A typo in the modifier costs the larger size, not the cards.
var html = Markdown.ToHtml("::: team grss\n- **Maik Palm**\n:::");
html.ShouldContain("<div class=\"team\">");
html.ShouldContain("team-card");
}
[Fact]
public void Lists_outside_a_team_block_are_untouched()
{
var html = Markdown.ToHtml("- **Maik Palm** (Vorsitzender)");
html.ShouldBe("<ul>\n<li><strong>Maik Palm</strong> (Vorsitzender)</li>\n</ul>\n");
}
private static (TeamMember Member, Markdig.Syntax.Inlines.Inline[] Note) Read(string markdown) =>
TeamMember.Read(FirstParagraph(markdown)) ?? throw new ShouldAssertException("The item was not read as a person.");
private static ParagraphBlock FirstParagraph(string markdown) =>
Markdown.Parse(markdown).Descendants<ParagraphBlock>().First();
}