ความคืบหน้างาน ไฟล์ต้นฉบับ

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