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) } } // TestDashboardLayoutSpec_SanitizeForRead_ClampsOutOfRange covers the // m/paliad#73 recovery path: a stale row in user_dashboard_layouts // carrying a W below MinW (or above MaxW) must be normalised on load so // the user doesn't get stranded with super-slim columns. Pre-fix the // sanitizer only dropped unknown keys; sizes passed through verbatim. func TestDashboardLayoutSpec_SanitizeForRead_ClampsOutOfRange(t *testing.T) { // upcoming-deadlines: MinW=4, MaxW=12, MinH=1, MaxH=4 (per catalog). def, ok := LookupWidgetDef(WidgetUpcomingDeadlines) if !ok { t.Fatal("LookupWidgetDef(WidgetUpcomingDeadlines) = !ok") } cases := []struct { name string in DashboardWidgetRef wantW int wantH int wantX int wantY int wantOK bool // expected SanitizeForRead-returns-true }{ { name: "W below MinW snaps to MinW", in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 1, H: 1}, wantW: def.MinW, wantH: 1, wantOK: true, }, { name: "W above MaxW snaps to MaxW", in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 99, H: 1}, wantW: DashboardGridColumns, wantH: 1, wantOK: true, }, { name: "W above grid width snaps to grid width", in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 50, H: 1}, wantW: DashboardGridColumns, wantH: 1, wantOK: true, }, { name: "H above MaxGridRowSpan snaps to MaxGridRowSpan", in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 6, H: 99}, wantW: 6, wantH: def.MaxH, // upcoming-deadlines MaxH=4 < MaxGridRowSpan=5 wantOK: true, }, { name: "X+W overflowing grid snaps X down", in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 10, Y: 0, W: 6, H: 1}, wantW: 6, wantH: 1, wantX: 6, // 12 - 6 = 6 wantOK: true, }, { name: "W=0 stays 0 (auto / default sentinel)", in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 0, H: 0}, wantW: 0, wantH: 0, wantOK: false, }, { name: "negative X snaps to 0", in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: -3, Y: 0, W: 6, H: 1}, wantW: 6, wantH: 1, wantX: 0, wantOK: true, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { s := DashboardLayoutSpec{Version: LayoutSpecVersion, Widgets: []DashboardWidgetRef{tc.in}} changed := s.SanitizeForRead() if changed != tc.wantOK { t.Errorf("SanitizeForRead returned %v; want %v", changed, tc.wantOK) } if len(s.Widgets) != 1 { t.Fatalf("expected 1 widget after sanitize, got %d", len(s.Widgets)) } got := s.Widgets[0] if got.W != tc.wantW { t.Errorf("W = %d; want %d", got.W, tc.wantW) } if got.H != tc.wantH { t.Errorf("H = %d; want %d", got.H, tc.wantH) } if got.X != tc.wantX { t.Errorf("X = %d; want %d", got.X, tc.wantX) } if got.Y != tc.wantY { t.Errorf("Y = %d; want %d", got.Y, tc.wantY) } }) } } // TestDashboardLayoutSpec_SanitizeForRead_ClampedSpecPassesValidate is // the round-trip guarantee — after the sanitiser heals a stale row, the // result must be acceptable to Validate so the next PUT doesn't reject // the user's layout. Without this guarantee, sanitizing on read could // produce a layout the validator won't accept on the autosave path. func TestDashboardLayoutSpec_SanitizeForRead_ClampedSpecPassesValidate(t *testing.T) { s := DashboardLayoutSpec{ Version: LayoutSpecVersion, Widgets: []DashboardWidgetRef{ {Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 1, H: 1}, {Key: WidgetUpcomingDeadlines, Visible: true, X: 50, Y: 0, W: 99, H: 99}, // duplicate key — Validate will reject; this case checks size clamp at least }, } // Trim to one widget for the validate assertion (duplicates are a // separate concern). s.Widgets = s.Widgets[:1] s.SanitizeForRead() if err := s.Validate(); err != nil { t.Errorf("Validate after SanitizeForRead returned %v; want nil", err) } } 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) } }