Track five things a day for seventy-five days
The workout, the protein, the calories, the water, and three lines of gratitude. One page per day, a card of seventy-five tiles that fill by how many of the five landed, and a missed day left as a gap rather than a reset. Single Go binary with no dependencies. The log is one JSON file on a mounted volume, written atomically and never silently replaced when it fails to parse, since it is the one thing here that cannot be recreated. Auth is Traefik basic auth at the ingress; the app has no login of its own, so every POST checks Sec-Fetch-Site to stop another origin posting with those credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
199
internal/store/store_test.go
Normal file
199
internal/store/store_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func settings() Settings {
|
||||
s := Defaults()
|
||||
s.StartDate = "2026-06-20"
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDayNumber(t *testing.T) {
|
||||
s := settings()
|
||||
|
||||
cases := []struct {
|
||||
date string
|
||||
want int
|
||||
}{
|
||||
{"2026-06-19", 0}, // the day before day 1
|
||||
{"2026-06-20", 1}, // day 1
|
||||
{"2026-06-21", 2}, // and the day after
|
||||
{"2026-07-31", 42}, // across a month boundary
|
||||
{"2026-09-02", 75}, // the last day
|
||||
{"2026-09-03", 0}, // one past the end
|
||||
{"nonsense", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := s.DayNumber(c.date); got != c.want {
|
||||
t.Errorf("DayNumber(%q) = %d, want %d", c.date, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The clocks go back on 2026-11-01 in America/Los_Angeles. Dates are parsed in
|
||||
// UTC precisely so that the day either side of it still counts as one day.
|
||||
func TestDayNumberAcrossDST(t *testing.T) {
|
||||
s := Defaults()
|
||||
s.StartDate = "2026-10-30"
|
||||
s.Length = 10
|
||||
|
||||
for i, date := range []string{"2026-10-30", "2026-10-31", "2026-11-01", "2026-11-02"} {
|
||||
if got, want := s.DayNumber(date), i+1; got != want {
|
||||
t.Errorf("DayNumber(%q) = %d, want %d", date, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarks(t *testing.T) {
|
||||
s := settings() // protein >= 180, calories <= 2200, water >= 128
|
||||
full := [3]string{"one", "two", "three"}
|
||||
|
||||
t.Run("everything hit", func(t *testing.T) {
|
||||
m := s.Marks(Day{Workout: true, Protein: ptr(180), Calories: ptr(2200),
|
||||
Water: ptr(128), Gratitude: full})
|
||||
if !m.Complete() {
|
||||
t.Fatalf("targets met exactly should score 5, got %d", m.Score())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nothing logged is not a perfect calorie day", func(t *testing.T) {
|
||||
m := s.Marks(Day{})
|
||||
if m.Calories {
|
||||
t.Error("an unlogged calorie count must not count as under the cap")
|
||||
}
|
||||
if m.Score() != 0 {
|
||||
t.Errorf("empty day scored %d, want 0", m.Score())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero is logged", func(t *testing.T) {
|
||||
m := s.Marks(Day{Calories: ptr(0)})
|
||||
if !m.Calories {
|
||||
t.Error("zero calories is under the cap and should count")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("calories as a floor", func(t *testing.T) {
|
||||
over := s
|
||||
over.CalorieMode = "over"
|
||||
if over.Marks(Day{Calories: ptr(2000)}).Calories {
|
||||
t.Error("2000 is below a 2200 floor and should not count")
|
||||
}
|
||||
if !over.Marks(Day{Calories: ptr(2400)}).Calories {
|
||||
t.Error("2400 clears a 2200 floor and should count")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gratitude needs all three", func(t *testing.T) {
|
||||
if s.Marks(Day{Gratitude: [3]string{"one", " ", "three"}}).Gratitude {
|
||||
t.Error("a blank line should not count as written")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSaveAndReopen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "tracker.json")
|
||||
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := st.SaveSettings(settings()); err != nil {
|
||||
t.Fatalf("save settings: %v", err)
|
||||
}
|
||||
day := Day{Date: "2026-07-01", Workout: true, Protein: ptr(190),
|
||||
Gratitude: [3]string{"a", "b", "c"}}
|
||||
if err := st.SaveDay(day); err != nil {
|
||||
t.Fatalf("save day: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
got := reopened.Day("2026-07-01")
|
||||
if !got.Workout || got.Protein == nil || *got.Protein != 190 {
|
||||
t.Fatalf("day did not survive the round trip: %+v", got)
|
||||
}
|
||||
if got.Calories != nil {
|
||||
t.Error("a number that was never entered came back set")
|
||||
}
|
||||
if reopened.Settings().StartDate != "2026-06-20" {
|
||||
t.Error("settings did not survive the round trip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveEmptyDayRemovesIt(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tracker.json")
|
||||
st, _ := Open(path)
|
||||
|
||||
if err := st.SaveDay(Day{Date: "2026-07-01", Workout: true}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
if err := st.SaveDay(Day{Date: "2026-07-01"}); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if st.Day("2026-07-01").Logged() {
|
||||
t.Error("a day cleared of everything should not stay in the file")
|
||||
}
|
||||
}
|
||||
|
||||
// A day handed out must not share memory with the stored one, or editing the
|
||||
// copy would quietly rewrite history.
|
||||
func TestDayIsACopy(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tracker.json")
|
||||
st, _ := Open(path)
|
||||
st.SaveDay(Day{Date: "2026-07-01", Protein: ptr(190)})
|
||||
|
||||
got := st.Day("2026-07-01")
|
||||
*got.Protein = 5
|
||||
|
||||
if again := st.Day("2026-07-01"); *again.Protein != 190 {
|
||||
t.Errorf("stored day changed to %d", *again.Protein)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRefusesUnreadableFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tracker.json")
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Open(path); err == nil {
|
||||
t.Fatal("Open accepted a corrupt file; it would have been overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaysFillsGaps(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tracker.json")
|
||||
st, _ := Open(path)
|
||||
st.SaveSettings(settings())
|
||||
st.SaveDay(Day{Date: "2026-06-22", Workout: true})
|
||||
|
||||
days := st.Days(settings().Start(), 5)
|
||||
if len(days) != 5 {
|
||||
t.Fatalf("got %d days, want 5", len(days))
|
||||
}
|
||||
if days[0].Date != "2026-06-20" || days[4].Date != "2026-06-24" {
|
||||
t.Errorf("wrong span: %s..%s", days[0].Date, days[4].Date)
|
||||
}
|
||||
if days[1].Logged() {
|
||||
t.Error("a day never written should come back empty")
|
||||
}
|
||||
if !days[2].Workout {
|
||||
t.Error("the written day is missing from the span")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsJunkDates(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tracker.json")
|
||||
st, _ := Open(path)
|
||||
if err := st.Import([]byte(`{"days":{"last tuesday":{"workout":true}}}`)); err == nil {
|
||||
t.Fatal("Import accepted a key that is not a date")
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(n int) *int { return &n }
|
||||
Reference in New Issue
Block a user