Season view: filter the editor to a year (#54) (#66)
Build image / build-and-push (push) Successful in 17s
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:
@@ -293,7 +293,7 @@ func TestCopyGardenDuplicatesContents(t *testing.T) {
|
||||
t.Errorf("settings not carried over: %+v vs source %+v", dup, src)
|
||||
}
|
||||
|
||||
full, err := s.GardenFull(ctx, owner, dup.ID)
|
||||
full, err := s.GardenFull(ctx, owner, dup.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GardenFull(copy): %v", err)
|
||||
}
|
||||
@@ -328,7 +328,7 @@ func TestCopyGardenDuplicatesContents(t *testing.T) {
|
||||
}
|
||||
|
||||
// The source is untouched by the copy.
|
||||
srcFull, err := s.GardenFull(ctx, owner, src.ID)
|
||||
srcFull, err := s.GardenFull(ctx, owner, src.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GardenFull(source): %v", err)
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func TestCopyGardenSkipsRemovedPlantings(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CopyGarden: %v", err)
|
||||
}
|
||||
full, err := s.GardenFull(ctx, owner, dup.ID)
|
||||
full, err := s.GardenFull(ctx, owner, dup.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GardenFull: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
@@ -197,28 +198,80 @@ func (s *Service) DeleteObject(ctx context.Context, actorID, objectID int64) err
|
||||
}
|
||||
|
||||
// GardenFull returns the whole editor payload for a garden the actor can view.
|
||||
func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64) (*FullGarden, error) {
|
||||
// year nil means "what's planted now"; a year means the season view (#54).
|
||||
func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64, year *int) (*FullGarden, error) {
|
||||
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.assembleFull(ctx, g)
|
||||
if year != nil && (*year < minSeasonYear || *year > maxSeasonYear) {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
return s.assembleFullFor(ctx, g, year)
|
||||
}
|
||||
|
||||
// Seasons are bounded to keep a typo'd year from producing a confidently empty
|
||||
// garden; the range is wide enough to cover any real record.
|
||||
const (
|
||||
minSeasonYear = 1900
|
||||
maxSeasonYear = 2200
|
||||
)
|
||||
|
||||
// GardenYears lists the calendar years a garden has planting data for, newest
|
||||
// first, always including the current one.
|
||||
//
|
||||
// This exists so the year selector can offer only years that actually hold
|
||||
// something. A free numeric field invites a typo and an empty view that looks
|
||||
// like data loss rather than a mistake.
|
||||
func (s *Service) GardenYears(ctx context.Context, actorID, gardenID int64) ([]int, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
years, err := s.store.GardenPlantingYears(ctx, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current := s.now().UTC().Year()
|
||||
for _, y := range years {
|
||||
if y == current {
|
||||
return years, nil
|
||||
}
|
||||
}
|
||||
// Newest first, and this year is newest unless the data runs into the future.
|
||||
out := append([]int{current}, years...)
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(out)))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// assembleFull builds the read-only /full payload for an already-authorized
|
||||
// garden: its objects, active plops (with DerivedCount filled in), and the
|
||||
// plants those plops reference. Shared by the authenticated GardenFull and the
|
||||
// unauthenticated PublicGarden read, so both return the identical shape.
|
||||
// garden as it stands NOW. Shared by the unauthenticated PublicGarden read,
|
||||
// which never offers a season view.
|
||||
func (s *Service) assembleFull(ctx context.Context, g *domain.Garden) (*FullGarden, error) {
|
||||
return s.assembleFullFor(ctx, g, nil)
|
||||
}
|
||||
|
||||
// assembleFullFor builds the /full payload: objects, plops (with DerivedCount
|
||||
// filled in), and the plants those plops reference.
|
||||
//
|
||||
// year nil is today's behaviour — active plops only. A year widens it to every
|
||||
// plop whose time in the ground overlapped that year, which necessarily includes
|
||||
// plops since removed, so the referenced-plant lookup has to widen with it or
|
||||
// those plops would render with no icon and no colour.
|
||||
func (s *Service) assembleFullFor(ctx context.Context, g *domain.Garden, year *int) (*FullGarden, error) {
|
||||
objects, err := s.store.ListObjectsForGarden(ctx, g.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plantings, err := s.store.ListActivePlantingsForGarden(ctx, g.ID)
|
||||
var plantings []domain.Planting
|
||||
if year != nil {
|
||||
plantings, err = s.store.ListPlantingsForGardenYear(ctx, g.ID, *year)
|
||||
} else {
|
||||
plantings, err = s.store.ListActivePlantingsForGarden(ctx, g.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plants, err := s.store.ListReferencedPlants(ctx, g.ID)
|
||||
plants, err := s.store.ListReferencedPlants(ctx, g.ID, year)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ type DescribePlanting struct {
|
||||
// object's active plantings (plant, effective count, rough location) — for a
|
||||
// garden the actor can view. Built on GardenFull so it inherits the ACL check.
|
||||
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
|
||||
full, err := s.GardenFull(ctx, actorID, gardenID)
|
||||
full, err := s.GardenFull(ctx, actorID, gardenID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestClearObject(t *testing.T) {
|
||||
if n < 1 {
|
||||
t.Fatalf("cleared %d, want ≥ 1", n)
|
||||
}
|
||||
full, _ := s.GardenFull(ctx, owner, g.ID)
|
||||
full, _ := s.GardenFull(ctx, owner, g.ID, nil)
|
||||
if len(full.Plantings) != 0 {
|
||||
t.Errorf("plantings after clear = %d, want 0", len(full.Plantings))
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestPlantingSoftRemoveLeavesFull(t *testing.T) {
|
||||
})
|
||||
|
||||
// It shows in /full, with a derived count.
|
||||
full, _ := s.GardenFull(context.Background(), owner, g.ID)
|
||||
full, _ := s.GardenFull(context.Background(), owner, g.ID, nil)
|
||||
if len(full.Plantings) != 1 {
|
||||
t.Fatalf("full.Plantings = %d, want 1", len(full.Plantings))
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func TestPlantingSoftRemoveLeavesFull(t *testing.T) {
|
||||
PlantingPatch{SetRemovedAt: true, RemovedAt: &rm}, pl.Version); err != nil {
|
||||
t.Fatalf("soft-remove: %v", err)
|
||||
}
|
||||
full, _ = s.GardenFull(context.Background(), owner, g.ID)
|
||||
full, _ = s.GardenFull(context.Background(), owner, g.ID, nil)
|
||||
if len(full.Plantings) != 0 {
|
||||
t.Errorf("removed plop still in /full: %d", len(full.Plantings))
|
||||
}
|
||||
@@ -371,7 +371,7 @@ func TestDeletePlanting(t *testing.T) {
|
||||
if err := s.DeletePlanting(context.Background(), owner, pl.ID); err != nil {
|
||||
t.Fatalf("DeletePlanting: %v", err)
|
||||
}
|
||||
full, _ := s.GardenFull(context.Background(), owner, g.ID)
|
||||
full, _ := s.GardenFull(context.Background(), owner, g.ID, nil)
|
||||
if len(full.Plantings) != 0 {
|
||||
t.Errorf("planting still present after delete: %d", len(full.Plantings))
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ func TestCopiedGardenDoesNotInheritSeedLots(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CopyGarden: %v", err)
|
||||
}
|
||||
full, err := s.GardenFull(ctx, owner, copied.ID)
|
||||
full, err := s.GardenFull(ctx, owner, copied.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GardenFull: %v", err)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestGardenACLMatrix(t *testing.T) {
|
||||
actor int64
|
||||
want error
|
||||
}{{owner, nil}, {editor, nil}, {viewer, nil}, {stranger, domain.ErrNotFound}} {
|
||||
_, err := s.GardenFull(ctx, tc.actor, g.ID)
|
||||
_, err := s.GardenFull(ctx, tc.actor, g.ID, nil)
|
||||
wantErr(t, "readFull", err, tc.want)
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ func TestUpdateAndRemoveShare(t *testing.T) {
|
||||
t.Errorf("self-leave: %v", err)
|
||||
}
|
||||
// After leaving, the garden is invisible again.
|
||||
if _, err := s.GardenFull(ctx, friend, g.ID); !errors.Is(err, domain.ErrNotFound) {
|
||||
if _, err := s.GardenFull(ctx, friend, g.ID, nil); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("after leaving, read = %v, want ErrNotFound", err)
|
||||
}
|
||||
// A non-participant can't remove someone else's share.
|
||||
|
||||
Reference in New Issue
Block a user