lex_test.go

  1package lex
  2
  3import (
  4	"encoding/json"
  5	"os"
  6	"path/filepath"
  7	"reflect"
  8	"slices"
  9	"sort"
 10	"strings"
 11	"testing"
 12)
 13
 14// lexObject is the part of a lexicon object definition the test compares.
 15type lexObject struct {
 16	Required   []string                   `json:"required"`
 17	Properties map[string]json.RawMessage `json:"properties"`
 18}
 19
 20// loadDef returns the object definition at ref ("<nsid>#<def>", where def may
 21// be "main", "main.input" or "main.output" for procedures).
 22func loadDef(t *testing.T, ref string) lexObject {
 23	t.Helper()
 24	nsid, def, _ := strings.Cut(ref, "#")
 25	var path string
 26	for _, dir := range []string{"tangled", "ci"} {
 27		p := filepath.Join("..", "..", "lexicons", dir, nsid+".json")
 28		if _, err := os.Stat(p); err == nil {
 29			path = p
 30		}
 31	}
 32	if path == "" {
 33		t.Fatalf("%s: no lexicon file", ref)
 34	}
 35	b, err := os.ReadFile(path)
 36	if err != nil {
 37		t.Fatal(err)
 38	}
 39	var doc struct {
 40		Defs map[string]struct {
 41			lexObject
 42			Record *lexObject `json:"record"`
 43			Input  *struct {
 44				Schema lexObject `json:"schema"`
 45			} `json:"input"`
 46			Output *struct {
 47				Schema lexObject `json:"schema"`
 48			} `json:"output"`
 49		} `json:"defs"`
 50	}
 51	if err := json.Unmarshal(b, &doc); err != nil {
 52		t.Fatalf("%s: %v", path, err)
 53	}
 54	name, part, _ := strings.Cut(def, ".")
 55	d, ok := doc.Defs[name]
 56	if !ok {
 57		t.Fatalf("%s: no def %q", path, name)
 58	}
 59	switch {
 60	case part == "input":
 61		return d.Input.Schema
 62	case part == "output":
 63		return d.Output.Schema
 64	case d.Record != nil:
 65		return *d.Record
 66	}
 67	return d.lexObject
 68}
 69
 70// jsonFields returns a struct's JSON field names (without $type) and which of
 71// them are omitempty.
 72func jsonFields(typ reflect.Type) (names []string, omit map[string]bool) {
 73	omit = map[string]bool{}
 74	for f := range typ.Fields() {
 75		tag := f.Tag.Get("json")
 76		name, opts, _ := strings.Cut(tag, ",")
 77		if name == "" || name == "-" || name == "$type" {
 78			continue
 79		}
 80		names = append(names, name)
 81		omit[name] = strings.Contains(opts, "omitempty")
 82	}
 83	sort.Strings(names)
 84	return names, omit
 85}
 86
 87func TestTypesMatchLexicons(t *testing.T) {
 88	cases := []struct {
 89		ref string
 90		v   any
 91	}{
 92		{"sh.tangled.git.refUpdate#main", GitRefUpdate{}},
 93		{"sh.tangled.git.refUpdate#meta", GitRefUpdateMeta{}},
 94		{"sh.tangled.git.refUpdate#langBreakdown", GitRefUpdateLangBreakdown{}},
 95		{"sh.tangled.git.refUpdate#individualLanguageSize", GitRefUpdateLanguageSize{}},
 96		{"sh.tangled.git.refUpdate#commitCountBreakdown", GitRefUpdateCommitCount{}},
 97		{"sh.tangled.git.refUpdate#individualEmailCommitCount", GitRefUpdateEmailCommitCount{}},
 98		{"sh.tangled.pipeline#main", Pipeline{}},
 99		{"sh.tangled.pipeline#triggerMetadata", PipelineTriggerMetadata{}},
100		{"sh.tangled.pipeline#triggerRepo", PipelineTriggerRepo{}},
101		{"sh.tangled.pipeline#pushTriggerData", PipelinePushTriggerData{}},
102		{"sh.tangled.pipeline#pullRequestTriggerData", PipelinePullRequestTrigger{}},
103		{"sh.tangled.pipeline#manualTriggerData", PipelineManualTriggerData{}},
104		{"sh.tangled.pipeline#scheduleTriggerData", PipelineScheduleTrigger{}},
105		{"sh.tangled.pipeline#workflow", PipelineWorkflow{}},
106		{"sh.tangled.pipeline#cloneOpts", PipelineCloneOpts{}},
107		{"sh.tangled.pipeline#pair", PipelinePair{}},
108		{"sh.tangled.pipeline.status#main", PipelineStatus{}},
109		{"sh.tangled.ci.triggerPipeline#main.input", TriggerPipelineInput{}},
110		{"sh.tangled.ci.triggerPipeline#main.output", TriggerPipelineOutput{}},
111		{"sh.tangled.ci.trigger#manual", ManualTrigger{}},
112		{"run.rtw.ci.workflow#main", Workflow{}},
113		{"run.rtw.ci.workflow#trigger", Trigger{}},
114		{"run.rtw.ci.workflow#repo", Repo{}},
115		{"run.rtw.ci.workflow#action", Action{}},
116		{"run.rtw.ci.workflow#gate", Gate{}},
117		{"run.rtw.ci.workflow#concurrency", Concurrency{}},
118		{"run.rtw.ci.step.result#main", Result{}},
119		{"run.rtw.ci.step.result#option", Option{}},
120		{"run.rtw.ci.step.result#attachment", Attachment{}},
121		{"run.rtw.ci.gate#main", GateRecord{}},
122		{"run.rtw.ci.transition#main", Transition{}},
123		{"run.rtw.ci.event#main", RunEvent{}},
124	}
125	for _, c := range cases {
126		t.Run(c.ref, func(t *testing.T) {
127			def := loadDef(t, c.ref)
128			want := make([]string, 0, len(def.Properties))
129			for k := range def.Properties {
130				want = append(want, k)
131			}
132			sort.Strings(want)
133			got, omit := jsonFields(reflect.TypeOf(c.v))
134			if !slices.Equal(got, want) {
135				t.Errorf("fields differ\n go:      %v\n lexicon: %v", got, want)
136			}
137			for _, r := range def.Required {
138				if omit[r] {
139					t.Errorf("required property %q is omitempty in Go", r)
140				}
141			}
142		})
143	}
144}
145
146func TestNextForms(t *testing.T) {
147	cases := []struct {
148		in, out string
149		stop    bool
150		n       int
151	}{
152		{`"stop"`, `"stop"`, true, 0},
153		{`"rollback"`, `[{"action":"rollback"}]`, false, 1},
154		{`{"action":"rollback","then":"stop"}`, `[{"action":"rollback","then":"stop"}]`, false, 1},
155		{`[{"action":"a"},{"action":"b","label":"B"}]`, `[{"action":"a"},{"action":"b","label":"B"}]`, false, 2},
156	}
157	for _, c := range cases {
158		var n Next
159		if err := json.Unmarshal([]byte(c.in), &n); err != nil {
160			t.Fatalf("%s: %v", c.in, err)
161		}
162		if n.Stop != c.stop || len(n.Options) != c.n {
163			t.Errorf("%s: got %+v", c.in, n)
164		}
165		b, _ := json.Marshal(n)
166		if string(b) != c.out {
167			t.Errorf("%s: marshals to %s, want %s", c.in, b, c.out)
168		}
169	}
170	for _, bad := range []string{`""`, `[]`, `5`, `{"action":"a","oops":1}`, `[{"actoin":"a"}]`} {
171		var n Next
172		if err := json.Unmarshal([]byte(bad), &n); err == nil {
173			t.Errorf("%s: accepted", bad)
174		}
175	}
176}
177
178// The example result from DESIGN.md ยง7 round-trips.
179func TestDesignResultExample(t *testing.T) {
180	in := `{
181  "version": 1,
182  "status": "failure",
183  "summary": "mx99.eu.sovrn.at /healthz 503 for 4m (stalwart: connection refused)",
184  "next": [
185    { "action": "rollback", "prompt": "mx99 degraded after deploy. Roll back sovrn?", "then": "stop" },
186    { "action": "health-check", "label": "Check again" }
187  ],
188  "gated": true,
189  "attachments": [ { "label": "health probes", "path": "health.ndjson", "type": "ndjson" } ],
190  "outputs": { "hosts": ["mx99.eu.sovrn.at"] }
191}`
192	var r Result
193	if err := StrictUnmarshal([]byte(in), &r); err != nil {
194		t.Fatal(err)
195	}
196	if r.Status != Failure || !r.Gated || len(r.Next.Options) != 2 || r.Next.Options[0].Then != ThenStop {
197		t.Fatalf("decoded %+v", r)
198	}
199	b, err := json.Marshal(r)
200	if err != nil {
201		t.Fatal(err)
202	}
203	var again Result
204	if err := StrictUnmarshal(b, &again); err != nil {
205		t.Fatalf("re-decode %s: %v", b, err)
206	}
207	if !reflect.DeepEqual(normalise(t, r), normalise(t, again)) {
208		t.Errorf("round trip changed the result:\n%s", b)
209	}
210}
211
212func normalise(t *testing.T, r Result) map[string]any {
213	b, _ := json.Marshal(r)
214	var m map[string]any
215	if err := json.Unmarshal(b, &m); err != nil {
216		t.Fatal(err)
217	}
218	return m
219}