Add home hero with intro and buttons from the body, next-event card and page tiles
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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><nav></c>.
|
||||
/// </summary>
|
||||
[GeneratedRegex("""<nav class="home-tiles".*?</nav>""", RegexOptions.Singleline)]
|
||||
private static partial Regex HomeTiles();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user