Add post and event cards, shared calendar sheet and per-event ICS; rework past events and next-event card

This commit is contained in:
tleininger committed 2026-10-01 15:32:01 +02:00
1 parent d328adac5c
commit b2dc033de1
33 files changed
+1150 -206

No files matched your search

+129
View File
@@ -92,6 +92,7 @@ public sealed partial class RouteSmokeTests : IDisposable
[Theory]
[InlineData("/gibt-es-nicht")]
[InlineData("/posts/gibt-es-nicht")]
[InlineData("/events/gibt-es-nicht.ics")]
public async Task Unknown_route_returns_404(string route)
{
var client = _factory.CreateClient();
@@ -328,6 +329,108 @@ public sealed partial class RouteSmokeTests : IDisposable
html.ShouldNotContain("Nächster Termin");
}
[Fact]
public async Task Event_cards_on_the_events_page_do_not_link_to_the_events_page()
{
// Events have no page of their own; a card linking to /events led the
// visitor back to where they already were. The navigation does link to
// /events, so only the card list is checked.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
var list = EventCards().Match(html);
list.Success.ShouldBeTrue();
Href().Matches(list.Value).Select(match => match.Groups[1].Value).ShouldAllBe(href => href.EndsWith(".ics", StringComparison.Ordinal));
}
[Fact]
public async Task Events_page_shows_its_intro_once_and_a_subscribe_button()
{
// The intro is the "events" page body; a hard-coded intro beside it used to
// say the same thing twice.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
Regex.Count(html, Regex.Escape("Sitzungen und Veranstaltungen auf einen Blick.")).ShouldBe(1);
html.ShouldContain("""<a class="btn btn-secondary" href="/events.ics">""");
html.ShouldContain("Kalender abonnieren");
}
[Fact]
public async Task Events_page_folds_past_events_away()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
var past = PastEvents().Match(html);
past.Success.ShouldBeTrue();
past.Value.ShouldContain("Vergangene Termine anzeigen (2)");
past.Value.ShouldContain("Infoabend Klasse 5");
past.Value.ShouldNotContain("Elternbeiratssitzung"); // upcoming, not past
past.Value.ShouldContain("""<h3 class="past-events-year">2026</h3>""");
past.Value.ShouldContain("10. September"); // day without weekday or year
past.Value.ShouldContain("19:00 Uhr"); // the time stays, after the title
past.Value.ShouldContain("20.–22. Juli"); // several days as a span
Regex.Count(past.Value, "Uhr").ShouldBe(1); // the all-day Projekttage get no time
}
[Fact]
public async Task Single_event_calendar_file_holds_exactly_that_event()
{
// The "In Kalender übernehmen" link of a card leads to a calendar with just
// that one event. Take the link from the page, as a visitor would.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
var href = Href().Matches(EventCards().Match(html).Value)
.Select(match => match.Groups[1].Value)
.First();
var response = await client.GetAsync(new Uri(href, UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/calendar");
var ics = await response.Content.ReadAsStringAsync();
ics.ShouldStartWith("BEGIN:VCALENDAR");
Regex.Count(ics, "BEGIN:VEVENT").ShouldBe(1);
ics.ShouldContain("SUMMARY:Elternbeiratssitzung"); // the earliest upcoming card
}
[Fact]
public async Task Home_event_cards_link_to_the_events_page()
{
// On the home page the cards are a teaser and may lead to the full list.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
var list = EventCards().Match(html);
list.Success.ShouldBeTrue();
Href().Matches(list.Value).Select(match => match.Groups[1].Value).ShouldAllBe(href => href == "/events");
html.ShouldContain("class=\"calendar-sheet\"");
}
[Fact]
public async Task Home_next_event_shows_the_time_and_keeps_the_full_date_for_screen_readers()
{
// The calendar sheet already shows the day, so the written-out date is only
// for screen readers; visible beside the sheet is the time. The meeting is
// 19:30 Berlin on Thursday, 8 October 2026 (fixture, test clock 1 October).
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
var card = NextEventCard().Match(html);
card.Success.ShouldBeTrue();
var hidden = VisuallyHidden().Match(card.Value);
hidden.Value.ShouldContain("Donnerstag, 8. Oktober 2026, 19:30 Uhr");
var visible = card.Value.Replace(hidden.Value, "", StringComparison.Ordinal);
visible.ShouldContain("19:30 Uhr");
visible.ShouldNotContain("Oktober 2026");
}
/// <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
@@ -356,4 +459,30 @@ public sealed partial class RouteSmokeTests : IDisposable
/// </summary>
[GeneratedRegex("""<section class="home-tiles".*?</section>""", RegexOptions.Singleline)]
private static partial Regex HomeTiles();
/// <summary>
/// Matches the "Nächster Termin" card in the home hero. It holds no other link,
/// so the first closing <c>&lt;/a&gt;</c> ends it.
/// </summary>
[GeneratedRegex("""<a class="next-event".*?</a>""", RegexOptions.Singleline)]
private static partial Regex NextEventCard();
/// <summary>
/// Matches the first text hidden for sighted visitors but read by screen readers.
/// </summary>
[GeneratedRegex("""<span class="visually-hidden".*?</span>""", RegexOptions.Singleline)]
private static partial Regex VisuallyHidden();
/// <summary>
/// Matches the first list of event cards, from its opening tag to the closing one.
/// The cards nest no other <c>&lt;ul&gt;</c>.
/// </summary>
[GeneratedRegex("""<ul class="event-list".*?</ul>""", RegexOptions.Singleline)]
private static partial Regex EventCards();
/// <summary>
/// Matches the folded-up past events on the events page.
/// </summary>
[GeneratedRegex("""<details class="past-events[ "].*?</details>""", RegexOptions.Singleline)]
private static partial Regex PastEvents();
}