package web_test import ( "context" "io" "net/http/httptest" "strings" "testing" "time" ) // TestCalendarRendersMonthGrid hits GET /calendar with the default month // and asserts the rendered HTML carries the grid table, nav anchors, and // weekday header. Empty-data path — no seeding required so this guards // the route registration + template parsing on every test run. func TestCalendarRendersMonthGrid(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() code, body := get(t, h, "/views/calendar") if code != 200 { t.Fatalf("GET /calendar → %d body=%s", code, body) } for _, want := range []string{ `id="calendar-section"`, `class="calendar-grid"`, `Mon`, `Sun`, `class="calendar-nav"`, `href="/views/calendar?month=`, // prev/next anchors present } { if !strings.Contains(body, want) { t.Errorf("calendar body missing %q", want) } } } // TestCalendarSurfacesDatedLink seeds a dated item_link on today and // confirms the corresponding cell carries the link summary + the // .is-today class on today's . Closes the round-trip "data goes in, // cell renders out" loop for the doc source. func TestCalendarSurfacesDatedLink(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") slug := "cal-doc-" + stamp var dev, id string if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { t.Fatalf("dev: %v", err) } if err := pool.QueryRow(ctx, `insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Cal doc', $1, ARRAY[$2]::uuid[]) returning id`, slug, dev, ).Scan(&id); err != nil { t.Fatalf("seed item: %v", err) } defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id) noteText := "cal-marker-" + stamp if _, err := pool.Exec(ctx, `insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date) values ($1, 'document', $2, 'contains', $3, current_date)`, id, "https://example.com/cal-"+stamp, noteText, ); err != nil { t.Fatalf("seed link: %v", err) } code, body := get(t, h, "/views/calendar") if code != 200 { t.Fatalf("GET /calendar → %d", code) } if !strings.Contains(body, noteText) { t.Errorf("calendar body missing seeded doc summary %q", noteText) } // Today's should carry the .is-today class. if !strings.Contains(body, "is-today") { t.Errorf("expected today's cell to carry .is-today class, body did not include it") } } // TestCalendarFilterScopeByTag seeds two items with distinct tags, drops // a dated link on each, and asserts ?tag=work scopes the rendered rows. // Confirms the TreeFilter integration matches the timeline cadence. func TestCalendarFilterScopeByTag(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") var dev string if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { t.Fatalf("dev: %v", err) } workSlug := "cal-work-" + stamp playSlug := "cal-play-" + stamp var workID, playID string if err := pool.QueryRow(ctx, `insert into projax.items (kind, title, slug, parent_ids, tags) values (array['project']::text[], 'work item', $1, ARRAY[$2]::uuid[], ARRAY['cal-test-work-`+stamp+`']::text[]) returning id`, workSlug, dev).Scan(&workID); err != nil { t.Fatalf("seed work item: %v", err) } defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, workID) if err := pool.QueryRow(ctx, `insert into projax.items (kind, title, slug, parent_ids, tags) values (array['project']::text[], 'play item', $1, ARRAY[$2]::uuid[], ARRAY['cal-test-play-`+stamp+`']::text[]) returning id`, playSlug, dev).Scan(&playID); err != nil { t.Fatalf("seed play item: %v", err) } defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, playID) workNote := "cal-work-note-" + stamp playNote := "cal-play-note-" + stamp if _, err := pool.Exec(ctx, `insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date) values ($1, 'document', $2, 'contains', $3, current_date), ($4, 'document', $5, 'contains', $6, current_date)`, workID, "https://example.com/cal-work-"+stamp, workNote, playID, "https://example.com/cal-play-"+stamp, playNote, ); err != nil { t.Fatalf("seed links: %v", err) } // Unfiltered: both notes show. _, all := get(t, h, "/views/calendar?refresh=1") if !strings.Contains(all, workNote) { t.Errorf("unfiltered calendar missing work note %q", workNote) } if !strings.Contains(all, playNote) { t.Errorf("unfiltered calendar missing play note %q", playNote) } // Filtered: only work note shows. _, scoped := get(t, h, "/views/calendar?refresh=1&tag=cal-test-work-"+stamp) if !strings.Contains(scoped, workNote) { t.Errorf("filtered calendar missing work note %q", workNote) } if strings.Contains(scoped, playNote) { t.Errorf("filtered calendar SHOULD NOT contain play note %q", playNote) } } // TestCalendarAdjacentMonthDays proves the lead-in / trail-out cells from // the prior / next month render with the .adjacent-month class so the // template can style them muted without losing the rectangular grid. func TestCalendarAdjacentMonthDays(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() // Pick a month whose first day is NOT a Monday so leading days appear. // May 2026 starts on a Friday; lead = Apr 27/28/29/30. _, body := get(t, h, "/views/calendar?month=2026-05&refresh=1") if !strings.Contains(body, "adjacent-month") { t.Errorf("expected adjacent-month class on lead-in cells for May 2026, body did not include it") } if !strings.Contains(body, `data-date="2026-04-27"`) { t.Errorf("expected lead-in cell data-date=2026-04-27 for May 2026, body did not include it") } } // TestCalendarNavPrevNextLinks confirms the header has working prev/next // month links — bookmarkability is the calendar's main affordance for // jumping around the timeline. func TestCalendarNavPrevNextLinks(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() _, body := get(t, h, "/views/calendar?month=2026-05") if !strings.Contains(body, `href="/views/calendar?month=2026-04"`) { t.Errorf("expected prev link to 2026-04, body did not include it") } if !strings.Contains(body, `href="/views/calendar?month=2026-06"`) { t.Errorf("expected next link to 2026-06, body did not include it") } } // TestCalendarFilterChipStripRenders proves the HTMX filter chip strip // (Phase 5e slice B) is rendered above the grid with the hx-target // pointing at #calendar-section so chip changes swap only the data and // leave the month-label chrome alone. func TestCalendarFilterChipStripRenders(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() _, body := get(t, h, "/views/calendar?month=2026-05") for _, want := range []string{ `id="calendar-filterbar"`, `hx-target="#calendar-section"`, `hx-get="/views/calendar"`, ``, // preserves month across chip changes `name="kind"`, `name="tag"`, `name="mgmt"`, } { if !strings.Contains(body, want) { t.Errorf("calendar body missing %q", want) } } } // TestCalendarHTMXReturnsSectionOnly proves an HX-Request returns just // the calendar-section fragment (no layout chrome) so the filter chip // strip can swap the grid in place without re-rendering the page shell. func TestCalendarHTMXReturnsSectionOnly(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() req := httptest.NewRequest("GET", "/views/calendar?month=2026-05", nil) req.Header.Set("HX-Request", "true") w := httptest.NewRecorder() h.ServeHTTP(w, req) body, _ := io.ReadAll(w.Result().Body) bs := string(body) if w.Result().StatusCode != 200 { t.Fatalf("HTMX /calendar → %d body=%s", w.Result().StatusCode, bs) } if !strings.Contains(bs, `id="calendar-section"`) { t.Errorf("HTMX response missing #calendar-section: %s", bs) } // Layout chrome (e.g. the