Files
paliad/internal/services/render_spec_test.go
mAi 83a3d27fe0 feat(t-paliad-177): ShapeTimeline enum + render_spec wiring
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.
2026-05-15 00:06:37 +02:00

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)
}
}