package web import ( "net/http/httptest" "strings" "testing" "time" "github.com/m/projax/store" ) // TestCalendarLayoutMondayLead seeds a synthetic month whose first day is a // Friday (May 2026) and confirms the layout function: // - First cell of week 0 is Monday April 27 2026 (adjacent-month). // - Last cell of the final week is Sunday May 31 2026 — the May 31st // itself falls on a Sunday, so no trailing pad days are needed. // - Total weeks = 5 (4 spillover Mondays + 31 days = 35 cells / 7). func TestCalendarLayoutMondayLead(t *testing.T) { loc := time.Local may := time.Date(2026, 5, 1, 0, 0, 0, 0, loc) gridStart := may.AddDate(0, 0, -mondayWeekday(may)) // Apr 27 2026 gridEnd := time.Date(2026, 6, 1, 0, 0, 0, 0, loc) // Jun 1 2026 (exclusive — May 31 is the final cell) weeks := layoutCalendarWeeks(may, gridStart, gridEnd, may, nil) if len(weeks) != 5 { t.Fatalf("expected 5 weeks for May 2026 (Fri-leading, Sun-trailing), got %d", len(weeks)) } first := weeks[0].Days[0] if first.DateKey != "2026-04-27" { t.Errorf("first cell DateKey = %q, want 2026-04-27", first.DateKey) } if !first.IsAdjacent { t.Errorf("first cell should be IsAdjacent (Apr 27 lives in April)") } last := weeks[4].Days[6] if last.DateKey != "2026-05-31" { t.Errorf("last cell DateKey = %q, want 2026-05-31", last.DateKey) } if last.IsAdjacent { t.Errorf("last cell should NOT be adjacent — May 31 is within May") } } // TestCalendarLayoutTrailingPad picks a month whose last day is a Monday // (June 2026) — the rectangular grid requires six trailing pad days to // close the week, yielding six rows total. func TestCalendarLayoutTrailingPad(t *testing.T) { loc := time.Local june := time.Date(2026, 6, 1, 0, 0, 0, 0, loc) // Monday gridStart := june // monthStart already a Monday → no lead // June has 30 days, ends on Tuesday. Pad to Sunday = +5 days → grid runs through Jul 5. gridEnd := time.Date(2026, 7, 6, 0, 0, 0, 0, loc) weeks := layoutCalendarWeeks(june, gridStart, gridEnd, june, nil) if len(weeks) != 5 { t.Fatalf("expected 5 weeks for June 2026 (Mon-leading), got %d", len(weeks)) } if weeks[0].Days[0].DateKey != "2026-06-01" { t.Errorf("first cell = %q, want 2026-06-01", weeks[0].Days[0].DateKey) } if weeks[4].Days[6].DateKey != "2026-07-05" { t.Errorf("last trailing cell = %q, want 2026-07-05", weeks[4].Days[6].DateKey) } if !weeks[4].Days[6].IsAdjacent { t.Errorf("July 5 cell should be IsAdjacent (lives in next month)") } } // TestCalendarTodayCell proves the IsToday flag fires on the right cell. func TestCalendarTodayCell(t *testing.T) { loc := time.Local may := time.Date(2026, 5, 1, 0, 0, 0, 0, loc) today := time.Date(2026, 5, 14, 0, 0, 0, 0, loc) gridStart := may.AddDate(0, 0, -mondayWeekday(may)) gridEnd := time.Date(2026, 6, 1, 0, 0, 0, 0, loc) weeks := layoutCalendarWeeks(may, gridStart, gridEnd, today, nil) found := false for _, wk := range weeks { for _, d := range wk.Days { if d.DateKey == "2026-05-14" { found = true if !d.IsToday { t.Errorf("cell for today (2026-05-14) should be IsToday") } } else if d.IsToday { t.Errorf("cell %s should NOT be IsToday", d.DateKey) } } } if !found { t.Fatalf("did not find cell for today in the grid") } } // TestCalendarCellRowOverflow proves that a day with more than three rows // renders three visible rows and surfaces the remainder via ExtraCount so // the template can emit a "+N more" link. func TestCalendarCellRowOverflow(t *testing.T) { loc := time.Local may := time.Date(2026, 5, 1, 0, 0, 0, 0, loc) gridStart := may.AddDate(0, 0, -mondayWeekday(may)) gridEnd := time.Date(2026, 6, 1, 0, 0, 0, 0, loc) dummy := &store.Item{ID: "id", Title: "t", Slug: "t"} rows := []calendarRow{ {Kind: "event", Item: dummy, ItemPath: "t", Summary: "a"}, {Kind: "event", Item: dummy, ItemPath: "t", Summary: "b"}, {Kind: "event", Item: dummy, ItemPath: "t", Summary: "c"}, {Kind: "event", Item: dummy, ItemPath: "t", Summary: "d"}, {Kind: "event", Item: dummy, ItemPath: "t", Summary: "e"}, } byDay := map[string][]calendarRow{"2026-05-15": rows} weeks := layoutCalendarWeeks(may, gridStart, gridEnd, may, byDay) var cell *calendarDay for _, wk := range weeks { for i, d := range wk.Days { if d.DateKey == "2026-05-15" { cell = &wk.Days[i] } } } if cell == nil { t.Fatalf("did not find May 15 cell") } if len(cell.Rows) != calendarMaxRowsPerCell { t.Errorf("len(Rows) = %d, want %d (cap)", len(cell.Rows), calendarMaxRowsPerCell) } if cell.ExtraCount != 2 { t.Errorf("ExtraCount = %d, want 2 (5 seeded - 3 visible)", cell.ExtraCount) } if cell.TotalRows != 5 { t.Errorf("TotalRows = %d, want 5", cell.TotalRows) } } // TestMondayWeekday pins the Monday=0 weekday conversion that drives the // lead-day count. The Sunday=0 default in Go's time package would put // Sunday at index 0 of the grid — wrong for the German week convention. func TestMondayWeekday(t *testing.T) { cases := []struct { day time.Weekday want int }{ {time.Monday, 0}, {time.Tuesday, 1}, {time.Wednesday, 2}, {time.Thursday, 3}, {time.Friday, 4}, {time.Saturday, 5}, {time.Sunday, 6}, } for _, c := range cases { // Pick a reference Monday and offset to get the weekday under test. ref := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Mon probe := ref.AddDate(0, 0, int(c.day-time.Monday+7)%7) if probe.Weekday() != c.day { t.Fatalf("setup bug: probe weekday = %v, want %v", probe.Weekday(), c.day) } if got := mondayWeekday(probe); got != c.want { t.Errorf("mondayWeekday(%v) = %d, want %d", c.day, got, c.want) } } } // TestFormatCalendarLongLabel exercises the per-cell long German label // used by the mobile breakpoint (hidden on desktop via CSS). The label // matters because on a 360px-wide phone the bare day number no longer // carries weekday context — the CSS collapses the column header. func TestFormatCalendarLongLabel(t *testing.T) { cases := map[string]string{ "2026-05-04": "Mo., 4. Mai", // Monday "2026-05-14": "Do., 14. Mai", // Thursday "2026-03-15": "So., 15. März", // Sunday in March (uses März) "2026-12-31": "Do., 31. Dez", } for in, want := range cases { t.Run(in, func(t *testing.T) { d, _ := time.Parse("2006-01-02", in) if got := formatCalendarLongLabel(d); got != want { t.Errorf("formatCalendarLongLabel(%s) = %q, want %q", in, got, want) } }) } } // TestFormatMonthLabel confirms the German month names render — m reads // /calendar primarily in the German register and the label is the most // visible piece of chrome. func TestFormatMonthLabel(t *testing.T) { cases := map[string]string{ "2026-01-01": "Januar 2026", "2026-03-15": "März 2026", "2026-05-01": "Mai 2026", "2026-12-31": "Dezember 2026", } for in, want := range cases { t.Run(in, func(t *testing.T) { d, _ := time.Parse("2006-01-02", in) if got := formatMonthLabel(d); got != want { t.Errorf("formatMonthLabel(%s) = %q, want %q", in, got, want) } }) } } // TestParseCalendarQueryDefaults proves month + kinds default to current // month + all-three. func TestParseCalendarQueryDefaults(t *testing.T) { now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC) r := httptest.NewRequest("GET", "/views/calendar", nil) q := parseCalendarQuery(r, now) if q.Month.Format("2006-01") != "2026-05" { t.Errorf("default month = %s, want 2026-05", q.Month.Format("2006-01")) } kinds := q.activeKinds() if len(kinds) != 3 { t.Errorf("default activeKinds len = %d, want 3 (event/todo/doc)", len(kinds)) } wantKinds := strings.Join(kinds, ",") for _, k := range []string{"event", "todo", "doc"} { if !strings.Contains(wantKinds, k) { t.Errorf("activeKinds missing %s: %v", k, kinds) } } } // TestParseCalendarQueryMonthParam proves a `?month=YYYY-MM` URL param // overrides the default. Bookmarkability matters because the prev/next // nav writes to this exact key. func TestParseCalendarQueryMonthParam(t *testing.T) { now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC) r := httptest.NewRequest("GET", "/views/calendar?month=2026-08", nil) q := parseCalendarQuery(r, now) if q.Month.Format("2006-01") != "2026-08" { t.Errorf("parsed month = %s, want 2026-08", q.Month.Format("2006-01")) } } // TestParseCalendarQueryKindFilter proves `?kind=event,doc` narrows the // kind set and drops unknown values. func TestParseCalendarQueryKindFilter(t *testing.T) { now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC) r := httptest.NewRequest("GET", "/views/calendar?kind=event,doc,junk,creation", nil) q := parseCalendarQuery(r, now) got := strings.Join(q.activeKinds(), ",") want := "doc,event" // sorted alphabetically; creation is excluded by design, junk dropped if got != want { t.Errorf("activeKinds = %q, want %q", got, want) } }