From e51afeb34b0d39359da1ef13b47a917192131a88 Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 1 Oct 2026 14:05:25 +0200 Subject: [PATCH 1/3] Add home hero with intro and buttons from the body, next-event card and page tiles --- Elternbeirat.Web.Tests/FixedTimeProvider.cs | 13 + Elternbeirat.Web.Tests/HomeIntroTests.cs | 67 +++++ Elternbeirat.Web.Tests/MarkdownTests.cs | 45 +++ Elternbeirat.Web.Tests/PocketBaseFixture.cs | 6 +- Elternbeirat.Web.Tests/RouteSmokeTests.cs | 78 ++++- Elternbeirat.Web.Tests/TileIconsTests.cs | 37 +++ Elternbeirat.Web.Tests/UpcomingEventsTests.cs | 45 +++ .../Features/Embeds/EventsEmbed.razor.cs | 14 +- .../Features/Events/EventList.razor.cs | 12 +- .../Features/Events/UpcomingEvents.cs | 30 ++ Elternbeirat.Web/Features/Home/Home.razor | 78 ++++- Elternbeirat.Web/Features/Home/Home.razor.cs | 70 ++++- Elternbeirat.Web/Features/Home/Home.razor.css | 266 +++++++++++++++++- Elternbeirat.Web/Features/Home/HomeIntro.cs | 128 +++++++++ Elternbeirat.Web/Features/Home/HomeTile.cs | 39 +++ Elternbeirat.Web/Features/Home/TileIcons.cs | 45 +++ .../Features/Layout/NavBuilder.cs | 42 ++- Elternbeirat.Web/Program.cs | 4 + Elternbeirat.Web/Shared/BerlinClock.cs | 33 +++ Elternbeirat.Web/Shared/Markdown.cs | 169 +++++++++-- Elternbeirat.Web/wwwroot/app.css | 72 +++++ docs/redaktion.md | 32 +++ pb/pb_migrations/1727200000_dev_seed.js | 2 +- 23 files changed, 1262 insertions(+), 65 deletions(-) create mode 100644 Elternbeirat.Web.Tests/FixedTimeProvider.cs create mode 100644 Elternbeirat.Web.Tests/HomeIntroTests.cs create mode 100644 Elternbeirat.Web.Tests/TileIconsTests.cs create mode 100644 Elternbeirat.Web.Tests/UpcomingEventsTests.cs create mode 100644 Elternbeirat.Web/Features/Events/UpcomingEvents.cs create mode 100644 Elternbeirat.Web/Features/Home/HomeIntro.cs create mode 100644 Elternbeirat.Web/Features/Home/HomeTile.cs create mode 100644 Elternbeirat.Web/Features/Home/TileIcons.cs create mode 100644 Elternbeirat.Web/Shared/BerlinClock.cs diff --git a/Elternbeirat.Web.Tests/FixedTimeProvider.cs b/Elternbeirat.Web.Tests/FixedTimeProvider.cs new file mode 100644 index 0000000..e8b99c5 --- /dev/null +++ b/Elternbeirat.Web.Tests/FixedTimeProvider.cs @@ -0,0 +1,13 @@ +namespace Elternbeirat.Web.Tests; + +/// +/// 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. +/// +/// The moment the clock shows. +internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => now; +} diff --git a/Elternbeirat.Web.Tests/HomeIntroTests.cs b/Elternbeirat.Web.Tests/HomeIntroTests.cs new file mode 100644 index 0000000..1b919d1 --- /dev/null +++ b/Elternbeirat.Web.Tests/HomeIntroTests.cs @@ -0,0 +1,67 @@ +using Elternbeirat.Web.Features.Home; + +namespace Elternbeirat.Web.Tests; + +/// +/// Unit tests for taking the home page's body apart into heading, intro and +/// buttons. Pure logic, no PocketBase needed. +/// +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("

Schön, dass Sie da sind.

\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"); + } +} diff --git a/Elternbeirat.Web.Tests/MarkdownTests.cs b/Elternbeirat.Web.Tests/MarkdownTests.cs index 038f9ca..0267aa0 100644 --- a/Elternbeirat.Web.Tests/MarkdownTests.cs +++ b/Elternbeirat.Web.Tests/MarkdownTests.cs @@ -78,4 +78,49 @@ public class MarkdownTests html.ShouldContain($"
  • "); html.ShouldContain("
  • 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(() => Markdown.FirstSentence("Text.", maxLength: 0)); } diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index e137631..151241a 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -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 diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index ca9ba7c..a4c3b0b 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -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); } + /// + /// "Now" for the default factory: a week before the fixture's first event, so + /// "upcoming" stays true no matter when the tests run. + /// + private static readonly DateTimeOffset BeforeFixtureEvents = new(2026, 10, 1, 10, 0, 0, TimeSpan.Zero); + + /// + /// Wraps so the app's clock shows . + /// + private static WebApplicationFactory WithClock(WebApplicationFactory factory, DateTimeOffset now) => + factory.WithWebHostBuilder(builder => + builder.ConfigureTestServices(services => services.AddSingleton(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"); + html.ShouldNotContain(">Elternbeirat der IGMH"); // the fallback title + html.ShouldContain(""""""); + html.ShouldContain(""""""); + } + + [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"); + } + /// /// Matches a resource reference that points at an absolute URL, with or without /// scheme (https://, http:// or protocol-relative //): the @@ -282,4 +349,11 @@ public sealed partial class RouteSmokeTests : IDisposable /// [GeneratedRegex(@"\bhref=""([^""]*)""")] private static partial Regex Href(); + + /// + /// Matches the home page's tile navigation, from its opening tag to the closing + /// one. It nests no other <nav>. + /// + [GeneratedRegex("""