tracker/tracker_test.go
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{"<strong>รหัสบทบาท</strong>", "<code>SS-07</code>", `<ol><li>เปิด <a href="docs/agent/13-role-matrix.md">Role Matrix</a></li>`,
"<em>comment</em>", "<ul><li>a</li><li>b</li></ul>"} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in %s", want, got)
}
}
evil := string(markdown(`<script>alert(1)</script> [x](javascript:alert(1)) <img src=x onerror=y>`))
if strings.Contains(evil, "<script") || strings.Contains(evil, "javascript:") || strings.Contains(evil, "<img") {
t.Fatalf("unsafe output: %s", evil)
}
if code := string(markdown("`**not bold**`")); !strings.Contains(code, "<code>**not bold**</code>") {
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, "<strong>ข้อ 2</strong>") {
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": "ขั้นสอง <b>",
"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": "ขั้นสอง <b>"`, `"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{"<table>", `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{`<h2 id="2-role-catalog">`, "<th>Code</th>", "<td><code>DEAN</code></td>", "<td>คณบดี | ภาค</td>", `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)
}
}
}