| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/url" |
| 6 | "regexp" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | // B8, first half. Each week day used to carry its own all-day strip |
| 12 | // above its hour column, so a day with a birthday started its timed |
| 13 | // grid ~42px lower than the others and its 10:00 meeting drew beside |
| 14 | // the 11:00 label. The all-day strip is now one row of the grid: every |
| 15 | // all-day cell comes before every timed column in the markup, and |
| 16 | // there is one cell per day whether that day has anything or not. |
| 17 | func TestWeekAllDayIsOneRowAboveEveryTimedColumn(t *testing.T) { |
| 18 | app, session := signedIn(t, "someone@example.com") |
| 19 | h := app.Handler() |
| 20 | createEvent(t, h, session, url.Values{ |
| 21 | "title": {"Birthday"}, "all_day": {"1"}, "start": {"2026-10-07"}, "end": {"2026-10-08"}, |
| 22 | "kind": {"note"}, "status": {"confirmed"}, "repeat": {"none"}, |
| 23 | }) |
| 24 | createEvent(t, h, session, url.Values{ |
| 25 | "title": {"Focus time"}, "start": {"2026-10-07T10:00"}, "end": {"2026-10-07T11:00"}, |
| 26 | "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"none"}, |
| 27 | }) |
| 28 | |
| 29 | body := getAs(t, h, session, "/week/2026-10-07").Body.String() |
| 30 | cells := strings.Count(body, `cal-allday="week"`) |
| 31 | if cells != 7 { |
| 32 | t.Fatalf("want one all-day cell per day (7), got %d", cells) |
| 33 | } |
| 34 | lastCell := strings.LastIndex(body, `cal-allday="week"`) |
| 35 | firstColumn := strings.Index(body, "<div cal-column>") |
| 36 | if firstColumn < 0 || lastCell > firstColumn { |
| 37 | t.Fatalf("an all-day cell sits inside the timed grid (last cell at %d, first column at %d)", lastCell, firstColumn) |
| 38 | } |
| 39 | if !strings.Contains(body[:firstColumn], "Birthday") { |
| 40 | t.Error("the all-day event is not in the all-day row") |
| 41 | } |
| 42 | if !strings.Contains(body[firstColumn:], "Focus time") { |
| 43 | t.Error("the timed event is not in the timed grid") |
| 44 | } |
| 45 | |
| 46 | // A week with nothing all-day draws no all-day row at all. |
| 47 | empty := getAs(t, h, session, "/week/2026-11-04").Body.String() |
| 48 | if strings.Contains(empty, `cal-allday`) { |
| 49 | t.Error("a week with no all-day events drew an all-day row") |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // B8, second half. Under an hour the column has room for one line, |
| 54 | // and a stacked chip put the title on the second — the line the chip's |
| 55 | // overflow cut off. A short chip is marked so time and title share one |
| 56 | // line; an hour or more keeps the stacked layout. |
| 57 | func TestShortEventsKeepTheirTitleLine(t *testing.T) { |
| 58 | app, session := signedIn(t, "someone@example.com") |
| 59 | h := app.Handler() |
| 60 | createEvent(t, h, session, url.Values{ |
| 61 | "title": {"Quick sync"}, "start": {"2026-10-07T14:00"}, "end": {"2026-10-07T14:30"}, |
| 62 | "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}, |
| 63 | }) |
| 64 | createEvent(t, h, session, url.Values{ |
| 65 | "title": {"Long review"}, "start": {"2026-10-07T09:00"}, "end": {"2026-10-07T10:00"}, |
| 66 | "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}, |
| 67 | }) |
| 68 | placed := regexp.MustCompile(`(?s)<div cal-placed="([a-z]*)"[^>]*>.*?<span cal-chip-title>([^<]*)</span>`) |
| 69 | for _, path := range []string{"/week/2026-10-07", "/day/2026-10-07"} { |
| 70 | body := getAs(t, h, session, path).Body.String() |
| 71 | got := map[string]string{} |
| 72 | for _, m := range placed.FindAllStringSubmatch(body, -1) { |
| 73 | got[strings.TrimSpace(m[2])] = m[1] |
| 74 | } |
| 75 | if v, ok := got["Quick sync"]; !ok || v != "short" { |
| 76 | t.Errorf("%s: a 30-minute chip is not marked short: %q (found %v)", path, v, got) |
| 77 | } |
| 78 | if v, ok := got["Long review"]; !ok || v != "" { |
| 79 | t.Errorf("%s: an hour-long chip is marked %q, want stacked", path, v) |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // B9. An all-day event stores bare dates, and a datetime-local input |
| 85 | // refuses a bare date — the edit form opened with Starts and Ends |
| 86 | // blank and could not be saved without retyping both. The form now |
| 87 | // offers date inputs for an all-day event. |
| 88 | func TestEditingAnAllDayEventShowsItsDates(t *testing.T) { |
| 89 | app, session := signedIn(t, "someone@example.com") |
| 90 | h := app.Handler() |
| 91 | loc := createEvent(t, h, session, url.Values{ |
| 92 | "title": {"Conference"}, "all_day": {"1"}, "start": {"2026-10-07"}, "end": {"2026-10-09"}, |
| 93 | "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"none"}, |
| 94 | }) |
| 95 | body := getAs(t, h, session, loc+"/edit").Body.String() |
| 96 | for name, want := range map[string]string{"start": "2026-10-07", "end": "2026-10-09"} { |
| 97 | input := regexp.MustCompile(`<input[^>]*\bname="` + name + `"[^>]*>`).FindString(body) |
| 98 | if !strings.Contains(input, `type="date"`) || !strings.Contains(input, `value="`+want+`"`) { |
| 99 | t.Errorf("%s input on an all-day edit form: %s\nwant type=date value=%s", name, input, want) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // A timed event keeps its datetime inputs. |
| 104 | timed := createEvent(t, h, session, url.Values{ |
| 105 | "title": {"Standup"}, "start": {"2026-10-07T09:30"}, "end": {"2026-10-07T09:45"}, |
| 106 | "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}, |
| 107 | }) |
| 108 | body = getAs(t, h, session, timed+"/edit").Body.String() |
| 109 | input := regexp.MustCompile(`<input[^>]*\bname="start"[^>]*>`).FindString(body) |
| 110 | if !strings.Contains(input, `type="datetime-local"`) || !strings.Contains(input, `value="2026-10-07T09:30"`) { |
| 111 | t.Errorf("timed edit form start input: %s", input) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // Without script, ticking or unticking All day cannot swap the input |
| 116 | // under it, so each shape of form must accept the other's values: a |
| 117 | // datetime under All day means its date, and a bare date without it |
| 118 | // means midnight. |
| 119 | func TestAllDayToggleWithoutScriptStillSaves(t *testing.T) { |
| 120 | app, session := signedIn(t, "someone@example.com") |
| 121 | h := app.Handler() |
| 122 | |
| 123 | loc := createEvent(t, h, session, url.Values{ |
| 124 | "title": {"Offsite"}, "all_day": {"1"}, "start": {"2026-10-07T09:00"}, "end": {"2026-10-08T00:00"}, |
| 125 | "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"none"}, |
| 126 | }) |
| 127 | body := getAs(t, h, session, loc+"/edit").Body.String() |
| 128 | if in := regexp.MustCompile(`<input[^>]*\bname="start"[^>]*>`).FindString(body); !strings.Contains(in, `value="2026-10-07"`) { |
| 129 | t.Errorf("an all-day event submitted with a datetime did not store its date: %s", in) |
| 130 | } |
| 131 | |
| 132 | cal := regexp.MustCompile(`<option value="(\d+)"[^>]*\bselected\b`).FindStringSubmatch(body) |
| 133 | if cal == nil { |
| 134 | t.Fatal("no calendar selected on the edit form") |
| 135 | } |
| 136 | rec := postAs(t, h, session, loc, url.Values{ |
| 137 | "calendar_id": {cal[1]}, "title": {"Offsite"}, "start": {"2026-10-07"}, "end": {"2026-10-08"}, |
| 138 | "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"none"}, |
| 139 | }) |
| 140 | if rec.Code != http.StatusSeeOther { |
| 141 | t.Fatalf("unticking All day on a date form: status %d\n%s", rec.Code, rec.Body.String()) |
| 142 | } |
| 143 | body = getAs(t, h, session, loc+"/edit").Body.String() |
| 144 | if in := regexp.MustCompile(`<input[^>]*\bname="start"[^>]*>`).FindString(body); !strings.Contains(in, `value="2026-10-07T00:00"`) { |
| 145 | t.Errorf("a timed save of a bare date did not mean midnight: %s", in) |
| 146 | } |
| 147 | } |
| 148 | |