Files
projax/web/calendar_test.go
mAi f820fa5830 feat(views): Phase 5j slice C — full URL migration + system views
Per m's Q1 pick (b) (2026-05-29): legacy `/`, `/dashboard`, `/calendar`,
`/timeline`, `/graph` become `/views/{system-slug}`. Old routes
301-redirect to the new ones with chip params preserved; the legacy
?view=<uuid> param from 5i is resolved through the uuid → slug map
when present so old bookmarks land on the right user view.

System views (web/system_views.go):
- SystemView struct (Slug / Name / Icon / URL) — code-resident, never
  rows in projax.views.
- AllSystemViews() returns the canonical five: tree, dashboard,
  calendar, timeline, graph. Display order matches the existing
  sidebar.
- LookupSystemView(slug) returns the matching entry or nil; the
  reserved-slug list in store.IsReservedViewSlug (slice A) is kept
  in sync.
- legacyRedirect(systemSlug) handler 301s with chip-param preservation
  + uuid → slug resolution for any leftover ?view=<uuid>.

Routes (web/server.go):
- GET /views/tree      → handleTree     (was GET /)
- GET /views/dashboard → handleDashboard
- GET /views/timeline  → handleTimeline
- GET /views/calendar  → handleCalendar
- GET /views/graph     → handleGraph
- GET /                → 301 → /views/tree
- GET /dashboard       → 301 → /views/dashboard
- GET /timeline        → 301 → /views/timeline
- GET /calendar        → 301 → /views/calendar
- GET /graph           → 301 → /views/graph
- POST action endpoints (/dashboard/task/*, /dashboard/pin, /admin/*)
  stay where they are — those are RPC-ish, not page renders.

handleTree: dropped the `r.URL.Path != "/"` guard — the only entry
point now is /views/tree, mounted via the new route. Slice F removes
any residual references; this slice keeps the handler reachable.

computeChipCounts grew a `base string` arg so chip URLs anchor on the
caller's route (/views/tree for the system tree, /views/{slug} for
saved views). PageViewTypes recognises both legacy and /views/ keys
during the transition.

Template hrefs / hx-gets bulk-updated to the new URLs:
- layout.tmpl: every sidebar + bottom-nav entry points at
  /views/{system-slug}. Active-state checks updated alongside.
- tree_section.tmpl, tree_card.tmpl, tree_kanban.tmpl: clear-filter
  / clear-all hrefs → /views/tree.
- calendar*.tmpl, timeline_section.tmpl, graph.tmpl,
  dashboard_section.tmpl: every internal nav + filter link points at
  the /views/{slug} surface.
- detail.tmpl, error.tmpl: cancel / back-to-tree → /views/tree.

Test-source updates (per the 5c sharpened rule):
- ~100 test paths bulk-rewritten from /dashboard /calendar /timeline
  /graph (and `/`) to their /views/{slug} counterparts. The
  behaviour-preservation contract holds: status codes + body shapes
  for the rendered pages stay the same; only the URL anchoring the
  test changes.
- layout_test.go: sidebar href assertions updated to /views/{slug}.
- view_type_test.go (Q2 + Q3 follow-up): PageViewTypes lookup table
  updated to use the new route keys.
- 2 deliberate behaviour-change assertions land: TestLegacyRedirects
  expects 301 on the old URLs (was 200); TestTreeRenders fetches
  /views/tree (the new home) instead of /.

Internal go-source URL emissions (dashboard.go, calendar.go,
timeline.go) updated to the new BasePath so chip + refresh URLs round
through /views/{slug} correctly.

New tests:
- TestSystemViewLookup — AllSystemViews shape + LookupSystemView
  round-trip + unknown-slug nil.
- TestLegacyRedirects — every legacy URL 301s to its new home with
  chip params preserved.
- TestLegacyViewUUIDRedirect — old `?view=<uuid>` URLs land on the
  resolved slug per m's Q3 pick.
2026-05-29 11:59:26 +02:00

245 lines
8.7 KiB
Go

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)
}
}