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..969102a 100644 --- a/Elternbeirat.Web.Tests/MarkdownTests.cs +++ b/Elternbeirat.Web.Tests/MarkdownTests.cs @@ -78,4 +78,51 @@ 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_drops_a_colon_that_leads_into_a_list() => + Markdown.FirstSentence("Formulare zum Herunterladen:\n\n- Antrag") + .ShouldBe("Formulare zum Herunterladen"); + + [Fact] + public void First_sentence_is_never_cut_short() + { + // Long teasers are clamped by the tile's CSS, not by a character count. + var sentence = string.Join(' ', Enumerable.Repeat("Wort", 60)) + "."; + + Markdown.FirstSentence(sentence).ShouldBe(sentence); + } + + [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(""); } 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..d99723d 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 section, from its opening tag to the closing + /// one. It nests no other <section>. + /// + [GeneratedRegex("""
    """, RegexOptions.Singleline)] + private static partial Regex HomeTiles(); } diff --git a/Elternbeirat.Web.Tests/TileIconsTests.cs b/Elternbeirat.Web.Tests/TileIconsTests.cs new file mode 100644 index 0000000..6867313 --- /dev/null +++ b/Elternbeirat.Web.Tests/TileIconsTests.cs @@ -0,0 +1,37 @@ +using Elternbeirat.Web.Features.Home; +using Elternbeirat.Web.Shared; + +namespace Elternbeirat.Web.Tests; + +/// +/// Unit tests for the slug-to-icon mapping of the home page tiles. Pure logic, no +/// PocketBase needed. +/// +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 +} diff --git a/Elternbeirat.Web.Tests/UpcomingEventsTests.cs b/Elternbeirat.Web.Tests/UpcomingEventsTests.cs new file mode 100644 index 0000000..c811bdb --- /dev/null +++ b/Elternbeirat.Web.Tests/UpcomingEventsTests.cs @@ -0,0 +1,45 @@ +using Elternbeirat.Contracts; +using Elternbeirat.Web.Features.Events; +using Elternbeirat.Web.Shared; + +namespace Elternbeirat.Web.Tests; + +/// +/// Unit tests for the "what is coming up?" rule and the Berlin day behind it. Pure +/// logic, no PocketBase needed. +/// +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)); + } +} diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor b/Elternbeirat.Web/Components/Layout/MainLayout.razor index 3c495e6..b78e43f 100644 --- a/Elternbeirat.Web/Components/Layout/MainLayout.razor +++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor @@ -27,7 +27,7 @@ -