Add home hero with intro and buttons from the body, next-event card and page tiles

This commit is contained in:
tleininger committed 2026-10-01 14:05:25 +02:00
1 parent b737f57ef7
commit e51afeb34b
23 files changed
+1262 -65

No files matched your search

@@ -0,0 +1,13 @@
namespace Elternbeirat.Web.Tests;
/// <summary>
/// A clock that always shows the same moment, so tests can decide what "today" is
/// (e.g. before or after the fixture's events). Hand-written instead of
/// Microsoft.Extensions.TimeProvider.Testing: overriding one method does not justify
/// a new package.
/// </summary>
/// <param name="now">The moment the clock shows.</param>
internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
+67
View File
@@ -0,0 +1,67 @@
using Elternbeirat.Web.Features.Home;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for taking the home page's body apart into heading, intro and
/// buttons. Pure logic, no PocketBase needed.
/// </summary>
public class HomeIntroTests
{
[Fact]
public void Body_splits_into_title_intro_and_buttons()
{
var intro = HomeIntro.Parse(
"# Willkommen\n\nSchön, dass Sie da sind.\n\n[Kommende Termine](/events)\n[Kontakt aufnehmen](/contact)");
intro.Title.ShouldBe("Willkommen");
intro.IntroHtml.ShouldBe("<p>Schön, dass Sie da sind.</p>\n");
intro.Links.ShouldBe(
[
new HomeLink("/events", "Kommende Termine"),
new HomeLink("/contact", "Kontakt aufnehmen"),
]);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_body_gives_the_fallback(string? markdown) =>
HomeIntro.Parse(markdown).ShouldBe(HomeIntro.Fallback);
[Fact]
public void Body_without_heading_keeps_the_fallback_title_and_all_text()
{
var intro = HomeIntro.Parse("Nur ein Absatz.");
intro.Title.ShouldBe(HomeIntro.FallbackTitle);
intro.IntroHtml.ShouldContain("Nur ein Absatz.");
intro.Links.ShouldBeEmpty();
}
[Fact]
public void Last_paragraph_with_other_text_stays_intro()
{
// "oder" would get lost in a button row, so the paragraph stays text.
var intro = HomeIntro.Parse("# Hallo\n\n[Termine](/events) oder [Kontakt](/contact)");
intro.Links.ShouldBeEmpty();
intro.IntroHtml.ShouldContain("href=\"/events\"");
}
[Fact]
public void Link_paragraph_that_is_not_last_stays_intro()
{
var intro = HomeIntro.Parse("# Hallo\n\n[Termine](/events)\n\nSchlusssatz.");
intro.Links.ShouldBeEmpty();
intro.IntroHtml.ShouldContain("Schlusssatz.");
}
[Fact]
public void Heading_markup_is_reduced_to_its_text()
{
HomeIntro.Parse("# Willkommen beim **Elternbeirat**").Title.ShouldBe("Willkommen beim Elternbeirat");
}
}
+45
View File
@@ -78,4 +78,49 @@ public class MarkdownTests
html.ShouldContain($"<li class=\"{Markdown.LoneLinkClass}\"><a href=\"/dokumente/antrag.pdf\">");
html.ShouldContain("<li>Text mit");
}
[Fact]
public void First_sentence_skips_the_heading_and_drops_the_markdown()
{
var sentence = Markdown.FirstSentence(
"# Vorstand\n\nWir sind **sieben** [Eltern](/board). Der Rest folgt.");
sentence.ShouldBe("Wir sind sieben Eltern.");
}
[Theory]
[InlineData("Wir helfen z. B. bei Anträgen. Mehr dazu unten.", "Wir helfen z. B. bei Anträgen.")]
[InlineData("Gewählt am 13. November 2025. Danach mehr.", "Gewählt am 13. November 2025.")]
[InlineData("Haben Sie Fragen? Schreiben Sie uns.", "Haben Sie Fragen?")]
[InlineData("Ohne Satzzeichen am Ende", "Ohne Satzzeichen am Ende")]
public void First_sentence_ends_at_a_real_sentence_end(string markdown, string expected) =>
Markdown.FirstSentence(markdown).ShouldBe(expected);
[Fact]
public void First_sentence_joins_soft_line_breaks_with_a_space()
{
// Editors wrap long lines; the wrap must not glue two words together.
Markdown.FirstSentence("Erste Zeile\nzweite Zeile.").ShouldBe("Erste Zeile zweite Zeile.");
}
[Fact]
public void First_sentence_longer_than_the_limit_is_cut_between_words()
{
var sentence = Markdown.FirstSentence("Eins zwei drei vier fünf.", maxLength: 12);
sentence.ShouldBe("Eins zwei…");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("# Nur eine Überschrift")]
[InlineData("- nur\n- eine Liste")]
[InlineData("::: hinweis\nText im Baustein.\n:::")]
public void First_sentence_is_empty_without_a_top_level_paragraph(string? markdown) =>
Markdown.FirstSentence(markdown).ShouldBe("");
[Fact]
public void First_sentence_rejects_a_limit_below_one() =>
Should.Throw<ArgumentOutOfRangeException>(() => Markdown.FirstSentence("Text.", maxLength: 0));
}
+5 -1
View File
@@ -226,7 +226,11 @@ public sealed class PocketBaseFixture : IAsyncLifetime
// links home too, but the nav entry is the single source of the home link.
await CreateRecordAsync(http, "pages", new
{
title = "Start", body = "# Willkommen", location = "header",
// The body drives the home hero: "# heading" -> h1, the paragraph -> intro,
// the last paragraph of only links -> the button row.
title = "Start",
body = "# Willkommen\n\nSchön, dass Sie da sind.\n\n[Kommende Termine](/events)\n[Kontakt aufnehmen](/contact)",
location = "header",
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
});
await CreateRecordAsync(http, "pages", new
+76 -2
View File
@@ -1,6 +1,8 @@
using System.Net;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Elternbeirat.Web.Tests;
@@ -29,10 +31,25 @@ public sealed partial class RouteSmokeTests : IDisposable
// the value from appsettings. UseSetting (not ConfigureAppConfiguration)
// because it wins over appsettings.Development.json, which otherwise pins the
// client to localhost:8090 while the test container uses a random port.
_factory = _baseFactory.WithWebHostBuilder(builder =>
builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString()));
_factory = WithClock(
_baseFactory.WithWebHostBuilder(builder =>
builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString())),
BeforeFixtureEvents);
}
/// <summary>
/// "Now" for the default factory: a week before the fixture's first event, so
/// "upcoming" stays true no matter when the tests run.
/// </summary>
private static readonly DateTimeOffset BeforeFixtureEvents = new(2026, 10, 1, 10, 0, 0, TimeSpan.Zero);
/// <summary>
/// Wraps <paramref name="factory"/> so the app's clock shows <paramref name="now"/>.
/// </summary>
private static WebApplicationFactory<Program> WithClock(WebApplicationFactory<Program> factory, DateTimeOffset now) =>
factory.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services => services.AddSingleton<TimeProvider>(new FixedTimeProvider(now))));
public void Dispose()
{
_factory.Dispose();
@@ -261,6 +278,56 @@ public sealed partial class RouteSmokeTests : IDisposable
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
}
[Fact]
public async Task Home_hero_takes_title_and_buttons_from_the_body()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldContain(">Willkommen</h1>");
html.ShouldNotContain(">Elternbeirat der IGMH</h1>"); // the fallback title
html.ShouldContain("""<a class="btn btn-primary" href="/events">""");
html.ShouldContain("""<a class="btn btn-secondary" href="/contact">""");
}
[Fact]
public async Task Home_tiles_are_exactly_the_public_header_pages()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
var tiles = HomeTiles().Match(html);
tiles.Success.ShouldBeTrue();
Href().Matches(tiles.Value).Select(match => match.Groups[1].Value)
.ShouldBe(["/board", "/patrons", "/faqs", "/downloads", "/posts", "/events"]);
}
[Fact]
public async Task Home_shows_the_next_event_when_one_is_upcoming()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldContain("Nächster Termin");
html.ShouldContain("Elternbeiratssitzung");
}
[Fact]
public async Task Home_hides_the_next_event_when_none_is_upcoming()
{
// After the fixture's last event nothing is upcoming; the card must vanish
// instead of showing an empty frame.
await using var factory = WithClock(_factory, new DateTimeOffset(2027, 1, 1, 10, 0, 0, TimeSpan.Zero));
var client = factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldNotContain("Nächster Termin");
}
/// <summary>
/// Matches a resource reference that points at an absolute URL, with or without
/// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the
@@ -282,4 +349,11 @@ public sealed partial class RouteSmokeTests : IDisposable
/// </summary>
[GeneratedRegex(@"\bhref=""([^""]*)""")]
private static partial Regex Href();
/// <summary>
/// Matches the home page's tile navigation, from its opening tag to the closing
/// one. It nests no other <c>&lt;nav&gt;</c>.
/// </summary>
[GeneratedRegex("""<nav class="home-tiles".*?</nav>""", RegexOptions.Singleline)]
private static partial Regex HomeTiles();
}
+37
View File
@@ -0,0 +1,37 @@
using Elternbeirat.Web.Features.Home;
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the slug-to-icon mapping of the home page tiles. Pure logic, no
/// PocketBase needed.
/// </summary>
public class TileIconsTests
{
[Theory]
[InlineData("board", "users")]
[InlineData("faqs", "help-circle")]
[InlineData("events", "calendar")]
public void Known_slug_gets_its_icon(string slug, string icon) =>
TileIcons.For(slug).ShouldBe(icon);
[Theory]
[InlineData("schulweg")] // a page an editor just created
[InlineData("Board")] // slugs are lowercase; anything else is unknown
[InlineData("")]
public void Unknown_slug_gets_the_default_icon(string slug) =>
TileIcons.For(slug).ShouldBe(TileIcons.Default);
[Theory]
[InlineData("board")]
[InlineData("patrons")]
[InlineData("faqs")]
[InlineData("downloads")]
[InlineData("posts")]
[InlineData("events")]
[InlineData("contact")]
[InlineData("unbekannt")] // the default
public void Every_icon_exists_in_the_icon_set(string slug) =>
IconSet.Find(TileIcons.For(slug)).ShouldNotBeNull(); // a typo would render no icon at all
}
@@ -0,0 +1,45 @@
using Elternbeirat.Contracts;
using Elternbeirat.Web.Features.Events;
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the "what is coming up?" rule and the Berlin day behind it. Pure
/// logic, no PocketBase needed.
/// </summary>
public class UpcomingEventsTests
{
private static Event Event(string title, DateTime start) =>
new() { Id = "test", Title = title, Start = start };
[Fact]
public void Keeps_today_and_later_ordered_by_start()
{
var events = new[]
{
Event("Basar", new DateTime(2026, 11, 22, 9, 0, 0)),
Event("Gestern", new DateTime(2026, 9, 30, 19, 30, 0)),
Event("Heute früh", new DateTime(2026, 10, 1, 7, 0, 0)), // already begun, still today
Event("Sitzung", new DateTime(2026, 10, 8, 19, 30, 0)),
};
var upcoming = UpcomingEvents.From(events, new DateOnly(2026, 10, 1));
upcoming.Select(@event => @event.Title).ShouldBe(["Heute früh", "Sitzung", "Basar"]);
}
[Fact]
public void Nothing_upcoming_gives_an_empty_list() =>
UpcomingEvents.From([Event("Vorbei", new DateTime(2026, 1, 1))], new DateOnly(2026, 10, 1))
.ShouldBeEmpty();
[Fact]
public void Berlin_day_starts_before_the_UTC_day()
{
// 22:30 UTC on 30 September is 00:30 on 1 October in Berlin (summer time).
var time = new FixedTimeProvider(new DateTimeOffset(2026, 9, 30, 22, 30, 0, TimeSpan.Zero));
BerlinClock.Today(time).ShouldBe(new DateOnly(2026, 10, 1));
}
}