Add post and event cards, shared calendar sheet and per-event ICS; rework past events and next-event card
This commit is contained in:
1 parent
d328adac5c
commit
b2dc033de1
33 files changed
+1150
-206
No files matched your search
@@ -0,0 +1,105 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.Web.Features.Events;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the calendar sheet texts and the time line beside it. Pure logic,
|
||||
/// no PocketBase needed. All times are Berlin wall-clock, as the contract delivers them.
|
||||
/// </summary>
|
||||
public class EventDateFormatTests
|
||||
{
|
||||
private static Event Event(DateTime start, DateTime? end = null) =>
|
||||
new() { Id = "test", Title = "Termin", Start = start, End = end };
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "Jan")]
|
||||
[InlineData(3, "März")]
|
||||
[InlineData(5, "Mai")]
|
||||
[InlineData(10, "Okt")]
|
||||
[InlineData(12, "Dez")]
|
||||
public void Sheet_month_is_the_German_short_month_without_period(int month, string expected) =>
|
||||
EventDateFormat.SheetMonth(new DateTime(2026, month, 1)).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Sheet_day_has_no_leading_zero() =>
|
||||
EventDateFormat.SheetDay(new DateTime(2026, 10, 8, 19, 30, 0)).ShouldBe("8");
|
||||
|
||||
[Theory]
|
||||
[InlineData(8, "Do")] // Thursday, 8 October 2026
|
||||
[InlineData(11, "So")] // Sunday
|
||||
public void Sheet_weekday_is_the_German_short_weekday_without_period(int day, string expected) =>
|
||||
EventDateFormat.SheetWeekday(new DateTime(2026, 10, day)).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Past_all_day_event_shows_the_day_and_no_time()
|
||||
{
|
||||
var allDay = Event(new DateTime(2026, 11, 13));
|
||||
|
||||
EventDateFormat.PastDays(allDay).ShouldBe("13. November");
|
||||
EventDateFormat.PastTime(allDay).ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Past_timed_event_shows_the_day_and_its_start_time()
|
||||
{
|
||||
var timed = Event(new DateTime(2026, 9, 1, 19, 0, 0));
|
||||
|
||||
EventDateFormat.PastDays(timed).ShouldBe("1. September"); // no leading zero
|
||||
EventDateFormat.PastTime(timed).ShouldBe("19:00 Uhr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Past_timed_event_ending_the_same_day_shows_the_time_range()
|
||||
{
|
||||
var evening = Event(new DateTime(2026, 11, 13, 19, 0, 0), new DateTime(2026, 11, 13, 21, 0, 0));
|
||||
|
||||
EventDateFormat.PastDays(evening).ShouldBe("13. November");
|
||||
EventDateFormat.PastTime(evening).ShouldBe("19:00–21:00 Uhr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Past_multi_day_event_in_one_month_names_the_month_once() =>
|
||||
EventDateFormat.PastDays(Event(new DateTime(2026, 2, 8), new DateTime(2026, 2, 9))).ShouldBe("8.–9. Februar");
|
||||
|
||||
[Fact]
|
||||
public void Past_multi_day_event_across_a_month_names_both_months() =>
|
||||
EventDateFormat.PastDays(Event(new DateTime(2026, 1, 30), new DateTime(2026, 2, 2)))
|
||||
.ShouldBe("30. Januar – 2. Februar");
|
||||
|
||||
[Fact]
|
||||
public void Past_multi_day_event_across_a_year_names_the_end_year() =>
|
||||
// The list is grouped by the start year, so only the end needs its year.
|
||||
EventDateFormat.PastDays(Event(new DateTime(2026, 12, 30), new DateTime(2027, 1, 2)))
|
||||
.ShouldBe("30. Dezember – 2. Januar 2027");
|
||||
|
||||
[Fact]
|
||||
public void Past_timed_multi_day_event_shows_only_the_start_time() =>
|
||||
// A range here would read as the daily hours.
|
||||
EventDateFormat.PastTime(Event(new DateTime(2026, 2, 8, 9, 0, 0), new DateTime(2026, 2, 9, 13, 0, 0)))
|
||||
.ShouldBe("09:00 Uhr");
|
||||
|
||||
[Fact]
|
||||
public void Span_of_a_timed_event_without_end_is_its_start_time() =>
|
||||
EventDateFormat.Span(Event(new DateTime(2026, 10, 8, 19, 30, 0))).ShouldBe("19:30 Uhr");
|
||||
|
||||
[Fact]
|
||||
public void Span_of_a_same_day_event_is_the_time_range() =>
|
||||
EventDateFormat.Span(Event(new DateTime(2026, 10, 8, 19, 30, 0), new DateTime(2026, 10, 8, 21, 0, 0)))
|
||||
.ShouldBe("19:30–21:00 Uhr");
|
||||
|
||||
[Fact]
|
||||
public void Span_of_a_multi_day_event_names_the_last_day() =>
|
||||
EventDateFormat.Span(Event(new DateTime(2026, 10, 9, 9, 0, 0), new DateTime(2026, 10, 11, 13, 0, 0)))
|
||||
.ShouldBe("09:00 Uhr bis Sonntag, 11. Oktober 2026, 13:00 Uhr");
|
||||
|
||||
[Fact]
|
||||
public void Span_of_a_multi_day_all_day_event_has_no_times() =>
|
||||
EventDateFormat.Span(Event(new DateTime(2026, 10, 9), new DateTime(2026, 10, 11)))
|
||||
.ShouldBe("bis Sonntag, 11. Oktober 2026");
|
||||
|
||||
[Fact]
|
||||
public void Span_of_a_single_all_day_event_is_empty() =>
|
||||
// The sheet already shows the day; there is nothing left to say.
|
||||
EventDateFormat.Span(Event(new DateTime(2026, 10, 8))).ShouldBe("");
|
||||
}
|
||||
@@ -125,4 +125,63 @@ public class MarkdownTests
|
||||
[InlineData("::: hinweis\nText im Baustein.\n:::")]
|
||||
public void First_sentence_is_empty_without_a_top_level_paragraph(string? markdown) =>
|
||||
Markdown.FirstSentence(markdown).ShouldBe("");
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("## Nur eine Überschrift")]
|
||||
public void Teaser_is_empty_without_paragraph_text(string? markdown) =>
|
||||
Markdown.Teaser(markdown).ShouldBe("");
|
||||
|
||||
[Fact]
|
||||
public void Teaser_keeps_a_short_body_whole() =>
|
||||
Markdown.Teaser("Kurzer Text.").ShouldBe("Kurzer Text.");
|
||||
|
||||
[Fact]
|
||||
public void Teaser_removes_markdown_and_skips_headings()
|
||||
{
|
||||
// Emphasis and link markup go, link text stays; the heading would repeat the
|
||||
// card title, so it is left out. Paragraphs and list items are joined.
|
||||
var teaser = Markdown.Teaser(
|
||||
"## Rückblick\n\nDer **Basar** war ein [Erfolg](/posts).\n\n- Danke an alle\n- Bis bald");
|
||||
|
||||
teaser.ShouldBe("Der Basar war ein Erfolg. Danke an alle Bis bald");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Teaser_cuts_a_long_body_at_a_word_boundary()
|
||||
{
|
||||
var teaser = Markdown.Teaser("Der Basar war ein voller Erfolg.", 20);
|
||||
|
||||
// 20 characters end inside "voller"; the cut falls back to the space before it.
|
||||
teaser.ShouldBe("Der Basar war ein…");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Teaser_keeps_a_word_that_ends_exactly_at_the_limit() =>
|
||||
Markdown.Teaser("Der Basar war ein voller Erfolg.", 17).ShouldBe("Der Basar war ein…");
|
||||
|
||||
[Fact]
|
||||
public void Teaser_drops_punctuation_left_at_the_cut() =>
|
||||
Markdown.Teaser("Liebe Eltern, der Basar war schön.", 14).ShouldBe("Liebe Eltern…");
|
||||
|
||||
[Fact]
|
||||
public void Teaser_cuts_a_single_overlong_word_hard() =>
|
||||
Markdown.Teaser("Donaudampfschifffahrtsgesellschaft", 10).ShouldBe("Donaudampf…");
|
||||
|
||||
[Fact]
|
||||
public void Teaser_defaults_to_160_characters_plus_ellipsis()
|
||||
{
|
||||
var body = string.Join(' ', Enumerable.Repeat("Wort", 100)); // 499 characters
|
||||
|
||||
var teaser = Markdown.Teaser(body);
|
||||
|
||||
teaser.Length.ShouldBeLessThanOrEqualTo(Markdown.TeaserLength + 1);
|
||||
teaser.ShouldEndWith("Wort…");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Teaser_rejects_a_length_of_zero() =>
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => Markdown.Teaser("Text", 0));
|
||||
}
|
||||
@@ -17,8 +17,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
|
||||
var events = await client.GetEventsAsync();
|
||||
|
||||
// Two seeded events, earliest start first.
|
||||
events.Select(@event => @event.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
|
||||
// Three seeded events, earliest start first.
|
||||
events.Select(@event => @event.Title).ShouldBe(["Projekttage", "Infoabend Klasse 5", "Elternbeiratssitzung", "Herbstbasar"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -338,6 +338,20 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
start = "2026-11-22 08:00:00.000Z", end = "2026-11-22 12:00:00.000Z",
|
||||
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
|
||||
});
|
||||
// Two past events (before the smoke tests' clock of 1 October 2026), so the
|
||||
// folded-up "Vergangene Termine" section on /events has something to show:
|
||||
// one with a time (19:00 Berlin, summer time), and one over several days
|
||||
// without a time. All-day means 00:00 Berlin, i.e. 22:00Z the day before.
|
||||
await CreateRecordAsync(http, "events", new
|
||||
{
|
||||
start = "2026-09-10 17:00:00.000Z", title = "Infoabend Klasse 5",
|
||||
location = "Aula", note = "", @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "events", new
|
||||
{
|
||||
start = "2026-07-19 22:00:00.000Z", end = "2026-07-21 22:00:00.000Z",
|
||||
title = "Projekttage", location = "", note = "", @public = true,
|
||||
});
|
||||
|
||||
// Faq topics: two, ordered. The topics own the grouping and the order the FAQ
|
||||
// page shows the groups in (cafeteria before lockers). Titles stay German for
|
||||
|
||||
@@ -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></a></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><ul></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();
|
||||
}
|
||||
Reference in new issue
Block a user