diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 5f58754cc..132cc2ec5 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -172,6 +172,7 @@ to the symptom table below, so the next similar issue costs fewer reads. | A microflow expression calls a function that doesn't exist (e.g. `randomInt(9)` — in some docs but not a Mendix built-in): parses, passes `mxcli check`, then fails the build with CE0117 "Error(s) in expression". Also: a Decimal-returning function (`random()`, `secondsBetween`, the duration `*Between` family) assigned to an Integer/Long variable fails CE0117, but `mxcli check` only caught bare `div` | (1) The func checker only validated arity for *known* functions; an unknown call name was ignored. (2) `checkNumericAssignment` used `SourceIsArithmeticDecimal`, which fires only on arithmetic roots, so a Decimal *function* result slipped through. Also the `*Between` duration funcs were mistyped Integer in `funcTable` | `mdl/exprcheck/unknown_funcs.go` (`UnknownFunctionCalls`, `SourceRejectedForIntegerTarget`, `nearestFunc`) + `mdl/exprcheck/func_checker.go` (`funcTable` between-date return kinds) + `mdl/executor/validate_microflow.go` (`checkExprFunctions` → MDL044; `checkNumericAssignment` now uses `SourceRejectedForIntegerTarget`) | Walk each expression for `CallExpr` names not in `funcTable` (which lists *every* built-in — a bare `name(...)` is always a built-in call) → **MDL044** with a Levenshtein/prefix "did you mean" hint. Extend the Decimal-into-Integer check to Decimal-returning non-rounding functions → **MDL041**. Correct `secondsBetween`/`minutesBetween`/`hoursBetween`/`daysBetween`/`weeksBetween` to Decimal (calendar variants stay Integer, millisecondsBetween stays Long). When adding a new Mendix built-in, add it to `funcTable` or MDL044 will false-positive. Bug-tests `f1-unknown-expression-function.fail.mdl`, `f2-decimal-func-into-integer.fail.mdl`. Findings #1, #2 | | `index name on (cols)` inside `create entity` (or `alter entity add index name on (cols)`) fails with `extraneous input 'on' expecting '('` — the SQL-like form docs/users expect. The bare `index name (cols)` worked | `indexDefinition` had no `ON` token: `IDENTIFIER? LPAREN indexAttributeList RPAREN` | `mdl/grammar/domains/MDLDomainModel.g4` (`indexDefinition`) | Make `ON` optional: `IDENTIFIER? ON? LPAREN indexAttributeList RPAREN`, regen grammar. `buildIndex` reads columns from `IndexAttributeList` only, so ON can't be mistaken for a column and no visitor change is needed. Covers both CREATE (entityOption) and ALTER ADD INDEX (shared rule). Bug-test `mdl-examples/bug-tests/f4-entity-index-on.mdl`. Findings #4 | | `retrieve … where [Seq = $Game/MoveSeq + 1]` fails with a bare `mismatched input '+' expecting ']'` — no hint that Mendix XPath can't compute values (this is a Mendix limitation, not an mxcli bug) | Mendix XPath constraints take a literal/token/variable/path on the value side, never an arithmetic expression; the parse error named the token but not the cause | `mdl/visitor/visitor.go` (`enhanceErrorMessage`, `looksLikeXPathArithmetic`/`xpathArithmeticRe`) | Do NOT add grammar support (mxbuild would still reject the XPath). Add an error hint keyed on `mismatched input '<+|*|div|mod>' expecting ']'` (`expecting ']'` only occurs inside a `[…]` constraint) explaining the limitation and the workaround: compute into a variable first, then compare. Also documented in `xpath-constraints.md`. Bug-test `mdl-examples/bug-tests/f8-xpath-arithmetic.fail.mdl`. Findings #8 | +| Design properties are written free-form: a `ColorPicker`/`ToggleButtonGroup` value serializes as a plain option (wrong `$Type` for Studio Pro's Appearance tab), and a typo'd key/value (they're case-sensitive) passes `mxcli check`. Also `show design properties ` reports "No design properties found for widget type container" for a valid widget | Root bug: `resolveDesignPropsKey` upper-cased the MDL keyword but the lookup map is **lowercase-keyed**, so `container`→`DivContainer` never resolved — leaving `resolveDesignPropertyValueType` dead code and the theme registry unused on the write/validate paths | `mdl/executor/theme_reader.go` (`resolveDesignPropsKey` case fix) + `mdl/executor/cmd_pages_builder_v3.go` (`astDesignPropToValue` takes theme props) + `mdl/executor/validate_design_properties.go` (new, MDL-WIDGET11/12) wired from `cmd/mxcli/cmd_check.go` + `cmd/mxcli/lsp_diagnostics.go` (cached `themeRegistry`) | Fix `resolveDesignPropsKey` to lower-case the lookup. On write, resolve each flat value's type from the registry (`ColorPicker`/`ToggleButtonGroup`→custom). On check (`-p` only, when themesource defines properties), walk page/snippet/alter-page widget trees and warn: **MDL-WIDGET11** unknown key (case-sensitivity hint / valid-key list), **MDL-WIDGET12** invalid value (lists allowed values). Warnings, not errors — a newer theme may add keys/values (forward-compat, per `page-styling-support.md:402`). Skip compound (registry doesn't model sub-props) and widgets with no type-specific metadata (pluggable). Bug-test `mdl-examples/bug-tests/typed-design-properties.mdl` | --- diff --git a/.claude/skills/mendix/theme-styling.md b/.claude/skills/mendix/theme-styling.md index 7f72a8767..e7cbb290f 100644 --- a/.claude/skills/mendix/theme-styling.md +++ b/.claude/skills/mendix/theme-styling.md @@ -123,10 +123,25 @@ Sub-property keys are case-sensitive, same as flat keys. `alter styling` cannot find widgets in pages created by the MDL page builder because `walkPageWidgets` traverses `LayoutCall.Arguments` but the page parser doesn't fully reconstruct the widget tree when re-reading builder-created pages. These commands work on pages originally created in Studio Pro. +## Validation with `mxcli check -p` + +When a project is supplied (`mxcli check page.mdl -p app.mpr`), design properties are +validated against the project's theme registry (`themesource/*/web/design-properties.json`): + +- **MDL-WIDGET11** — a design-property key not defined for that widget type (with a + case-sensitivity hint, or the list of valid keys). +- **MDL-WIDGET12** — an option value that isn't allowed; the message **lists the + allowed values** (case-sensitive), which is the fastest way to fix a casing typo. + +Both are warnings (a newer theme may add keys/values), so they inform without blocking. +`show design properties ` lists the same allowed keys/values up front. On the +write side, the value's BSON type is taken from the registry (a `ColorPicker` / +`ToggleButtonGroup` property serializes as a custom value, not a plain option). + ## Checklist - [ ] Never apply `style` directly to DYNAMICTEXT — wrap in a CONTAINER -- [ ] Design property keys are case-sensitive — match `design-properties.json` exactly +- [ ] Design property keys are case-sensitive — match `design-properties.json` exactly (`check -p` flags mismatches as MDL-WIDGET11/12) - [ ] Compound/nested design properties (e.g. grouped Spacing) use a nested list: `'Spacing': ['margin-top': 'Large']` - [ ] For CSS changes, run `docker build` then `docker reload --css` - [ ] Use `describe styling` to verify changes after modification diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index 5c24a0704..81d02221a 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -138,6 +138,11 @@ Examples: // Check for intra-script duplicate definitions (CREATE X … CREATE X without DROP) violations = append(violations, executor.CheckScriptDuplicates(prog)...) + // Validate design properties against the project's theme registry + // (themesource design-properties.json) — flags unknown keys and invalid + // option values, listing the allowed values. Only runs with --project. + violations = append(violations, executor.ValidateDesignProperties(prog, projectPath)...) + // Validate pluggable widget properties against widget definitions — // catches typos in property keys before MxBuild does. Uses built-in // definitions alone when no project is given; with --project, also diff --git a/cmd/mxcli/lsp.go b/cmd/mxcli/lsp.go index 3fa5c87e8..eb07c49e1 100644 --- a/cmd/mxcli/lsp.go +++ b/cmd/mxcli/lsp.go @@ -62,6 +62,19 @@ type mdlServer struct { widgetCompletionsOnce sync.Once widgetCompletionItems []protocol.CompletionItem widgetRegistry *executor.WidgetRegistry // populated by the same Once + + // Design-property (theme) registry cache (lazily populated once per session, + // keeping design-property file I/O out of the per-keystroke path). + themeRegistryOnce sync.Once + themeRegistry *executor.ThemeRegistry +} + +// ensureThemeRegistry loads the project's design-property registry once per +// server lifetime. Nil when there is no project/themesource metadata. +func (s *mdlServer) ensureThemeRegistry() { + s.themeRegistryOnce.Do(func() { + s.themeRegistry = executor.LoadThemeRegistryForProject(s.mprPath) + }) } func newMDLServer(client protocol.Client) *mdlServer { diff --git a/cmd/mxcli/lsp_diagnostics.go b/cmd/mxcli/lsp_diagnostics.go index 73ea14580..afe9ab4bb 100644 --- a/cmd/mxcli/lsp_diagnostics.go +++ b/cmd/mxcli/lsp_diagnostics.go @@ -262,6 +262,7 @@ func (s *mdlServer) runSemanticValidation(text string) []protocol.Diagnostic { // filesystem isn't walked on every keystroke. Nil-safe: when the registry // hasn't loaded (no project context, etc.) widget validation is skipped. s.ensureWidgetRegistry() + s.ensureThemeRegistry() var diags []protocol.Diagnostic for i, stmt := range prog.Statements { @@ -287,6 +288,9 @@ func (s *mdlServer) runSemanticValidation(text string) []protocol.Diagnostic { if s.widgetRegistry != nil { violations = append(violations, executor.ValidateWidgetPropertiesForStatement(stmt, s.widgetRegistry)...) } + if s.themeRegistry != nil { + violations = append(violations, executor.ValidateDesignPropertiesForStatement(stmt, s.themeRegistry)...) + } lineNum := uint32(0) if i < len(stmtLines) { diff --git a/mdl-examples/bug-tests/typed-design-properties.mdl b/mdl-examples/bug-tests/typed-design-properties.mdl new file mode 100644 index 000000000..159428e64 --- /dev/null +++ b/mdl-examples/bug-tests/typed-design-properties.mdl @@ -0,0 +1,20 @@ +-- Typed design properties: with `mxcli check -p app.mpr`, design-property keys +-- and values are validated against the project's theme registry +-- (themesource/*/web/design-properties.json): +-- * MDL-WIDGET11 — unknown key for the widget (case-sensitive; keys are listed) +-- * MDL-WIDGET12 — value not in the allowed set (allowed values are listed) +-- On write, a ColorPicker / ToggleButtonGroup value is serialized as a custom +-- value (correct BSON $Type), not a plain option. Simple toggles/dropdowns and +-- compound (Spacing) are unchanged. This file itself is valid MDL. +create page TypedDP.Home ( + title: 'Home', + layout: Atlas_Core.Atlas_Default +) { + container hero (designproperties: [ + 'Background color': 'Brand Primary', + 'Card style': on, + 'Spacing': ['margin-bottom': 'Large'] + ]) { + dynamictext t1 (content: 'Welcome', rendermode: H2) + } +} diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 9762d582c..142a43000 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -545,9 +545,17 @@ func applyWidgetAppearance(widget pages.Widget, w *ast.WidgetV3, theme *ThemeReg // Apply design properties astProps := w.GetDesignProperties() if len(astProps) > 0 { + // Resolve the widget's design-property definitions from the theme registry + // (when loaded) so each value's BSON type is taken from metadata + // (ColorPicker/ToggleButtonGroup → custom) rather than guessed. Nil/empty + // when no project/themesource — the converter then falls back to option. + var themeProps []ThemeProperty + if theme != nil { + themeProps = theme.GetPropertiesForWidget(resolveDesignPropsKey(w.Type)) + } var dpValues []pages.DesignPropertyValue for _, p := range astProps { - if dp, ok := astDesignPropToValue(p); ok { + if dp, ok := astDesignPropToValue(p, themeProps); ok { dpValues = append(dpValues, dp) } } @@ -567,11 +575,18 @@ func applyWidgetAppearance(widget pages.Widget, w *ast.WidgetV3, theme *ThemeReg // pages.DesignPropertyValue. Compound entries (a key whose value is a nested // list, e.g. 'Spacing': ['margin-top': 'Large', …]) recurse into sub-properties. // Returns ok=false for an entry that should be skipped (a flat toggle set OFF). -func astDesignPropToValue(p ast.DesignPropertyEntryV3) (pages.DesignPropertyValue, bool) { +// +// themeProps are the widget's design-property definitions from the theme registry +// (nil/empty when no project/themesource). When available, a flat value's BSON +// type is taken from the property's declared Type — a ColorPicker or +// ToggleButtonGroup materializes as Forms$CustomDesignPropertyValue rather than +// the option default (findings: typed design properties). Without metadata the +// prior syntactic behaviour (on→toggle, else→option) is preserved. +func astDesignPropToValue(p ast.DesignPropertyEntryV3, themeProps []ThemeProperty) (pages.DesignPropertyValue, bool) { if len(p.Nested) > 0 { dp := pages.DesignPropertyValue{Key: p.Key, ValueType: "compound"} for _, sub := range p.Nested { - if sv, ok := astDesignPropToValue(sub); ok { + if sv, ok := astDesignPropToValue(sub, themeProps); ok { dp.Compound = append(dp.Compound, sv) } } @@ -583,7 +598,11 @@ func astDesignPropToValue(p ast.DesignPropertyEntryV3) (pages.DesignPropertyValu case "off": return pages.DesignPropertyValue{}, false // toggle absence — skip default: - return pages.DesignPropertyValue{Key: p.Key, ValueType: "option", Option: p.Value}, true + return pages.DesignPropertyValue{ + Key: p.Key, + ValueType: resolveDesignPropertyValueType(p.Key, themeProps), + Option: p.Value, + }, true } } diff --git a/mdl/executor/theme_reader.go b/mdl/executor/theme_reader.go index d2d72cf2d..f457cea97 100644 --- a/mdl/executor/theme_reader.go +++ b/mdl/executor/theme_reader.go @@ -130,12 +130,12 @@ var mdlKeywordToDesignPropsKey = map[string]string{ "footer": "Footer", } -// resolveDesignPropsKey converts an MDL widget type keyword (e.g., "CONTAINER") -// to the design-properties.json key (e.g., "DivContainer"). Falls back to the input as-is -// for unrecognized types (e.g., pluggable widget identifiers). +// resolveDesignPropsKey converts an MDL widget type keyword (e.g., "container", +// "CONTAINER") to the design-properties.json key (e.g., "DivContainer"). The +// lookup is case-insensitive against the lowercase-keyed map. Falls back to the +// input as-is for unrecognized types (e.g., pluggable widget identifiers). func resolveDesignPropsKey(mdlKeyword string) string { - upper := strings.ToUpper(mdlKeyword) - if key, ok := mdlKeywordToDesignPropsKey[upper]; ok { + if key, ok := mdlKeywordToDesignPropsKey[strings.ToLower(mdlKeyword)]; ok { return key } return mdlKeyword diff --git a/mdl/executor/validate_design_properties.go b/mdl/executor/validate_design_properties.go new file mode 100644 index 000000000..7df213052 --- /dev/null +++ b/mdl/executor/validate_design_properties.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// LoadThemeRegistryForProject loads the design-property registry for a project +// given the path to its .mpr file (the theme lives in /themesource). +// Returns nil when there is no usable metadata (no project, no themesource, or an +// empty registry) so callers can skip validation cleanly. The LSP loads this once +// per session to keep file I/O out of the per-keystroke path. +func LoadThemeRegistryForProject(mprPath string) *ThemeRegistry { + if mprPath == "" { + return nil + } + reg, err := loadThemeRegistry(filepath.Dir(mprPath)) + if err != nil || reg == nil || len(reg.WidgetProperties) == 0 { + return nil + } + return reg +} + +// ValidateDesignProperties validates the design properties authored on page / +// snippet / alter-page widgets against the project's theme registry +// (themesource/*/web/design-properties.json). It warns (never blocks — a newer +// theme may legitimately add keys/values the snapshot lacks) on: +// +// - MDL-WIDGET11: a design-property key that is not defined for the widget type. +// - MDL-WIDGET12: an option/toggle-group value that is not one of the property's +// allowed values — the message lists the allowed values (design-property +// values are case-sensitive, so this catches the common casing typo). +// +// It only runs with --project AND a themesource that actually defines design +// properties; otherwise there is no metadata to validate against and it is a +// no-op. Compound (nested) entries are skipped — the registry does not model +// their sub-properties, so validating them would produce false positives. +func ValidateDesignProperties(prog *ast.Program, projectPath string) []linter.Violation { + reg := LoadThemeRegistryForProject(projectPath) + if reg == nil { + return nil + } + var out []linter.Violation + for _, stmt := range prog.Statements { + out = append(out, ValidateDesignPropertiesForStatement(stmt, reg)...) + } + return out +} + +// ValidateDesignPropertiesForStatement validates one statement's widget design +// properties against a pre-loaded registry (LSP entry point). +func ValidateDesignPropertiesForStatement(stmt ast.Statement, reg *ThemeRegistry) []linter.Violation { + if reg == nil { + return nil + } + switch s := stmt.(type) { + case *ast.CreatePageStmtV3: + return validateDesignPropsTree(s.Widgets, reg, "page "+s.Name.String()) + case *ast.CreateSnippetStmtV3: + return validateDesignPropsTree(s.Widgets, reg, "snippet "+s.Name.String()) + case *ast.AlterPageStmt: + var out []linter.Violation + for _, op := range s.Operations { + switch o := op.(type) { + case *ast.InsertWidgetOp: + out = append(out, validateDesignPropsTree(o.Widgets, reg, "alter "+s.PageName.String())...) + case *ast.ReplaceWidgetOp: + out = append(out, validateDesignPropsTree(o.NewWidgets, reg, "alter "+s.PageName.String())...) + } + } + return out + } + return nil +} + +func validateDesignPropsTree(widgets []*ast.WidgetV3, reg *ThemeRegistry, locationPrefix string) []linter.Violation { + var out []linter.Violation + for _, w := range widgets { + if w == nil { + continue + } + out = append(out, validateWidgetDesignProps(w, reg, locationPrefix)...) + if len(w.Children) > 0 { + out = append(out, validateDesignPropsTree(w.Children, reg, locationPrefix)...) + } + } + return out +} + +func validateWidgetDesignProps(w *ast.WidgetV3, reg *ThemeRegistry, locationPrefix string) []linter.Violation { + astProps := w.GetDesignProperties() + if len(astProps) == 0 { + return nil + } + key := resolveDesignPropsKey(w.Type) + // Only validate widgets we have type-specific metadata for. For an unknown + // widget type (e.g. a pluggable widget not in the theme registry) we don't + // know the full property set, so we must not flag its keys as unknown. + if _, ok := reg.WidgetProperties[key]; !ok { + return nil + } + props := reg.GetPropertiesForWidget(key) + + var out []linter.Violation + for _, p := range astProps { + if len(p.Nested) > 0 { + continue // compound — registry doesn't model sub-properties + } + tp := findThemeProp(props, p.Key) + if tp == nil { + out = append(out, linter.Violation{ + RuleID: "MDL-WIDGET11", + Severity: linter.SeverityWarning, + Message: fmt.Sprintf("%s: widget %q (%s) sets design property %q, which is not defined for this widget type", + locationPrefix, w.Name, w.Type, p.Key), + Location: linter.Location{DocumentType: "page", DocumentName: locationPrefix}, + Suggestion: designPropKeySuggestion(props, p.Key), + }) + continue + } + // Value validation for enumerated types. Toggle values are on/off (handled + // by the writer); options/pickers must be one of the declared values. + if len(tp.Options) > 0 && !strings.EqualFold(p.Value, "on") && !strings.EqualFold(p.Value, "off") { + if !themeOptionAllowed(tp.Options, p.Value) { + out = append(out, linter.Violation{ + RuleID: "MDL-WIDGET12", + Severity: linter.SeverityWarning, + Message: fmt.Sprintf("%s: widget %q (%s) design property %q has value %q, which is not an allowed value", + locationPrefix, w.Name, w.Type, p.Key, p.Value), + Location: linter.Location{DocumentType: "page", DocumentName: locationPrefix}, + Suggestion: fmt.Sprintf("Allowed values (case-sensitive): %s", themeOptionNames(tp.Options)), + }) + } + } + } + return out +} + +// findThemeProp returns the property whose Name matches key exactly. +func findThemeProp(props []ThemeProperty, key string) *ThemeProperty { + for i := range props { + if props[i].Name == key { + return &props[i] + } + } + return nil +} + +// designPropKeySuggestion offers a case-insensitive near match (design-property +// keys are case-sensitive, so a casing typo is the most common cause), otherwise +// lists the defined keys for the widget. +func designPropKeySuggestion(props []ThemeProperty, key string) string { + for i := range props { + if strings.EqualFold(props[i].Name, key) { + return fmt.Sprintf("Design-property keys are case-sensitive — did you mean %q?", props[i].Name) + } + } + var names []string + for i := range props { + names = append(names, props[i].Name) + } + sort.Strings(names) + if len(names) == 0 { + return "Run 'mxcli show design properties' to list the valid design properties for this widget." + } + return "Valid design properties for this widget: " + strings.Join(names, ", ") +} + +func themeOptionAllowed(options []ThemeOption, value string) bool { + for _, o := range options { + if o.Name == value { + return true + } + } + return false +} + +func themeOptionNames(options []ThemeOption) string { + names := make([]string, 0, len(options)) + for _, o := range options { + names = append(names, o.Name) + } + return strings.Join(names, ", ") +} diff --git a/mdl/executor/validate_design_properties_test.go b/mdl/executor/validate_design_properties_test.go new file mode 100644 index 000000000..e7ef9bbfe --- /dev/null +++ b/mdl/executor/validate_design_properties_test.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// testThemeRegistry is a small in-memory registry: a Dropdown, a ColorPicker, +// and a Toggle on DivContainer (what MDL `container` resolves to). +func testThemeRegistry() *ThemeRegistry { + return &ThemeRegistry{WidgetProperties: map[string][]ThemeProperty{ + "DivContainer": { + {Name: "Background color", Type: "Dropdown", Options: []ThemeOption{{Name: "Brand Primary"}, {Name: "Default"}}}, + {Name: "Text alignment", Type: "ToggleButtonGroup", Options: []ThemeOption{{Name: "Left"}, {Name: "Center"}, {Name: "Right"}}}, + {Name: "Card style", Type: "Toggle"}, + }, + }} +} + +// TestAstDesignPropToValue_Typed verifies the write path picks the BSON value +// type from the theme registry (ColorPicker/ToggleButtonGroup → custom) rather +// than always defaulting flat values to option. Typed design properties. +func TestAstDesignPropToValue_Typed(t *testing.T) { + props := []ThemeProperty{ + {Name: "Background color", Type: "Dropdown"}, + {Name: "Text alignment", Type: "ToggleButtonGroup"}, + {Name: "Accent", Type: "ColorPicker"}, + } + cases := []struct { + key, val, wantType string + }{ + {"Background color", "Brand Primary", "option"}, + {"Text alignment", "Center", "custom"}, + {"Accent", "#ff0000", "custom"}, + {"Unknown Key", "x", "option"}, // not in registry → default option + } + for _, c := range cases { + dp, ok := astDesignPropToValue(ast.DesignPropertyEntryV3{Key: c.key, Value: c.val}, props) + if !ok { + t.Fatalf("%s: expected ok", c.key) + } + if dp.ValueType != c.wantType { + t.Errorf("%s=%q: ValueType=%q, want %q", c.key, c.val, dp.ValueType, c.wantType) + } + } + // on/off still map to toggle/skip regardless of metadata. + if dp, ok := astDesignPropToValue(ast.DesignPropertyEntryV3{Key: "Card style", Value: "on"}, props); !ok || dp.ValueType != "toggle" { + t.Errorf("on → toggle, got %+v ok=%v", dp, ok) + } + if _, ok := astDesignPropToValue(ast.DesignPropertyEntryV3{Key: "Card style", Value: "off"}, props); ok { + t.Error("off should be skipped") + } + // Without metadata the flat default stays option (backward compatible). + if dp, _ := astDesignPropToValue(ast.DesignPropertyEntryV3{Key: "Text alignment", Value: "Center"}, nil); dp.ValueType != "option" { + t.Errorf("no metadata → option, got %q", dp.ValueType) + } +} + +func designPropViolations(t *testing.T, src string, reg *ThemeRegistry) map[string]string { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse error: %v", errs[0]) + } + out := map[string]string{} + for _, stmt := range prog.Statements { + for _, v := range ValidateDesignPropertiesForStatement(stmt, reg) { + out[v.RuleID] = v.Message + " || " + v.Suggestion + } + } + return out +} + +// TestValidateDesignProperties_UnknownKeyAndBadValue covers MDL-WIDGET11 (unknown +// design-property key) and MDL-WIDGET12 (value not allowed, listing allowed values). +func TestValidateDesignProperties_UnknownKeyAndBadValue(t *testing.T) { + reg := testThemeRegistry() + + // Unknown key. + v := designPropViolations(t, `create page M.P (layout: Atlas_Core.Atlas_Default) { + container c1 (designproperties: ['Nonexistent': 'x']) {} +}`, reg) + if _, ok := v["MDL-WIDGET11"]; !ok { + t.Errorf("expected MDL-WIDGET11 for unknown key, got %v", v) + } + + // Bad value on a known Dropdown → MDL-WIDGET12 listing allowed values. + v = designPropViolations(t, `create page M.P (layout: Atlas_Core.Atlas_Default) { + container c1 (designproperties: ['Background color': 'Bogus']) {} +}`, reg) + msg, ok := v["MDL-WIDGET12"] + if !ok { + t.Fatalf("expected MDL-WIDGET12 for bad value, got %v", v) + } + if !strings.Contains(msg, "Brand Primary") || !strings.Contains(msg, "Default") { + t.Errorf("expected allowed values listed, got: %s", msg) + } + + // Valid key + valid value + valid toggle → no violations. + v = designPropViolations(t, `create page M.P (layout: Atlas_Core.Atlas_Default) { + container c1 (designproperties: ['Background color': 'Brand Primary', 'Card style': on]) {} +}`, reg) + if len(v) != 0 { + t.Errorf("expected no violations for valid design properties, got %v", v) + } +} + +// TestValidateDesignProperties_WrongCaseHint verifies the case-sensitivity hint. +func TestValidateDesignProperties_WrongCaseHint(t *testing.T) { + reg := testThemeRegistry() + v := designPropViolations(t, `create page M.P (layout: Atlas_Core.Atlas_Default) { + container c1 (designproperties: ['card style': on]) {} +}`, reg) + msg, ok := v["MDL-WIDGET11"] + if !ok { + t.Fatalf("expected MDL-WIDGET11 for wrong-case key, got %v", v) + } + if !strings.Contains(msg, "case-sensitive") || !strings.Contains(msg, "Card style") { + t.Errorf("expected case-sensitivity hint naming 'Card style', got: %s", msg) + } +} + +// TestValidateDesignProperties_UnknownWidgetSkipped verifies a widget type with +// no registry metadata (e.g. a pluggable widget) is not flagged. +func TestValidateDesignProperties_UnknownWidgetSkipped(t *testing.T) { + // Registry only knows DataGrid; the container has no entry → skip. + prog, errs := visitor.Build(`create page M.P (layout: Atlas_Core.Atlas_Default) { + container c1 (designproperties: ['Something': 'x']) {} +}`) + if len(errs) > 0 { + t.Fatalf("parse error: %v", errs[0]) + } + // Swap the widget's resolved key out of the registry by using an empty registry. + empty := &ThemeRegistry{WidgetProperties: map[string][]ThemeProperty{"DataGrid": {{Name: "X", Type: "Toggle"}}}} + var n int + for _, stmt := range prog.Statements { + n += len(ValidateDesignPropertiesForStatement(stmt, empty)) + } + if n != 0 { + t.Errorf("expected no violations when widget type has no registry metadata, got %d", n) + } +}