Files
paliad/internal/services/dashboard_layout_spec_test.go
mAi f8245a06a6 fix(dashboard): t-paliad-227 — rebuild edit mode on a single 12-col grid (m/paliad#69)
Three issues from Slice B were entangled in the same root cause:

1. **Drag/drop reorder only swapped the first two same-size widgets.**
   Widgets lived in two parents (.container + .dashboard-columns); the
   old applyLayout used parent.appendChild per widget which physically
   moved every .container widget to the END of .container — past the
   .dashboard-columns row, edit-footer, and save-toast. Only the two
   columns inside .dashboard-columns swapped visibly because they
   shared a parent. Cross-row drags appeared to silently no-op.

2. **No resize affordance** — the design's per-widget sizing existed
   only on paper.

3. **Per-widget options were thin** — count + horizon dropdowns only.

This change rebuilds the whole layout primitive on a single 12-column
CSS grid:

Backend (internal/services/):
- DashboardWidgetRef gains x/y/w/h grid coordinates. Validator clamps
  against catalog MinW/MaxW/MinH/MaxH and rejects x+w > 12.
- WidgetDef gains DefaultW/H + MinW/MaxW/MinH/MaxH for the resize clamps.
- WidgetSettingsSchema gains Views ([{id,label_de,label_en}]), CountMax,
  HorizonMax. Validator accepts free-form ints inside [1,CountMax] in
  addition to dropdown presets, plus view-id against schema.
- WidgetCatalog wires views for upcoming-deadlines/-appointments (list,
  calendar), inline-agenda (timeline, list), recent-activity (full,
  compact), plus default sizes per widget.
- FactoryDefaultLayout greedy-packs visible widgets onto the grid,
  tracking row-max height so taller previous neighbours never overlap.

Frontend:
- dashboard.tsx: every widget moved into a single .dashboard-grid
  wrapper; matter-summary converted to a CollapsibleSection so it
  participates in the grid like everything else.
- applyLayout rewritten — never moves DOM nodes; writes inline
  grid-column / grid-row from computed placements. computePlacements
  trusts explicit positions and auto-flows the rest with the same
  rowMaxH-aware packer the backend uses.
- reorderViaDnd swaps (x, y) instead of array order; layout re-sorted
  by (y, x) so the persisted array matches visual order.
- Resize handles in edit mode: bottom-right pointer-drag, cellW/cellH
  derived from live grid metrics, snaps to grid + clamps to schema,
  autosaves on pointerup. Native HTML5 DnD suppressed during resize.
- afterLayoutMutation now materialises every visible widget's
  (x,y,w,h) so the spec stays self-describing — no mixed
  explicit/auto-flow on next render.
- Gear popover expanded: view segmented control, custom count/horizon
  numeric inputs alongside preset dropdowns, size (W/H) + position
  (X/Y) spinners. Every visible widget gets a gear in edit mode.
- View-aware renderers:
  - upcoming-deadlines / -appointments: list (default) or mini-month
    calendar with item dots.
  - inline-agenda: timeline (default) or flat list.
  - recent-activity: full (default) or compact (one-line per row).

CSS:
- .dashboard-grid (12 cols, dense auto-flow); collapses to single
  stack on narrow viewports.
- .dashboard-widget__resize handle (bottom-right diagonal stripes).
- .dashboard-widget__view-group segmented control.
- .dashboard-cal-* mini-calendar.
- .dashboard-activity-list--compact one-line variant.
- Grid items get card chrome via .dashboard-grid > .dashboard-section.

Tests:
- New: AcceptsCustomCountWithinMax, AcceptsValidView,
  RejectsUnknownView, RejectsViewOnNoViewWidget, GridPosition,
  GridSizeOutsideClamps, NoOverlap (greedy packer regression),
  AssignsPositions.
- Updated: BadSettings now asserts a value above CountMax (free-form
  values inside [1,CountMax] are valid; presets stay valid too).

Backwards-compatible: a stored layout without x/y/w/h still loads — the
client's auto-flow placer puts widgets into a clean single column until
the user customises. The first drag / resize / settings tweak
materialises all positions so subsequent renders are deterministic.
2026-05-21 09:54:23 +02:00

397 lines
14 KiB
Go

package services
// Pure-function tests for DashboardLayoutSpec + WidgetCatalog.
// No DB; safe to run in any environment.
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestFactoryDefaultLayout_AllKnownWidgetsPresent(t *testing.T) {
def := FactoryDefaultLayout()
if def.Version != LayoutSpecVersion {
t.Errorf("FactoryDefaultLayout version=%d; want %d", def.Version, LayoutSpecVersion)
}
if len(def.Widgets) != len(KnownWidgetKeys) {
t.Fatalf("FactoryDefaultLayout has %d widgets; want %d", len(def.Widgets), len(KnownWidgetKeys))
}
for i, k := range KnownWidgetKeys {
if def.Widgets[i].Key != k {
t.Errorf("widgets[%d].Key = %q; want %q", i, def.Widgets[i].Key, k)
}
// Slice C: some catalog entries default-hidden (pinned-projects,
// quick-actions) — opt-in via the picker. Factory visibility
// must match the catalog declaration so a user can re-enable a
// widget without going through the picker every time.
catalogDef, ok := LookupWidgetDef(k)
if !ok {
t.Errorf("widgets[%d] %q has no catalog def", i, k)
continue
}
if def.Widgets[i].Visible != catalogDef.DefaultVisible {
t.Errorf("widgets[%d] %q: factory Visible=%v; catalog DefaultVisible=%v",
i, k, def.Widgets[i].Visible, catalogDef.DefaultVisible)
}
}
}
func TestFactoryDefaultLayout_SettingsDefaultsPresent(t *testing.T) {
def := FactoryDefaultLayout()
for _, w := range def.Widgets {
catalogDef, ok := LookupWidgetDef(w.Key)
if !ok {
t.Errorf("factory widget %q is not in catalog", w.Key)
continue
}
hasDefaults := catalogDef.DefaultCount != nil || catalogDef.DefaultHorizon != nil
if hasDefaults && len(w.Settings) == 0 {
t.Errorf("widget %q has catalog defaults but factory layout has empty settings", w.Key)
}
if !hasDefaults && len(w.Settings) > 0 {
t.Errorf("widget %q has no catalog defaults but factory layout has settings %s", w.Key, string(w.Settings))
}
}
}
func TestFactoryDefaultLayout_PassesValidation(t *testing.T) {
def := FactoryDefaultLayout()
if err := def.Validate(); err != nil {
t.Fatalf("factory default failed Validate(): %v", err)
}
}
func TestDashboardLayoutSpec_Validate_WrongVersion(t *testing.T) {
s := DashboardLayoutSpec{Version: 99, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
if !strings.Contains(err.Error(), "version") {
t.Errorf("error %q should mention 'version'", err.Error())
}
}
func TestDashboardLayoutSpec_Validate_TooManyWidgets(t *testing.T) {
widgets := make([]DashboardWidgetRef, LayoutWidgetCap+1)
for i := range widgets {
widgets[i] = DashboardWidgetRef{Key: WidgetDeadlineSummary, Visible: true}
}
s := DashboardLayoutSpec{Version: 1, Widgets: widgets}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
}
func TestDashboardLayoutSpec_Validate_UnknownKey(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: "not-a-real-widget", Visible: true},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
}
func TestDashboardLayoutSpec_Validate_DuplicateKey(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetDeadlineSummary, Visible: true},
{Key: WidgetDeadlineSummary, Visible: false},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
if !strings.Contains(err.Error(), "duplicate") {
t.Errorf("error %q should mention 'duplicate'", err.Error())
}
}
func TestDashboardLayoutSpec_Validate_BadSettings(t *testing.T) {
// count over CountMax=50 for upcoming-deadlines must be rejected.
// (Values inside [1,CountMax] are accepted free-form per the gear
// pane's numeric input; values inside CountOptions are the curated
// presets. 100 is outside both.)
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 100}`)},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
}
func TestDashboardLayoutSpec_Validate_AcceptsCustomCountWithinMax(t *testing.T) {
// Custom count not in CountOptions but inside CountMax — accepted.
// upcoming-deadlines: CountOptions {1,3,5,10,20}, CountMax 50.
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
}}
if err := s.Validate(); err != nil {
t.Fatalf("Validate rejected custom count=7 within CountMax=50: %v", err)
}
}
func TestDashboardLayoutSpec_Validate_AcceptsValidView(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"view":"calendar"}`)},
}}
if err := s.Validate(); err != nil {
t.Fatalf("Validate rejected legal view=calendar: %v", err)
}
}
func TestDashboardLayoutSpec_Validate_RejectsUnknownView(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"view":"sankey"}`)},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate accepted unknown view; want ErrInvalidInput, got %v", err)
}
}
func TestDashboardLayoutSpec_Validate_RejectsViewOnNoViewWidget(t *testing.T) {
// deadline-summary has no Views — view knob must be rejected.
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetDeadlineSummary, Visible: true, Settings: json.RawMessage(`{"view":"list"}`)},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate accepted view on no-view widget; want ErrInvalidInput, got %v", err)
}
}
func TestDashboardLayoutSpec_Validate_GridPosition(t *testing.T) {
// X+W overflowing GridColumns must be rejected.
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, X: 8, W: 6},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate accepted x+w overflow; want ErrInvalidInput, got %v", err)
}
}
func TestDashboardLayoutSpec_Validate_GridSizeOutsideClamps(t *testing.T) {
// upcoming-deadlines has MinW=4. W=2 must be rejected.
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 2, H: 1},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate accepted W=2 below MinW=4; want ErrInvalidInput, got %v", err)
}
}
func TestFactoryDefaultLayout_AssignsPositions(t *testing.T) {
def := FactoryDefaultLayout()
// At least one visible widget must have a non-zero position OR
// W set (W=0 means "auto = full width" but factory should assign
// concrete sizes from the catalog).
anySized := false
for _, w := range def.Widgets {
if w.W > 0 {
anySized = true
break
}
}
if !anySized {
t.Fatal("FactoryDefaultLayout did not assign any W; every visible widget should carry a default size")
}
}
// TestFactoryDefaultLayout_NoOverlap verifies the greedy packer in
// FactoryDefaultLayout produces a non-overlapping arrangement. CSS Grid
// would render overlapping items stacked on top of each other — a
// regression here would mean every new paliad user sees a broken
// dashboard until they manually adjust positions.
func TestFactoryDefaultLayout_NoOverlap(t *testing.T) {
def := FactoryDefaultLayout()
type rect struct{ x, y, w, h int }
visible := []rect{}
for _, w := range def.Widgets {
if !w.Visible {
continue
}
if w.W <= 0 || w.H <= 0 {
t.Errorf("factory visible widget %q has non-positive size (w=%d, h=%d)", w.Key, w.W, w.H)
continue
}
visible = append(visible, rect{w.X, w.Y, w.W, w.H})
}
for i, a := range visible {
if a.x+a.w > DashboardGridColumns {
t.Errorf("widget %d overflows grid: x=%d w=%d", i, a.x, a.w)
}
for j, b := range visible {
if i == j {
continue
}
if a.x < b.x+b.w && b.x < a.x+a.w && a.y < b.y+b.h && b.y < a.y+a.h {
t.Errorf("widgets %d and %d overlap: %v vs %v", i, j, a, b)
}
}
}
}
func TestDashboardLayoutSpec_Validate_AcceptsValidSettings(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 5, "horizon_days": 14}`)},
{Key: WidgetInlineAgenda, Visible: true, Settings: json.RawMessage(`{"horizon_days": 60}`)},
{Key: WidgetRecentActivity, Visible: false},
}}
if err := s.Validate(); err != nil {
t.Fatalf("Validate returned %v; want nil", err)
}
}
func TestDashboardLayoutSpec_Validate_SettingsOnNoSettingsWidget(t *testing.T) {
// deadline-summary has no Settings schema.
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetDeadlineSummary, Visible: true, Settings: json.RawMessage(`{"count": 5}`)},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
}
func TestDashboardLayoutSpec_SanitizeForRead_DropsUnknownKeys(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetDeadlineSummary, Visible: true},
{Key: "deprecated-widget", Visible: true},
{Key: WidgetInlineAgenda, Visible: true},
}}
changed := s.SanitizeForRead()
if !changed {
t.Errorf("SanitizeForRead returned false; expected true (one entry dropped)")
}
if len(s.Widgets) != 2 {
t.Errorf("after sanitize: %d widgets; want 2", len(s.Widgets))
}
if s.Widgets[0].Key != WidgetDeadlineSummary || s.Widgets[1].Key != WidgetInlineAgenda {
t.Errorf("after sanitize: keys = %v %v; want %v %v",
s.Widgets[0].Key, s.Widgets[1].Key, WidgetDeadlineSummary, WidgetInlineAgenda)
}
}
func TestDashboardLayoutSpec_SanitizeForRead_NoopOnClean(t *testing.T) {
s := FactoryDefaultLayout()
if s.SanitizeForRead() {
t.Errorf("SanitizeForRead on factory default returned true; want false (already clean)")
}
}
func TestDashboardLayoutSpec_SanitizeForRead_BumpsVersion(t *testing.T) {
s := DashboardLayoutSpec{Version: 0, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
if !s.SanitizeForRead() {
t.Errorf("SanitizeForRead returned false; expected version bump")
}
if s.Version != LayoutSpecVersion {
t.Errorf("after sanitize: Version=%d; want %d", s.Version, LayoutSpecVersion)
}
}
func TestParseDashboardLayoutSpec_RoundTrip(t *testing.T) {
def := FactoryDefaultLayout()
bytes, err := json.Marshal(def)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := ParseDashboardLayoutSpec(bytes)
if err != nil {
t.Fatalf("parse: %v", err)
}
if parsed.Version != def.Version {
t.Errorf("version mismatch: %d vs %d", parsed.Version, def.Version)
}
if len(parsed.Widgets) != len(def.Widgets) {
t.Errorf("widget count mismatch: %d vs %d", len(parsed.Widgets), len(def.Widgets))
}
}
func TestParseDashboardLayoutSpec_InvalidJSON(t *testing.T) {
_, err := ParseDashboardLayoutSpec([]byte(`{not-json}`))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("ParseDashboardLayoutSpec returned %v; want ErrInvalidInput", err)
}
}
func TestWidgetCatalog_AllKnownKeysHaveDef(t *testing.T) {
for _, k := range KnownWidgetKeys {
def, ok := LookupWidgetDef(k)
if !ok {
t.Errorf("KnownWidgetKeys entry %q has no WidgetDef", k)
continue
}
if def.TitleDE == "" || def.TitleEN == "" {
t.Errorf("widget %q missing title (de=%q en=%q)", k, def.TitleDE, def.TitleEN)
}
if def.DescriptionDE == "" || def.DescriptionEN == "" {
t.Errorf("widget %q missing description", k)
}
}
}
func TestWidgetCatalog_SliceC_HasPinnedAndQuickActions(t *testing.T) {
// Slice C activated pinned-projects + quick-actions in the catalog
// AND in KnownWidgetKeys. Lock both via this test so a future
// change can't accidentally remove them without thinking about the
// firm-default migration story.
if _, ok := LookupWidgetDef(WidgetPinnedProjects); !ok {
t.Errorf("WidgetCatalog missing pinned-projects entry")
}
if _, ok := LookupWidgetDef(WidgetQuickActions); !ok {
t.Errorf("WidgetCatalog missing quick-actions entry")
}
var hasPinned, hasQuick bool
for _, k := range KnownWidgetKeys {
if k == WidgetPinnedProjects {
hasPinned = true
}
if k == WidgetQuickActions {
hasQuick = true
}
}
if !hasPinned {
t.Errorf("KnownWidgetKeys missing pinned-projects")
}
if !hasQuick {
t.Errorf("KnownWidgetKeys missing quick-actions")
}
}
func TestWidgetCatalog_NoOrphanDefs(t *testing.T) {
known := make(map[WidgetKey]bool, len(KnownWidgetKeys))
for _, k := range KnownWidgetKeys {
known[k] = true
}
for _, def := range WidgetCatalog() {
if !known[def.Key] {
// Orphans are allowed (forward-compat: pinned-projects const
// exists in widget_catalog.go before its widget module ships).
// But verify the catalog entry is internally coherent.
if def.TitleDE == "" || def.TitleEN == "" {
t.Errorf("orphan catalog entry %q must still have titles", def.Key)
}
}
}
}
func TestWidgetSettingsSchema_NilRejectsNonEmpty(t *testing.T) {
var sch *WidgetSettingsSchema
if err := sch.Validate(json.RawMessage(`{"count": 5}`)); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("nil schema accepted settings; got %v", err)
}
if err := sch.Validate(nil); err != nil {
t.Errorf("nil schema rejected empty settings: %v", err)
}
if err := sch.Validate(json.RawMessage(`null`)); err != nil {
t.Errorf("nil schema rejected 'null' settings: %v", err)
}
}