Slice 4 step 1 (faraday-Q7). RenderShape gets a fourth member
ShapeTimeline, AllShapes extends, Validate accepts it. The
companion TimelineConfig struct stores the saved palette / density /
range-preset for a CV-timeline view so re-opening the view restores
the same visual settings — same vocabulary as the standalone
/projects/{id}/chart URL state, just persisted in user_views.render_spec
instead of the URL.
Validator mirrors the frontend's enum guards:
- known palettes (default | kind-coded | track-coded | high-contrast | print)
- known densities (compact | standard | spacious)
- known range presets (1y | 2y | all | custom)
- ISO-date strings length-bounded to 32 chars so a hostile editor
can't bloat the jsonb column.
Tests pin every accept/reject path in TestRenderSpec_TimelineConfigValidates.
Design ref: docs/design-project-chart-2026-05-09.md §11.5 + §14 Q7.
154 lines
4.9 KiB
Go
154 lines
4.9 KiB
Go
package services
|
|
|
|
// Pure-Go tests for RenderSpec.
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func TestRenderSpec_HappyPath(t *testing.T) {
|
|
s := DefaultRenderSpec()
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("default render spec must validate: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
|
|
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline}
|
|
for _, sh := range cases {
|
|
t.Run(string(sh), func(t *testing.T) {
|
|
s := RenderSpec{Shape: sh}
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("shape %q must validate: %v", sh, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_TimelineConfigValidates(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cfg TimelineConfig
|
|
ok bool
|
|
}{
|
|
{"empty defaults are fine", TimelineConfig{}, true},
|
|
{"known palette", TimelineConfig{Palette: "kind-coded"}, true},
|
|
{"known density", TimelineConfig{Density: "compact"}, true},
|
|
{"known range preset", TimelineConfig{RangePreset: "2y"}, true},
|
|
{"custom range with bounds", TimelineConfig{RangePreset: "custom", RangeFrom: "2026-01-01", RangeTo: "2026-12-31"}, true},
|
|
{"unknown palette rejects", TimelineConfig{Palette: "neon"}, false},
|
|
{"unknown density rejects", TimelineConfig{Density: "tiny"}, false},
|
|
{"unknown range rejects", TimelineConfig{RangePreset: "10y"}, false},
|
|
{"oversized range_from rejects", TimelineConfig{RangeFrom: string(make([]byte, 64))}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeTimeline, Timeline: &tc.cfg}
|
|
err := s.Validate()
|
|
if tc.ok && err != nil {
|
|
t.Fatalf("expected ok, got error: %v", err)
|
|
}
|
|
if !tc.ok && !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("expected ErrInvalidInput, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_UnknownShapeRejects(t *testing.T) {
|
|
s := RenderSpec{Shape: "kanban"}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown shape must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_ListColumnEnum(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: []string{"date", "bogus"}}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown list column must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_KnownListColumnsAccepted(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: KnownListColumns}}
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("known columns must validate: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_ListSortEnum(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Sort: "weird"}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown sort must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_ListDensityEnum(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Density: "huge"}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown density must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_CardsGroupByEnum(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeCards, Cards: &CardsConfig{GroupBy: "month"}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown group_by must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_CalendarViewEnum(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeCalendar, Calendar: &CalendarConfig{DefaultView: "year"}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown default_view must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_RowActionEnum(t *testing.T) {
|
|
for _, action := range KnownRowActions {
|
|
t.Run(string(action), func(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeList, List: &ListConfig{RowAction: action}}
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("known row_action %q must validate: %v", action, err)
|
|
}
|
|
})
|
|
}
|
|
s := RenderSpec{Shape: ShapeList, List: &ListConfig{RowAction: "delete"}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown row_action must reject, got %v", err)
|
|
}
|
|
// Empty defaults to navigate at the renderer level — schema accepts.
|
|
empty := RenderSpec{Shape: ShapeList, List: &ListConfig{}}
|
|
if err := empty.Validate(); err != nil {
|
|
t.Fatalf("empty row_action must validate (defaults to navigate): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_RoundTrip(t *testing.T) {
|
|
original := RenderSpec{
|
|
Shape: ShapeList,
|
|
List: &ListConfig{
|
|
Columns: []string{"time", "actor", "title", "project"},
|
|
Sort: SortDateDesc,
|
|
Density: DensityCompact,
|
|
},
|
|
Cards: &CardsConfig{GroupBy: CardsGroupByDay, Sort: SortDateAsc},
|
|
Calendar: &CalendarConfig{DefaultView: CalendarMonth},
|
|
}
|
|
b, err := MarshalRenderSpec(original)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
parsed, err := UnmarshalRenderSpec(b)
|
|
if err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if parsed.Shape != original.Shape {
|
|
t.Errorf("shape mismatch: %v vs %v", parsed.Shape, original.Shape)
|
|
}
|
|
if parsed.List == nil || parsed.List.Density != DensityCompact {
|
|
t.Errorf("list config not preserved: %+v", parsed.List)
|
|
}
|
|
}
|