package main import ( "bufio" "math" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" ) func writeFile(t *testing.T, dir, name, body string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } func TestStoreKeepsLastGoodSnapshotOnBadJSON(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", `[{"id":"m1","title":"A","epics":[{"id":"e1","tasks":[{"id":"T-1","title":"x","status":"done"}]}]}]`) s := NewStore(dir) if _, err, _ := s.Get(); err != nil { t.Fatalf("unexpected error: %v", err) } writeFile(t, dir, "milestones.json", `[{"id":"m1",`) s.Reload() snap, err, _ := s.Get() if err == nil { t.Fatal("expected a load error for broken JSON") } if len(snap.Milestones) != 1 || snap.Milestones[0].Epics[0].Tasks[0].ID != "T-1" { t.Fatalf("previous snapshot was not kept: %+v", snap) } } func TestValidateRejectsUnknownStatusAndDuplicateIDs(t *testing.T) { cases := map[string]string{ "unknown status": `[{"id":"m1","epics":[{"id":"e1","tasks":[{"id":"T-1","status":"doing"}]}]}]`, "duplicate task id": `[{"id":"m1","epics":[{"id":"e1","tasks":[{"id":"T-1","status":"todo"},{"id":"T-1","status":"done"}]}]}]`, "task id reused": `[{"id":"m1","epics":[{"id":"e1","tasks":[]},{"id":"e2","tasks":[{"id":"e1","status":"todo"}]}]}]`, "missing task id": `[{"id":"m1","epics":[{"id":"e1","tasks":[{"status":"todo"}]}]}]`, "missing epic id": `[{"id":"m1","epics":[{"tasks":[]}]}]`, "missing milestone id": `[{"epics":[]}]`, } for name, body := range cases { t.Run(name, func(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", body) if _, err := loadSnapshot(dir); err == nil { t.Fatal("expected validation error") } }) } } func TestOptionalFilesMayBeMissing(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", `[]`) if _, err := loadSnapshot(dir); err != nil { t.Fatalf("tests.json and problems.json should be optional: %v", err) } } func TestBuildPageCountsAndCurrentTask(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", `[ {"id":"m0","no":0,"title":"Prep","epics":[ {"id":"e-a","title":"A","tasks":[ {"id":"T-1","status":"done"}, {"id":"T-2","status":"in_progress","steps":[{"text":"a","done":true},{"text":"b"},{"text":"c"}]}]}, {"id":"e-b","title":"B","tasks":[ {"id":"T-3","status":"review","review":{"requested_at":"2026-10-30"}}, {"id":"T-4","status":"review","review":{"requested_at":"2026-10-01"}}, {"id":"T-5","status":"todo"}]}]}]`) p := buildPage(NewStore(dir), "overview", "x") if got := p.Counts; got.Total != 5 || got.Done != 1 || got.InProgress != 1 || got.Review != 2 || got.Todo != 1 { t.Fatalf("counts = %+v", got) } if len(p.Current) != 1 || len(p.Current[0].Done) != 1 || len(p.Current[0].Left) != 2 { t.Fatalf("current task split wrong: %+v", p.Current) } if p.Current[0].Percent() != 33 { t.Fatalf("percent = %d", p.Current[0].Percent()) } if p.Reviews[0].ID != "T-4" { t.Fatalf("oldest review should come first, got %s", p.Reviews[0].ID) } ms := p.Milestones[0] if ms.Epics[0].Code != "E0.1" || ms.Epics[1].Code != "E0.2" { t.Fatalf("epic codes = %s, %s", ms.Epics[0].Code, ms.Epics[1].Code) } if ms.Counts.Total != 5 || ms.Epics[1].Counts.Review != 2 { t.Fatalf("milestone/epic counts = %+v / %+v", ms.Counts, ms.Epics[1].Counts) } if w := p.Current[0].Where; w.EpicCode != "E0.1" || w.MilestoneNo != 0 || w.EpicID != "e-a" { t.Fatalf("current task location = %+v", w) } if p.Epics["e-b"].EpicTitle != "B" { t.Fatal("epic lookup for problems missing") } } func TestCheckpointsSortedNewestFirstAndValidated(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", `[]`) writeFile(t, dir, "checkpoints.json", `[ {"id":"C-1","at":"2026-10-29T09:00:00+07:00","title":"old"}, {"id":"C-2","at":"2026-10-30T15:00:00+07:00","title":"new"}, {"id":"C-3","at":"2026-10-30","title":"date only"}]`) snap, err := loadSnapshot(dir) if err != nil { t.Fatal(err) } if got := []string{snap.Checkpoints[0].ID, snap.Checkpoints[1].ID, snap.Checkpoints[2].ID}; got[0] != "C-2" || got[1] != "C-3" || got[2] != "C-1" { t.Fatalf("order = %v", got) } for name, body := range map[string]string{ "bad time": `[{"id":"C-1","at":"yesterday"}]`, "duplicate id": `[{"id":"C-1","at":"2026-10-30"},{"id":"C-1","at":"2026-10-30"}]`, "missing id": `[{"at":"2026-10-30"}]`, } { writeFile(t, dir, "checkpoints.json", body) if _, err := loadSnapshot(dir); err == nil { t.Fatalf("%s: expected an error", name) } } } func TestThaiDateUsesBuddhistEra(t *testing.T) { if got := thaiDate(parseDay("2026-10-30")); got != "30 ต.ค. 2569" { t.Fatalf("got %q", got) } } func get(t *testing.T, h http.Handler, path string) (int, string) { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) return rec.Code, rec.Body.String() } func TestAllPagesRender(t *testing.T) { store := NewStore("data") // the real seed data must stay valid if _, err, _ := store.Get(); err != nil { t.Fatalf("seed data invalid: %v", err) } snap, _, _ := store.Get() m := snap.Milestones[0] e := m.Epics[0] task := e.Tasks[0] h := newMux(store, NewHub()) paths := []string{"/", "/milestones", "/checkpoints", "/tests", "/problems", "/reviews", "/milestones/" + m.ID, "/milestones/" + m.ID + "/" + e.ID, "/tasks/" + task.ID} for _, path := range paths { code, body := get(t, h, path) if code != 200 || !strings.Contains(body, `id="main"`) { t.Fatalf("%s: status %d", path, code) } } if _, body := get(t, h, "/tasks/"+task.ID); !strings.Contains(body, `class="crumbs"`) || !strings.Contains(body, "/milestones/"+m.ID+"/"+e.ID) { t.Fatal("task page should have breadcrumbs linking back to its epic") } for _, path := range []string{"/nope", "/milestones/no-such", "/milestones/" + m.ID + "/no-such", "/tasks/NO-1"} { if code, _ := get(t, h, path); code != 404 { t.Fatalf("%s: status %d, want 404", path, code) } } } func TestSubPagesWithSeveralMilestones(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", `[ {"id":"m0","no":0,"title":"Prep","epics":[{"id":"e0a","title":"Docs","tasks":[{"id":"T-1","status":"done"}]}]}, {"id":"m1","no":1,"title":"Platform core","epics":[ {"id":"m1-iam","title":"IAM","tasks":[{"id":"T-10","title":"Login","status":"in_progress","steps":[{"text":"a","done":true},{"text":"b"}]}]}, {"id":"m1-org","title":"ORG","tasks":[]}]}]`) h := newMux(NewStore(dir), NewHub()) _, body := get(t, h, "/milestones/m1") for _, want := range []string{"Platform core", "E1.1", "E1.2", "/milestones/m1/m1-iam", `class="nav-sub is-on"`} { if !strings.Contains(body, want) { t.Fatalf("milestone page missing %q", want) } } if !strings.Contains(body, `href="/milestones/m0"`) { t.Fatal("sidebar should list every milestone") } if code, _ := get(t, h, "/milestones/m0/m1-iam"); code != 404 { t.Fatal("an epic under the wrong milestone must be 404") } _, body = get(t, h, "/tasks/T-10") for _, want := range []string{"Login", "E1.1 IAM", "ส่วนที่ยังเหลือก่อนปิด Task"} { if !strings.Contains(body, want) { t.Fatalf("task page missing %q", want) } } if code, _ := get(t, h, "/milestones/m1/m1-org"); code != 200 { t.Fatal("an empty epic should still render") } } func TestWatchBroadcastsOnDataChange(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", `[]`) store := NewStore(dir) hub := NewHub() ch, _ := hub.subscribe() defer hub.unsubscribe(ch) go Watch(t.Context(), store, hub, dir, 20*time.Millisecond) time.Sleep(60 * time.Millisecond) // Ensure a different mtime/size even on coarse filesystems. writeFile(t, dir, "milestones.json", `[{"id":"m1","title":"New","epics":[]}]`) select { case <-ch: case <-time.After(2 * time.Second): t.Fatal("no broadcast after data change") } if snap, _, _ := store.Get(); len(snap.Milestones) != 1 { t.Fatal("store was not reloaded") } } func TestEventsStreamSendsHelloAndUpdate(t *testing.T) { hub := NewHub() srv := httptest.NewServer(hub) defer srv.Close() resp, err := http.Get(srv.URL) if err != nil { t.Fatal(err) } defer resp.Body.Close() if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { t.Fatalf("content type = %q", ct) } r := bufio.NewReader(resp.Body) expectEvent(t, r, "hello") hub.Broadcast() expectEvent(t, r, "update") } func expectEvent(t *testing.T, r *bufio.Reader, name string) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { line, err := r.ReadString('\n') if err != nil { t.Fatal(err) } if strings.TrimSpace(line) == "event: "+name { return } } t.Fatalf("event %q not received", name) } func TestMarkdownFormatsAndEscapes(t *testing.T) { got := string(markdown("ตรวจ **รหัสบทบาท** และ `SS-07`\n\n1. เปิด [Role Matrix](docs/agent/13-role-matrix.md)\n2. ตอบใน *comment*\n\n- a\n- b")) for _, want := range []string{"รหัสบทบาท", "SS-07", `
  1. เปิด Role Matrix
  2. `, "comment", ""} { if !strings.Contains(got, want) { t.Fatalf("missing %q in %s", want, got) } } evil := string(markdown(` [x](javascript:alert(1)) `)) if strings.Contains(evil, "**not bold**") { t.Fatalf("code span was formatted: %s", code) } } func TestReviewTabsByState(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", `[{"id":"m0","no":0,"title":"P","epics":[{"id":"e","title":"E","tasks":[ {"id":"T-1","status":"review","review":{"requested_at":"2026-10-30"}}, {"id":"T-2","status":"in_progress","review":{"state":"rejected","decision":"แก้ **ข้อ 2**"}}, {"id":"T-3","status":"review","review":{"state":"hold"}}, {"id":"T-4","status":"done","review":{"state":"done","checklist":[{"text":"ตรวจ","done":true}]}}, {"id":"T-5","status":"todo"}]}]}]`) h := newMux(NewStore(dir), NewHub()) for state, want := range map[string]string{"": "T-1", "todo": "T-1", "rejected": "T-2", "hold": "T-3", "done": "T-4", "bogus": "T-1"} { _, body := get(t, h, "/reviews?state="+state) if !strings.Contains(body, `id="`+want+`"`) { t.Fatalf("state %q should list %s", state, want) } } _, body := get(t, h, "/reviews?state=rejected") if !strings.Contains(body, "ข้อ 2") { t.Fatal("decision should render as Markdown") } if p := buildPage(NewStore(dir), "overview", ""); len(p.Reviews) != 1 { t.Fatalf("only todo reviews count as waiting, got %d", len(p.Reviews)) } writeFile(t, dir, "milestones.json", `[{"id":"m0","epics":[{"id":"e","tasks":[{"id":"T-1","status":"review","review":{"state":"maybe"}}]}]}]`) if _, err := loadSnapshot(dir); err == nil { t.Fatal("unknown review state should be rejected") } } func TestTestViewFiltersByAudience(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", `[]`) writeFile(t, dir, "tests.json", `[{"id":"R-1","suite":"S","date":"2026-10-30","groups":[ {"name":"ข้อมูลถูกต้อง","audience":"ba","cases":[{"name":"a","status":"pass"}]}, {"name":"SSE","audience":"tech","cases":[{"name":"b","status":"fail"}]}, {"name":"หน้าเว็บ","audience":"both","cases":[{"name":"c","status":"pass"}]}]}]`) p := buildPage(NewStore(dir), "tests", "") p.SetTestView("ba") if len(p.TestRuns) != 1 || len(p.TestRuns[0].Shown) != 2 || p.TestRuns[0].Fail != 0 { t.Fatalf("ba view = %+v", p.TestRuns) } p.SetTestView("tech") if p.TestRuns[0].Fail != 1 || p.TestCounts[""] != 3 || p.TestCounts["ba"] != 2 || p.TestCounts["tech"] != 2 { t.Fatalf("tech view = %+v counts %v", p.TestRuns[0], p.TestCounts) } if !p.Tests[0].Failing() { t.Fatal("a failed written check makes the run failing") } } const reviewFixture = `[ { "id": "m0", "no": 0, "title": "P", "custom_field": "kept", "epics": [ { "id": "e", "title": "E", "tasks": [ { "id": "T-1", "title": "งานทดสอบ", "status": "review", "review": { "reviewer": "BA", "state": "todo", "checklist": [ { "text": "ขั้นแรก", "done": false }, { "text": "ขั้นสอง ", "done": false } ] } }, { "id": "T-2", "title": "ไม่มีรีวิว", "status": "todo" } ] } ] } ] ` func postReview(t *testing.T, h http.Handler, tid string, form url.Values, origin string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest("POST", "/reviews/"+tid, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if origin != "" { req.Header.Set("Origin", origin) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func TestReviewFormApproveRejectHold(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", reviewFixture) store := NewStore(dir) h := newMux(store, NewHub()) origin := "http://example.com" // httptest requests use Host example.com // Reject needs a reason; the form comes back with the text kept. rec := postReview(t, h, "T-1", url.Values{"outcome": {"reject"}, "reviewer": {"สมชาย"}}, origin) if rec.Code != 400 || !strings.Contains(rec.Body.String(), "ต้องเขียนผลการรีวิว") || !strings.Contains(rec.Body.String(), `value="สมชาย"`) { t.Fatalf("reject without reason: %d", rec.Code) } rec = postReview(t, h, "T-1", url.Values{"outcome": {"reject"}, "decision": {"แก้ **ข้อ 2**"}, "reviewer": {"สมชาย"}, "checklist": {"0"}}, origin) if rec.Code != 303 || !strings.Contains(rec.Header().Get("Location"), "state=rejected") { t.Fatalf("reject: %d %s", rec.Code, rec.Header().Get("Location")) } snap, _, _ := store.Get() task := snap.Milestones[0].Epics[0].Tasks[0] if task.Status != StatusInProgress || task.Review.State != ReviewRejected || task.Review.DecidedBy != "สมชาย" || !task.Review.Checklist[0].Done || task.Review.Checklist[1].Done || len(task.Review.History) != 1 { t.Fatalf("after reject: %+v %+v", task.Status, task.Review) } rec = postReview(t, h, "T-1", url.Values{"outcome": {"approve"}, "reviewer": {"สมชาย"}, "checklist": {"0", "1"}, "return": {"task"}}, origin) if rec.Code != 303 || !strings.HasPrefix(rec.Header().Get("Location"), "/tasks/T-1") { t.Fatalf("approve: %d %s", rec.Code, rec.Header().Get("Location")) } snap, _, _ = store.Get() task = snap.Milestones[0].Epics[0].Tasks[0] if task.Status != StatusReviewed || task.Finished == "" || task.Review.State != ReviewDone || len(task.Review.History) != 2 { t.Fatalf("after approve: %+v", task) } // Only the review, status and finished date changed; other fields, key // order and unescaped text are kept. b, _ := os.ReadFile(filepath.Join(dir, "milestones.json")) got := string(b) for _, want := range []string{`"custom_field": "kept"`, `"text": "ขั้นสอง "`, `"title": "ไม่มีรีวิว"`} { if !strings.Contains(got, want) { t.Fatalf("file lost %q:\n%s", want, got) } } if strings.Index(got, `"no": 0`) > strings.Index(got, `"title": "P"`) { t.Fatal("key order changed") } rec = postReview(t, h, "T-1", url.Values{"outcome": {"hold"}, "decision": {"รอข้อมูลจาก BA"}}, origin) snap, _, _ = store.Get() if task = snap.Milestones[0].Epics[0].Tasks[0]; rec.Code != 303 || task.Status != StatusReview || task.Review.State != ReviewHold { t.Fatalf("hold: %d %+v", rec.Code, task) } } func TestReviewFormRejectsCrossSiteAndUnknownTasks(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", reviewFixture) h := newMux(NewStore(dir), NewHub()) form := url.Values{"outcome": {"approve"}} if rec := postReview(t, h, "T-1", form, "http://evil.example"); rec.Code != 403 { t.Fatalf("cross-site post: %d", rec.Code) } if rec := postReview(t, h, "T-1", form, ""); rec.Code != 403 { t.Fatalf("post without Origin or Referer: %d", rec.Code) } if rec := postReview(t, h, "NO-1", form, "http://example.com"); rec.Code != 404 { t.Fatalf("unknown task: %d", rec.Code) } if rec := postReview(t, h, "T-2", form, "http://example.com"); rec.Code != 400 { t.Fatalf("task without review: %d", rec.Code) } b, _ := os.ReadFile(filepath.Join(dir, "milestones.json")) if string(b) != reviewFixture { t.Fatal("rejected requests must not change the file") } } func TestFileViewerShowsRepoFilesSafely(t *testing.T) { old := repoRoot repoRoot = ".." defer func() { repoRoot = old }() h := newMux(NewStore("data"), NewHub()) code, body := get(t, h, "/files/docs/agent/13-role-matrix.md") if code != 200 { t.Fatalf("role matrix: %d", code) } for _, want := range []string{"", `class="viewer-toc"`, `id="row-filter"`, `id="3-approval-authority-matrix"`} { if !strings.Contains(body, want) { t.Fatalf("role matrix page missing %q", want) } } if code, body := get(t, h, "/files/tracker/"); code != 200 || !strings.Contains(body, "main.go") { t.Fatalf("folder listing: %d", code) } if code, _ := get(t, h, "/files/tracker"); code != 301 { t.Fatalf("folder without slash should redirect, got %d", code) } for _, bad := range []string{"/files/.git/config", "/files/tracker/../../../etc/passwd", "/files/nope.md", "/files/%2e%2e/%2e%2e/etc/passwd"} { // The router may first redirect to the cleaned path; follow it once. rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", bad, nil)) if loc := rec.Header().Get("Location"); rec.Code/100 == 3 && loc != "" { rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", loc, nil)) } if rec.Code != 404 || strings.Contains(rec.Body.String(), "root:") { t.Fatalf("%s: got %d, want 404", bad, rec.Code) } } if refHref("docs/agent/14-security-matrix.md") != "/files/docs/agent/14-security-matrix.md" || refHref("tracker/") != "/files/tracker/" { t.Fatal("refHref should link existing repo paths") } if refHref("not a file") != "" || refHref("https://example.com") != "" || refHref(".git/HEAD") != "" { t.Fatal("refHref should ignore text that is not a viewable repo path") } _, body = get(t, h, "/reviews") if !strings.Contains(body, `href="/files/docs/agent/13-role-matrix.md" target="_blank"`) { t.Fatal("refs on the review page should open the file viewer in a new tab") } } func TestMarkdownTablesAndHeadingIDs(t *testing.T) { got := string(renderMarkdown("## 2. Role catalog\n\n| Code | Thai |\n|---|---|\n| `DEAN` | คณบดี \\| ภาค |\n\n## A2. โครงสร้างหน่วยงาน", mdOptions{headingIDs: true})) for _, want := range []string{`

    `, "

    ", "", "", `id="a2-โครงสร้างหน่วยงาน"`} { if !strings.Contains(got, want) { t.Fatalf("missing %q in %s", want, got) } } } func TestReviewedCountsAsComplete(t *testing.T) { var c Counts for _, s := range []string{StatusDone, StatusReviewed, StatusReview, StatusTodo} { c.add(s) } if c.Reviewed != 1 || c.Done != 1 || c.Complete() != 2 || c.Percent() != 50 { t.Fatalf("counts = %+v", c) } } func TestTimelineLayout(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", `[{"id":"m1","no":1,"title":"Core","target":"2026-11-20","epics":[{"id":"e1","title":"IAM","tasks":[ {"id":"T-1","status":"done","started":"2026-11-02","finished":"2026-11-02"}, {"id":"T-2","status":"in_progress","started":"2026-11-03T09:00:00+07:00"}, {"id":"T-3","status":"todo","start":"2026-11-10","due":"2026-11-12"}, {"id":"T-4","status":"todo"}]}]}]`) p := buildPage(NewStore(dir), "timeline", "") now := time.Date(2026, 11, 4, 12, 0, 0, 0, ict) row := func(tl Timeline, id string) TLRow { for _, r := range tl.Rows { if r.ID == id { return r } } t.Fatalf("row %s missing", id) return TLRow{} } tl := buildTimeline(p.Milestones, "day", now) if tl.Scale.Name != "day" || len(tl.Rows) != 6 { t.Fatalf("scale %s rows %d", tl.Scale.Name, len(tl.Rows)) } near := func(a, b float64) bool { return math.Abs(a-b) < 1e-9 } if b := row(tl, "T-1").Bar; b == nil || !near(b.Width, tl.UnitPct) || tl.Width != float64(len(tl.Minor))*44 { t.Fatalf("one-day task should be one day wide: %+v", b) } if b := row(tl, "T-2").Bar; b == nil || !near(b.Left+b.Width, tl.Now) { t.Fatalf("open task should end at now (%.1f): %+v", tl.Now, b) } if r := row(tl, "T-3"); r.Bar != nil || r.Plan == nil || !near(r.Plan.Width, 3*tl.UnitPct) { t.Fatalf("planned task: bar %+v plan %+v", r.Bar, r.Plan) } if r := row(tl, "T-4"); r.Bar != nil || r.Plan != nil || tl.Unscheduled != 1 { t.Fatalf("task without dates: %+v unscheduled %d", r, tl.Unscheduled) } e, m := row(tl, "e1"), row(tl, "m1") if e.Bar == nil || !near(e.Bar.Left, row(tl, "T-1").Bar.Left) || e.Bar.Left+e.Bar.Width < row(tl, "T-3").Plan.Left+row(tl, "T-3").Plan.Width-1e-9 { t.Fatalf("epic bar should cover its tasks: %+v", e.Bar) } if m.Marker == nil || m.Bar == nil || m.Percent != 25 { t.Fatalf("milestone: %+v", m) } if got := buildTimeline(p.Milestones, "bogus", now).Scale.Name; got != "day" { t.Fatalf("unknown scale -> %s", got) } if h := buildTimeline(p.Milestones, "hour", now); h.Clipped || len(h.Minor) > 24*21 { t.Fatalf("19 days of work fits the hour view: clipped=%v units=%d", h.Clipped, len(h.Minor)) } far := append([]MilestoneView(nil), p.Milestones...) far[0].Target = "2027-06-30" if h := buildTimeline(far, "hour", now); !h.Clipped || len(h.Minor) != 24*21 || h.Now < 0 { t.Fatalf("a long plan clips the hour view around now: clipped=%v units=%d now=%.0f", h.Clipped, len(h.Minor), h.Now) } if w := buildTimeline(p.Milestones, "week", now); w.Clipped || w.From.Weekday() != time.Monday { t.Fatalf("week scale should start on Monday: %v", w.From) } h := newMux(NewStore(dir), NewHub()) for _, sc := range []string{"", "hour", "day", "week"} { code, body := get(t, h, "/timeline?scale="+sc) if code != 200 || !strings.Contains(body, `id="timeline"`) || !strings.Contains(body, `data-tl-toggle="e1"`) { t.Fatalf("timeline scale %q: %d", sc, code) } } if _, body := get(t, h, "/"); !strings.Contains(body, `href="/timeline"`) { t.Fatal("menu should link to the timeline") } } func TestStaticCaching(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "milestones.json", `[]`) h := newMux(NewStore(dir), NewHub()) _, body := get(t, h, "/") if want := `/static/style.css?v=` + staticVersion; !strings.Contains(body, want) { t.Fatalf("page should link %s", want) } for path, want := range map[string]string{ "/static/style.css?v=" + staticVersion: "immutable", "/static/style.css": "no-cache", "/static/style.css?v=old": "no-cache", } { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) if rec.Code != 200 || !strings.Contains(rec.Header().Get("Cache-Control"), want) { t.Fatalf("%s: %d %q, want %s", path, rec.Code, rec.Header().Get("Cache-Control"), want) } } }
    CodeDEANคณบดี | ภาค