diff --git a/candy/plugin-omarchy/params/cue_types_gen.go b/candy/plugin-omarchy/params/cue_types_gen.go index 8aa9b58..10a7947 100644 --- a/candy/plugin-omarchy/params/cue_types_gen.go +++ b/candy/plugin-omarchy/params/cue_types_gen.go @@ -8,25 +8,25 @@ type OmarchyInput struct { // omarchy-shell IPC (the SAME surface the menu and hotkeys use); the // cli-read methods run the omarchy-* bin commands (the acceptance // surfaces the beds assert). - Method string `yaml:"method,omitempty" json:"method"` + Method string `json:"method"` // args — the omarchy CLI args (method: cli) or the weather-location // args (method: weather-location). Required for those two methods. - Args string `yaml:"args,omitempty" json:"args,omitempty"` + Args string `json:"args,omitempty"` // plugin — the omarchy. id for shell-summon / shell-hide. - Plugin string `yaml:"plugin,omitempty" json:"plugin,omitempty"` + Plugin string `json:"plugin,omitempty"` // payload — the JSON payload for shell-summon (optional). - Payload string `yaml:"payload,omitempty" json:"payload,omitempty"` + Payload string `json:"payload,omitempty"` // title / text — the notification title/body for shell-notifications-send. - Title string `yaml:"title,omitempty" json:"title,omitempty"` + Title string `json:"title,omitempty"` - Text string `yaml:"text,omitempty" json:"text,omitempty"` + Text string `json:"text,omitempty"` // expect_non_zero: assert the command FAILED (any non-zero exit) — the // CLI-rejects-X class. Mirrors plugin-command's expect_non_zero; mutually // exclusive with the step-level exit_status matcher (exact code). - ExpectNonZero bool `yaml:"expect_non_zero,omitempty" json:"expect_non_zero"` + ExpectNonZero bool `json:"expect_non_zero"` } diff --git a/candy/plugin-omarchy/params/decode_test.go b/candy/plugin-omarchy/params/decode_test.go new file mode 100644 index 0000000..26c45c5 --- /dev/null +++ b/candy/plugin-omarchy/params/decode_test.go @@ -0,0 +1,120 @@ +package params + +// decode_test.go — pins this generated projection against its SOURCE OF TRUTH, in both directions. +// +// charly hands a plugin its parameters as JSON (`spec.Op`'s params_json) and the plugin decodes +// that JSON into this projection; the sdk's kit.DecodeInput re-marshals to JSON before it +// unmarshals too. No YAML decoder ever sees these types — which is why the projection's `yaml:` +// tags are inert, and why this test pins the JSON side rather than asserting that claim in prose. +// +// The expected keys below are copied from the `#OmarchyInput` definition in +// `candy/plugin-omarchy/schema/*.cue` — the schema that GENERATES this file. They are hardcoded on +// purpose: deriving them from the struct's own `json` tags (the obvious thing) makes the test +// vacuous, because a tag that drifts takes its derived expectation with it and the decode still +// lines up. + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +// schemaKeys is the field set of #OmarchyInput, taken from candy/plugin-omarchy/schema. +var schemaKeys = []string{ + "args", + "expect_non_zero", + "method", + "payload", + "plugin", + "text", + "title", +} + +func TestProjectionMatchesItsSchema(t *testing.T) { + rt := reflect.TypeOf(OmarchyInput{}) + ptr := reflect.New(rt) + + // Direction 1: STRUCT -> SCHEMA. Every exported field must carry a json key the schema + // declares; a drifted, emptied or removed tag is caught here. + wire := map[string]any{} + exported := 0 + tagOf := map[string]string{} // json key -> Go field name + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + if f.PkgPath != "" { + continue // unexported + } + exported++ + key := strings.Split(f.Tag.Get("json"), ",")[0] + if key == "" || key == "-" { + t.Fatalf("field %s carries no usable json tag (%q) — no schema key can reach it", f.Name, key) + } + if !contains(schemaKeys, key) { + t.Fatalf("field %s is tagged json:%q, which #OmarchyInput in candy/plugin-omarchy/schema does NOT declare — the projection has drifted from its source of truth", f.Name, key) + } + tagOf[key] = f.Name + switch f.Type.Kind() { + case reflect.String: + wire[key] = "probe" + case reflect.Bool: + wire[key] = true + case reflect.Int, reflect.Int64: + wire[key] = 7 + case reflect.Float64: + wire[key] = 1.5 + case reflect.Slice: + wire[key] = []any{"probe"} + case reflect.Map: + wire[key] = map[string]any{"k": "v"} + default: + // nested structs/pointers/interfaces are not filled by a flat wire map + continue + } + } + if exported == 0 { + t.Fatal("the projection has no exported fields — the test would prove nothing") + } + + // Direction 2: SCHEMA -> STRUCT. Every key the schema declares must be carried by a field; + // a key the projection dropped is caught here, and it is checked against schemaKeys directly + // rather than against anything derived from the tags. + for _, key := range schemaKeys { + if _, ok := tagOf[key]; !ok { + t.Fatalf("schema key %q is declared by #OmarchyInput but NO exported field carries it — the projection is missing part of its schema", key) + } + } + + b, err := json.Marshal(wire) + if err != nil { + t.Fatalf("marshal the wire map: %v", err) + } + // THE PATH: json -> struct, exactly `json.Unmarshal(req.GetParamsJson(), &op)`. + if err := json.Unmarshal(b, ptr.Interface()); err != nil { + t.Fatalf("decode the wire map into the projection: %v", err) + } + elem := ptr.Elem() + missing := []string{} + for key := range wire { + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + if f.PkgPath == "" && strings.Split(f.Tag.Get("json"), ",")[0] == key && elem.Field(i).IsZero() { + missing = append(missing, fmt.Sprintf("%s (field %s)", key, f.Name)) + } + } + } + if len(missing) > 0 { + t.Fatalf("%d wire key(s) were not delivered into the projection: %v", len(missing), missing) + } + t.Logf("both directions hold: %d exported field(s), %d schema key(s), %d decoded", exported, len(schemaKeys), len(wire)) +} + +func contains(hay []string, needle string) bool { + for _, h := range hay { + if h == needle { + return true + } + } + return false +}