Season view: filter the editor to a year (#54) (#66)
Build image / build-and-push (push) Successful in 17s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #66.
This commit is contained in:
2026-07-21 05:47:34 +00:00
committed by steve
parent b96ca1ec0a
commit 8c5ddb21ec
17 changed files with 556 additions and 36 deletions
+221 -3
View File
@@ -220,7 +220,7 @@ func TestObjectCrossUserIsNotFound(t *testing.T) {
if err := s.DeleteObject(context.Background(), bob, o.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob delete err = %v, want ErrNotFound", err)
}
if _, err := s.GardenFull(context.Background(), bob, g.ID); !errors.Is(err, domain.ErrNotFound) {
if _, err := s.GardenFull(context.Background(), bob, g.ID, nil); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob /full err = %v, want ErrNotFound", err)
}
}
@@ -236,7 +236,7 @@ func TestDeleteObject(t *testing.T) {
if err := s.DeleteObject(context.Background(), owner, o.ID); err != nil {
t.Fatalf("DeleteObject: %v", err)
}
full, err := s.GardenFull(context.Background(), owner, g.ID)
full, err := s.GardenFull(context.Background(), owner, g.ID, nil)
if err != nil {
t.Fatalf("GardenFull: %v", err)
}
@@ -252,7 +252,7 @@ func TestGardenFullShape(t *testing.T) {
s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindBed, XCM: 300, YCM: 300, WidthCM: 100, HeightCM: 100})
s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindContainer, Shape: domain.ShapeCircle, XCM: 600, YCM: 600, WidthCM: 60, HeightCM: 60})
full, err := s.GardenFull(context.Background(), owner, g.ID)
full, err := s.GardenFull(context.Background(), owner, g.ID, nil)
if err != nil {
t.Fatalf("GardenFull: %v", err)
}
@@ -270,3 +270,221 @@ func TestGardenFullShape(t *testing.T) {
t.Errorf("plants = %v, want empty non-nil", full.Plants)
}
}
// TestSeasonViewIncludesRemovedAndSpanningPlantings — a season is a date range
// over data that already existed; the point is seeing what WAS there.
func TestSeasonViewIncludesRemovedAndSpanningPlantings(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
// Planted and pulled inside 2025.
past, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: -50, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2025-04-01"),
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if _, err := s.UpdatePlanting(ctx, owner, past.ID, PlantingPatch{
SetRemovedAt: true, RemovedAt: strPtr("2025-09-01"),
}, past.Version); err != nil {
t.Fatalf("remove: %v", err)
}
// Garlic: in the ground October 2025, pulled July 2026. Belongs to both.
spanning, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2025-10-15"),
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if _, err := s.UpdatePlanting(ctx, owner, spanning.ID, PlantingPatch{
SetRemovedAt: true, RemovedAt: strPtr("2026-07-10"),
}, spanning.Version); err != nil {
t.Fatalf("remove: %v", err)
}
// Undated: predates the feature, belongs to every year.
undated, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 50, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2025-01-01"),
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if _, err := s.UpdatePlanting(ctx, owner, undated.ID, PlantingPatch{
SetPlantedAt: true, PlantedAt: nil,
}, undated.Version); err != nil {
t.Fatalf("clear planted_at: %v", err)
}
ids := func(year int) map[int64]bool {
t.Helper()
full, err := s.GardenFull(ctx, owner, g.ID, &year)
if err != nil {
t.Fatalf("GardenFull(%d): %v", year, err)
}
out := map[int64]bool{}
for _, p := range full.Plantings {
out[p.ID] = true
}
return out
}
y2025 := ids(2025)
if !y2025[past.ID] || !y2025[spanning.ID] || !y2025[undated.ID] {
t.Errorf("2025 = %v, want all three", y2025)
}
y2026 := ids(2026)
if y2026[past.ID] {
t.Error("a planting pulled in 2025 showed up in 2026")
}
if !y2026[spanning.ID] {
t.Error("a planting spanning the year boundary is missing from 2026")
}
if !y2026[undated.ID] {
t.Error("an undated planting must appear in every year")
}
y2024 := ids(2024)
if y2024[past.ID] || y2024[spanning.ID] {
t.Errorf("2024 predates both dated plantings but returned %v", y2024)
}
if !y2024[undated.ID] {
t.Error("an undated planting must appear in every year, including 2024")
}
// Without a year, /full is exactly what it always was: active plops only.
now, err := s.GardenFull(ctx, owner, g.ID, nil)
if err != nil {
t.Fatalf("GardenFull(now): %v", err)
}
if len(now.Plantings) != 1 || now.Plantings[0].ID != undated.ID {
t.Errorf("live view = %+v, want only the one still in the ground", now.Plantings)
}
// A past season's plops must still render, so their plants come along even
// though none of them is active.
if len(ids(2025)) > 0 {
y := 2025
full, _ := s.GardenFull(ctx, owner, g.ID, &y)
if len(full.Plants) == 0 {
t.Error("season view returned plops with no plants to render them")
}
}
}
// TestGardenYearsOffersOnlyYearsWithData
func TestGardenYearsOffersOnlyYearsWithData(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2023-05-01"),
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if _, err := s.UpdatePlanting(ctx, owner, pl.ID, PlantingPatch{
SetRemovedAt: true, RemovedAt: strPtr("2024-06-01"),
}, pl.Version); err != nil {
t.Fatalf("remove: %v", err)
}
years, err := s.GardenYears(ctx, owner, g.ID)
if err != nil {
t.Fatalf("GardenYears: %v", err)
}
current := s.now().UTC().Year()
want := map[int]bool{2023: true, 2024: true, current: true}
if len(years) != len(want) {
t.Fatalf("years = %v, want exactly %v", years, want)
}
for _, y := range years {
if !want[y] {
t.Errorf("unexpected year %d in %v", y, years)
}
}
// Newest first, so the selector reads in the order people scan.
for i := 1; i < len(years); i++ {
if years[i-1] < years[i] {
t.Errorf("years not newest-first: %v", years)
}
}
}
// TestSeasonYearIsBounded — a typo'd year should be refused, not answered with a
// confidently empty garden.
func TestSeasonYearIsBounded(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
for _, y := range []int{0, 202, 20260, -5} {
if _, err := s.GardenFull(ctx, owner, g.ID, &y); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("year %d accepted (err=%v)", y, err)
}
}
}
// TestSeasonPlantsAreScopedToTheYear — the plant list has to match the plops it
// is there to render. A garden with a decade of history shouldn't load a decade
// of catalog to draw one season, and a season shouldn't carry plants that had
// nothing in the ground that year.
func TestSeasonPlantsAreScopedToTheYear(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
ctx := context.Background()
oldPlant := seedOwnPlant(t, s, owner, 15)
newPlant, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "Later", Category: domain.CategoryHerb, SpacingCM: 20, Color: "#4a7c3f", Icon: "🌱",
})
if err != nil {
t.Fatalf("CreatePlant: %v", err)
}
plant2020, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: oldPlant.ID, XCM: -40, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2020-04-01"),
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if _, err := s.UpdatePlanting(ctx, owner, plant2020.ID, PlantingPatch{
SetRemovedAt: true, RemovedAt: strPtr("2020-09-01"),
}, plant2020.Version); err != nil {
t.Fatalf("remove: %v", err)
}
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: newPlant.ID, XCM: 40, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2026-04-01"),
}); err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
names := func(year int) []string {
t.Helper()
full, err := s.GardenFull(ctx, owner, g.ID, &year)
if err != nil {
t.Fatalf("GardenFull(%d): %v", year, err)
}
out := []string{}
for _, p := range full.Plants {
out = append(out, p.Name)
}
return out
}
if got := names(2020); len(got) != 1 || got[0] != oldPlant.Name {
t.Errorf("2020 plants = %v, want just %q", got, oldPlant.Name)
}
if got := names(2026); len(got) != 1 || got[0] != "Later" {
t.Errorf("2026 plants = %v, want just \"Later\"", got)
}
}