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", `