package definition_test import ( "encoding/base64" "encoding/json" "fmt" "os" "reflect" "sort" "strconv" "strings" "testing" "github.com/krow/krow-backend/go-api/internal/definition" ) // Phase 4D — JS/Go parser conformance. // // The fixture these tests read (testdata/oracle.json) is not written by hand. // It is captured by scripts/oracle.mjs, which loads the REAL frontend module // graph through Vite — import.meta.glob, the `@/` alias and raw Markdown // loading all behave exactly as they do in the app — and records what the // JavaScript parser did with every shipped definition and every adversarial // case. So the assertion below is not "Go agrees with a description of the // frontend"; it is "Go agrees with the frontend", replayed. // // Regenerate after any change to src/lib/skills or src/lib/agents: // // node scripts/oracle.mjs go-api/internal/definition/testdata/oracle.json // // A frontend change that alters parsing therefore fails these tests, which is // the point: the contract cannot drift silently in either direction. type oracle struct { Vocabulary struct { Pages []struct { ID string `json:"id"` Aliases []string `json:"aliases"` } `json:"pages"` AgentStatuses []string `json:"agentStatuses"` Reasoning []string `json:"reasoning"` Icons []string `json:"icons"` KnowledgeKinds []string `json:"knowledgeKinds"` Access []string `json:"access"` Roles []string `json:"roles"` } `json:"vocabulary"` Corpus []observation `json:"corpus"` Cases []observation `json:"cases"` } // observation is one definition as the JavaScript saw it, end to end. type observation struct { // Exactly one of these identifies the row. Path string `json:"path"` Name string `json:"name"` Type string `json:"type"` Kind string `json:"kind"` // agent | skill RawBase64 string `json:"rawBase64"` HasFrontmatter bool `json:"hasFrontmatter"` Frontmatter struct { OK bool `json:"ok"` Data map[string]any `json:"data"` Body string `json:"body"` Error string `json:"error"` } `json:"frontmatter"` Parse struct { OK bool `json:"ok"` Error string `json:"error"` } `json:"parse"` Normalized map[string]any `json:"normalized"` Accepted bool `json:"accepted"` Rejection *string `json:"rejection"` } func (o observation) id() string { if o.Path != "" { return o.Path } return o.Name } func (o observation) raw(t *testing.T) string { t.Helper() b, err := base64.StdEncoding.DecodeString(o.RawBase64) if err != nil { t.Fatalf("%s: undecodable fixture: %v", o.id(), err) } return string(b) } func load(t *testing.T) *oracle { t.Helper() b, err := os.ReadFile("testdata/oracle.json") if err != nil { t.Fatalf("read fixture: %v", err) } var o oracle if err := json.Unmarshal(b, &o); err != nil { t.Fatalf("parse fixture: %v", err) } if len(o.Corpus) == 0 || len(o.Cases) == 0 { t.Fatal("fixture is empty; regenerate with scripts/oracle.mjs") } return &o } func all(o *oracle) []observation { return append(append([]observation{}, o.Corpus...), o.Cases...) } /* ── 1. The corpus is the corpus ──────────────────────────────────────────── */ // The shipped definition count, asserted rather than assumed. A definition // added to or removed from the product without regenerating the fixture leaves // these tests passing against a corpus that no longer exists, which is the one // way this suite could quietly stop meaning anything. func TestCorpusShape(t *testing.T) { o := load(t) counts := map[string]int{} for _, c := range o.Corpus { counts[c.Type]++ } for _, want := range []struct { kind string n int }{{"agent", 9}, {"skill", 23}, {"example", 5}} { if counts[want.kind] != want.n { t.Errorf("%s definitions: got %d, want %d", want.kind, counts[want.kind], want.n) } } if len(o.Corpus) != 37 { t.Errorf("shipped definitions: got %d, want 37", len(o.Corpus)) } } /* ── 2. The vocabulary has not drifted ────────────────────────────────────── */ // Every closed table in vocabulary.go, checked against the table the frontend // actually exports. A page added to surfaces.js fails here rather than becoming // a definition the editor accepts and the API rejects. func TestVocabularyMatchesFrontend(t *testing.T) { o := load(t) wantPages := make([]string, len(o.Vocabulary.Pages)) for i, p := range o.Vocabulary.Pages { wantPages[i] = p.ID } if !reflect.DeepEqual(definition.SupportedPages, wantPages) { t.Errorf("supported pages differ\n go %v\n js %v", definition.SupportedPages, wantPages) } // Aliases resolve, and resolve to the same canonical id. for _, p := range o.Vocabulary.Pages { for _, alias := range append([]string{p.ID}, p.Aliases...) { if got := definition.CanonicalPage(alias); got != p.ID { t.Errorf("CanonicalPage(%q) = %q, want %q", alias, got, p.ID) } } } for _, table := range []struct { name string got []string wanted []string }{ {"agent statuses", definition.AgentStatuses, o.Vocabulary.AgentStatuses}, {"reasoning modes", definition.ReasoningModes, o.Vocabulary.Reasoning}, {"icons", definition.AgentIcons, o.Vocabulary.Icons}, {"knowledge kinds", definition.KnowledgeKinds, o.Vocabulary.KnowledgeKinds}, {"access modes", definition.AgentAccess, o.Vocabulary.Access}, {"permission roles", definition.PermissionRole, o.Vocabulary.Roles}, } { if !reflect.DeepEqual(table.got, table.wanted) { t.Errorf("%s differ\n go %v\n js %v", table.name, table.got, table.wanted) } } } /* ── 3. The frontmatter tree ──────────────────────────────────────────────── */ // The deepest parity check available: the YAML subset must produce the same // data structure the JavaScript produced, for every definition and every // adversarial case. Not a projection of it — the whole tree. func TestFrontmatterTreeParity(t *testing.T) { for _, c := range all(load(t)) { t.Run(c.id(), func(t *testing.T) { raw := c.raw(t) doc, err := definition.ParseFrontmatter(raw) if !c.Frontmatter.OK { if err == nil { t.Fatalf("JS refused this frontmatter (%s); Go accepted it", c.Frontmatter.Error) } if err.Error() != c.Frontmatter.Error { t.Errorf("error text differs\n go %q\n js %q", err.Error(), c.Frontmatter.Error) } return } if err != nil { t.Fatalf("JS read this frontmatter; Go refused it: %v", err) } if got, want := normalizeTree(doc.Data), normalizeTree(c.Frontmatter.Data); !reflect.DeepEqual(got, want) { t.Errorf("frontmatter differs\n go %s\n js %s", show(got), show(want)) } if doc.Body != c.Frontmatter.Body { t.Errorf("body differs\n go %q\n js %q", doc.Body, c.Frontmatter.Body) } if got := definition.HasFrontmatter(raw); got != c.HasFrontmatter { t.Errorf("HasFrontmatter = %v, JS said %v", got, c.HasFrontmatter) } }) } } // normalizeTree puts a parsed tree into the shape `encoding/json` would have // produced, so the Go value and the value round-tripped through the fixture's // JSON are comparable. Numbers become float64 on both sides, which is what // JavaScript had in the first place. func normalizeTree(v any) any { b, err := json.Marshal(v) if err != nil { return fmt.Sprintf("unmarshalable: %v", err) } var out any if err := json.Unmarshal(b, &out); err != nil { return fmt.Sprintf("unmarshalable: %v", err) } return out } func show(v any) string { b, _ := json.Marshal(v) return string(b) } /* ── 4. Accept / reject parity ────────────────────────────────────────────── */ // knownDivergence is the complete list of definitions where the two parsers // disagree, each with the reason. It is a CLOSED list: anything not on it that // disagrees fails, and anything on it that stops disagreeing fails too, so the // list cannot quietly grow and cannot quietly go stale. // // Every entry is a case where Go is stricter, except the first — and the first // is the one asymmetry this package documents as deferred. var knownDivergence = map[string]string{ "skill-examples/board-invalid-context.md": "" + "JS rejects on `ui:` placement/source semantics, which this package defers " + "to the frontend. Go accepts and reports Deferred: [ui].", "oversized-markdown": "" + "JS accepts; the database refuses it (markdown_size CHECK). Go refuses it " + "first, so an author gets a message instead of a constraint violation.", "oversized-agent": "" + "JS accepts; the database refuses it (markdown_size CHECK). Go refuses it " + "first, so an author gets a message instead of a constraint violation.", "version-above-int32-agent": "" + "JS accepts any whole number of 1 or more; agent_definitions.version is a " + "PostgreSQL `integer`, so the database refuses this one. Go refuses it " + "first, for the same reason as the size bound.", } func TestAcceptanceParity(t *testing.T) { seen := map[string]bool{} for _, c := range all(load(t)) { t.Run(c.id(), func(t *testing.T) { raw := c.raw(t) var err error if c.Kind == "agent" { err = definition.ValidateAgent(raw) } else { err = definition.ValidateSkill(raw) } accepted := err == nil if reason, expected := knownDivergence[c.id()]; expected { seen[c.id()] = true if accepted == c.Accepted { t.Errorf("listed as a known divergence but the two now agree (%v).\n"+ "Remove it from knownDivergence.\n reason on file: %s", accepted, reason) } return } if accepted != c.Accepted { t.Fatalf("acceptance differs: go=%v js=%v\n go said: %v\n js said: %v", accepted, c.Accepted, err, deref(c.Rejection)) } }) } for id := range knownDivergence { if !seen[id] { t.Errorf("knownDivergence names %q, which is not in the fixture", id) } } } // Where both refuse a definition, they must refuse it for the same stated // reason. A parser that rejects the right definitions with the wrong messages // sends an author to the wrong line. func TestRejectionMessageParity(t *testing.T) { for _, c := range all(load(t)) { if c.Accepted || c.Rejection == nil { continue } if _, skip := knownDivergence[c.id()]; skip { continue } t.Run(c.id(), func(t *testing.T) { raw := c.raw(t) var err error if c.Kind == "agent" { err = definition.ValidateAgent(raw) } else { err = definition.ValidateSkill(raw) } if err == nil { t.Fatalf("JS rejected this; Go accepted it") } if r, ok := err.(*definition.Rejection); ok && r.BackendOnly { t.Fatalf("refused by a backend-only rule where JS refused it too: %q", r.Message) } if err.Error() != *c.Rejection { t.Errorf("rejection differs\n go %q\n js %q", err.Error(), *c.Rejection) } }) } } func deref(s *string) string { if s == nil { return "" } return *s } /* ── 5. Normalized projection parity ──────────────────────────────────────── */ // textCoercion is the complete list of fixture cases where the JavaScript // record holds a value that is NOT a string in a field migration 000005 // projects into a `text` or `text[]` column, named field by field. // // The frontend can afford this and the backend cannot. `name: [a, b]` leaves a // JavaScript ARRAY on skill.name, and every consumer stringifies it at the // point of use — the trigger list on that very record reads "a,b", which is // String(["a","b"]). A text column has no such option: something must be // written, once, at the boundary. This package writes String(x), which is the // string the frontend's own consumers produce. // // Listing them rather than coercing everywhere is the point. Coercion is // applied ONLY to the fields named here, so a genuine difference between two // strings still fails; each entry is checked to be still necessary, so the list // cannot go stale; and an unlisted case that needs coercion fails outright, so // the list cannot quietly grow. It is the same closed-list discipline // knownDivergence has, for the same reason. var textCoercion = map[string][]string{ "invalid-field-type-name-list": {"name"}, "name-list-agent": {"name"}, "name-numeric": {"name"}, "name-boolean": {"name"}, "description-list": {"description"}, "description-numeric": {"description"}, "pages-numeric-entry": {"pages"}, "pages-mapping-entry": {"pages"}, } // The two shapes a text projection can have, named rather than inferred. // // Which one applies is a property of the COLUMN, not of what the author // happened to write. `name` is `text`, so a sequence written there becomes one // string — String(["a","b"]) is "a,b". `pages` is `text[]`, so a sequence stays // a sequence and each entry becomes a string of its own. Inferring the shape // from whatever Go produced would make the test agree with the parser by // construction, which is the one thing it must not do. var textScalarFields = map[string]bool{ "name": true, "description": true, "category": true, "trigger": true, "prompt": true, } var textListFields = map[string]bool{ "pages": true, "actions": true, "triggers": true, "skills": true, "subagents": true, } // jsText is String(x) for a value decoded from the fixture's JSON — the same // conversion jsvalue.go performs inside the parser, restated here so the test // does not have to reach into the package it is testing to check it. func jsText(t *testing.T, field string, v any) any { t.Helper() switch { case textScalarFields[field]: return jsScalarText(v) case textListFields[field]: list, ok := v.([]any) if !ok { return jsScalarText(v) } out := make([]any, len(list)) for i, item := range list { out[i] = jsScalarText(item) } return out } t.Fatalf("textCoercion names %q, which is not a text-projected field", field) return nil } func jsScalarText(v any) string { switch x := v.(type) { case nil: return "null" case bool: if x { return "true" } return "false" case float64: if x == float64(int64(x)) { return strconv.FormatInt(int64(x), 10) } return strconv.FormatFloat(x, 'g', -1, 64) case string: return x case []any: // Array.prototype.toString: nil renders as the empty string, not // "null", which is the one place the two differ. parts := make([]string, len(x)) for i, item := range x { if item == nil { continue } parts[i] = jsScalarText(item) } return strings.Join(parts, ",") case map[string]any: return "[object Object]" } return "" } // The fields migration 000005 projects into columns, compared for every // definition both parsers accept. These are the values that reach the // database, so a difference here is a row the frontend would render wrongly. func TestProjectionParity(t *testing.T) { fixture := map[string]bool{} for _, c := range all(load(t)) { fixture[c.id()] = true if c.Normalized == nil { continue // JS could not parse it; covered by the tree test } coerce := map[string]bool{} for _, field := range textCoercion[c.id()] { coerce[field] = true } t.Run(c.id(), func(t *testing.T) { raw := c.raw(t) if c.Kind == "agent" { agent, err := definition.ParseAgent(raw, definition.Options{}) if err != nil { t.Fatalf("JS parsed this; Go refused it: %v", err) } compare(t, map[string]any{ "id": agent.ID, "name": agent.Name, "description": agent.Description, "status": agent.Status, "version": agent.Version, "pages": agent.Pages, "icon": agent.Icon, "reasoning": agent.Reasoning, "trigger": agent.Trigger, "webSearch": agent.WebSearch, "skills": agent.Skills, "subagents": agent.Subagents, "starters": agent.Starters, "permissions": agent.Permissions, "errors": agent.Errors, }, c.Normalized, coerce) return } skill, err := definition.ParseSkill(raw, definition.Options{}) if err != nil { t.Fatalf("JS parsed this; Go refused it: %v", err) } compare(t, map[string]any{ "id": skill.ID, "name": skill.Name, "description": skill.Description, "status": skill.Status, "pages": skill.Pages, "kind": skill.Kind, "category": skill.Category, "actions": skill.Actions, "triggers": skill.Triggers, "declaredTriggers": skill.DeclaredTriggers, "prompt": skill.Prompt, "skillId": skill.SkillID, }, c.Normalized, coerce) }) } for id := range textCoercion { if !fixture[id] { t.Errorf("textCoercion names %q, which is not in the fixture", id) } } } // compare checks every field Go produced against the JS record, field by field // so a failure names the field rather than dumping two objects. func compare(t *testing.T, got map[string]any, want map[string]any, coerce map[string]bool) { t.Helper() keys := make([]string, 0, len(got)) for k := range got { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { wantValue, present := want[k] if !present { t.Errorf("%s: absent from the JS record", k) continue } if coerce[k] { // Listed in textCoercion. Check the entry is still earning its // place before honouring it: if the JS value is already the string // Go produced, the coercion is doing nothing and the list has gone // stale. coerced := jsText(t, k, normalizeTree(wantValue)) if reflect.DeepEqual(normalizeTree(wantValue), normalizeTree(coerced)) { t.Errorf("%s: listed in textCoercion, but the JS value is already "+ "a string. Remove the entry.", k) } wantValue = coerced } g, w := normalizeTree(got[k]), normalizeTree(wantValue) // An empty list and a missing one are the same thing to both parsers. if isEmptyList(g) && isEmptyList(w) { continue } if !reflect.DeepEqual(g, w) { t.Errorf("%s differs\n go %s\n js %s", k, show(g), show(w)) } } } func isEmptyList(v any) bool { if v == nil { return true } l, ok := v.([]any) return ok && len(l) == 0 } /* ── 6. The parser never rewrites what is stored ──────────────────────────── */ // Migration 000005 keeps `markdown` verbatim and derives every other column // from it. Parsing must therefore be a read: normalization exists to // INTERPRET a definition, never to rewrite it. func TestParsingDoesNotMutateSource(t *testing.T) { for _, c := range all(load(t)) { raw := c.raw(t) before := string(append([]byte{}, raw...)) _, _ = definition.ParseSkill(raw, definition.Options{}) _, _ = definition.ParseAgent(raw, definition.Options{}) _ = definition.ValidateSkill(raw) _ = definition.ValidateAgent(raw) if raw != before { t.Fatalf("%s: the source changed under the parser", c.id()) } } } // Normalize is what the parser reads THROUGH; what it returns must never be // what gets stored. Asserted directly, because the whole separation rests on // it: the corpus contains definitions whose normalized form differs from their // stored form, and storing the normalized one would silently rewrite an // author's file. func TestNormalizationIsNotStorage(t *testing.T) { rewritten := 0 for _, c := range all(load(t)) { raw := c.raw(t) if definition.Normalize(raw) != raw { rewritten++ } } if rewritten == 0 { t.Fatal("no case in the corpus is changed by Normalize; " + "this test can no longer tell storage and interpretation apart") } t.Logf("%d of %d definitions normalize to something other than their stored bytes", rewritten, len(all(load(t)))) } /* ── 7. Adversarial coverage is real ──────────────────────────────────────── */ // The adversarial cases Phase 4D requires, each mapped to the fixture rows that // exercise it. A case list that drifts away from the requirement is a suite // that looks thorough and tests something else. func TestAdversarialCoverage(t *testing.T) { required := map[string][]string{ "UTF-8 BOM": {"utf8-bom", "utf8-bom-agent", "bom-crlf-blankline"}, "CRLF": {"crlf", "crlf-agent", "crlf-inside-frontmatter-only"}, "CR": {"cr-only"}, "leading blank line": {"leading-blank-line"}, "multiple leading blank lines": {"multiple-leading-blank-lines", "leading-spaces-then-blank-lines"}, "trailing spaces": {"trailing-spaces-on-values"}, "trailing newline": {"many-trailing-newlines", "no-trailing-newline"}, "trailing ws after fence": {"trailing-ws-after-open-fence", "trailing-tab-after-close-fence"}, "quoted scalar": {"double-quoted-scalar", "doubled-quote-escape"}, "single-quoted scalar": {"single-quoted-scalar"}, "colon inside quoted string": {"colon-in-quoted-string", "colon-in-unquoted-string"}, "hash inside quoted string": {"hash-in-quoted-string", "hash-unquoted-trailing-comment", "hash-unquoted-midword"}, "empty scalar": {"empty-scalar", "tilde-scalar", "null-scalar"}, "empty array": {"empty-array"}, "inline array": {"inline-flow-array", "inline-flow-map"}, "multiline scalar": {"block-scalar-literal", "block-scalar-folded"}, "duplicate key": {"duplicate-key", "duplicate-key-array"}, "malformed YAML": {"malformed-yaml-bare-line", "key-with-space", "ragged-indent"}, "malformed opening fence": {"malformed-open-fence-two-dashes", "malformed-open-fence-four-dashes", "malformed-open-fence-indented", "malformed-open-fence-text-after"}, "malformed closing fence": {"malformed-close-fence-two-dashes", "malformed-close-fence-missing", "malformed-close-fence-four-dashes"}, "missing frontmatter": {"missing-frontmatter", "empty-fence-pair", "frontmatter-is-a-sequence"}, "unsupported frontmatter field": {"unsupported-frontmatter-field", "unsupported-field-agent", "uppercase-key"}, "invalid field type": {"invalid-field-type-pages-scalar", "invalid-field-type-pages-scalar-agent", "invalid-field-type-name-list"}, "invalid definition id": {"invalid-definition-id-uppercase", "invalid-definition-id-leading-dash", "invalid-definition-id-underscore"}, "invalid status": {"invalid-status-skill", "invalid-status-agent", "inactive-status-skill"}, "invalid visibility": {"visibility-field-personal", "visibility-field-invalid"}, "oversized markdown": {"oversized-markdown", "oversized-agent", "at-size-bound"}, "empty markdown": {"empty-markdown", "whitespace-only-markdown"}, } present := map[string]bool{} for _, c := range load(t).Cases { present[c.Name] = true } for requirement, names := range required { for _, n := range names { if !present[n] { t.Errorf("%q: the fixture has no case named %q", requirement, n) } } } } /* ── 8. Deferred blocks are reported, not assumed ─────────────────────────── */ // Every skill carrying a `ui:` or `owliver:` block must say so, because that is // the one part of validation this package does not do. A block that stopped // being reported would be a gap nobody could see. func TestDeferredBlocksAreReported(t *testing.T) { o := load(t) found := 0 for _, c := range append(append([]observation{}, o.Corpus...), o.Cases...) { if c.Kind != "skill" || !c.Frontmatter.OK { continue } want := []string{} for _, key := range []string{"ui", "owliver"} { if _, present := c.Frontmatter.Data[key]; present { want = append(want, key) } } skill, err := definition.ParseSkill(c.raw(t), definition.Options{}) if err != nil { continue } if len(want) == 0 { if len(skill.Deferred) != 0 { t.Errorf("%s: reported Deferred %v with no such block", c.id(), skill.Deferred) } continue } found++ if !reflect.DeepEqual(skill.Deferred, want) { t.Errorf("%s: Deferred = %v, want %v", c.id(), skill.Deferred, want) } } if found != 19 { t.Errorf("definitions carrying a deferred block: got %d, want 19", found) } } /* ── 9. Mutation checks ───────────────────────────────────────────────────── */ // Tests that pass against a broken parser are not tests. Each mutation below // is a plausible mistake in this package; every one must be caught by a real // definition changing its meaning, not by an assertion written to notice it. func TestMutationsWouldBeCaught(t *testing.T) { base := strings.Join([]string{ "---", "id: sample-skill", "name: Sample Skill", "description: A sample.", "pages:", " - candidates", "---", "", "# Sample Skill", }, "\n") mutations := []struct { name string raw string check func(t *testing.T, s *definition.Skill, err error) }{ { // Dropping the BOM strip: the fence stops matching and every field // empties out. name: "BOM before the fence still fences", raw: "\uFEFF" + base, check: func(t *testing.T, s *definition.Skill, err error) { if err != nil || s.ID != "sample-skill" || len(s.Pages) != 1 { t.Errorf("got id=%q pages=%v err=%v", s.ID, s.Pages, err) } }, }, { // Dropping CR normalization: `candidates\r` is not a page. name: "CRLF endings do not leak into values", raw: strings.ReplaceAll(base, "\n", "\r\n"), check: func(t *testing.T, s *definition.Skill, err error) { if err != nil || len(s.Pages) != 1 || s.Pages[0] != "candidates" { t.Errorf("got pages=%v err=%v", s.Pages, err) } }, }, { // Trimming the closing fence too eagerly, or not at all. name: "trailing tab after the closing fence still closes it", raw: strings.Replace(base, "\n---\n", "\n---\t\n", 1), check: func(t *testing.T, s *definition.Skill, err error) { if err != nil || s.Name != "Sample Skill" { t.Errorf("got name=%q err=%v", s.Name, err) } }, }, { // A greedy fence would swallow the second document and lose the id. name: "a second --- document is body, not frontmatter", raw: base + "\n\n---\nid: second\n---\n", check: func(t *testing.T, s *definition.Skill, err error) { if err != nil || s.ID != "sample-skill" { t.Errorf("got id=%q err=%v", s.ID, err) } }, }, { // Treating `#` as always starting a comment. name: "a hash inside a word is part of the word", raw: strings.Replace(base, "description: A sample.", "category: ops#1", 1), check: func(t *testing.T, s *definition.Skill, err error) { if err != nil || s.Category != "ops#1" { t.Errorf("got category=%q err=%v", s.Category, err) } }, }, { // Treating a spaced `#` as part of the value. name: "a spaced hash starts a comment", raw: strings.Replace(base, "description: A sample.", "category: ops # note", 1), check: func(t *testing.T, s *definition.Skill, err error) { if err != nil || s.Category != "ops" { t.Errorf("got category=%q err=%v", s.Category, err) } }, }, { // Splitting a quoted value on its colon. name: "a colon inside quotes stays in the value", raw: strings.Replace(base, "name: Sample Skill", `name: "Sample: Skill"`, 1), check: func(t *testing.T, s *definition.Skill, err error) { if err != nil || s.Name != "Sample: Skill" { t.Errorf("got name=%q err=%v", s.Name, err) } }, }, { // Keeping the first duplicate rather than the last. name: "a duplicate key takes the last value", raw: strings.Replace(base, "name: Sample Skill", "name: First\nname: Second", 1), check: func(t *testing.T, s *definition.Skill, err error) { if err != nil || s.Name != "Second" { t.Errorf("got name=%q err=%v", s.Name, err) } }, }, { // Accepting ragged indentation instead of refusing it. name: "ragged indentation is refused with its line", raw: strings.Replace(base, " - candidates", " - candidates\n - positions", 1), check: func(t *testing.T, s *definition.Skill, err error) { var pe *definition.Error if err == nil { t.Fatalf("accepted ragged indentation: %+v", s) } if !asError(err, &pe) || pe.Line != 6 { t.Errorf("got %v, want an *Error on line 6", err) } }, }, { // Canonicalising a skill's pages, which the frontend does not do. name: "a skill keeps the page name as written", raw: strings.Replace(base, " - candidates", " - Talent Pool", 1), check: func(t *testing.T, s *definition.Skill, err error) { if err != nil || len(s.Pages) != 1 || s.Pages[0] != "Talent Pool" { t.Errorf("got pages=%v err=%v", s.Pages, err) } if err := definition.ValidateSkill(strings.Replace(base, " - candidates", " - Talent Pool", 1)); err != nil { t.Errorf("an aliased page should still validate: %v", err) } }, }, } for _, m := range mutations { t.Run(m.name, func(t *testing.T) { skill, err := definition.ParseSkill(m.raw, definition.Options{}) if skill == nil { skill = &definition.Skill{} } m.check(t, skill, err) }) } } // asError is errors.As, spelled out for the one concrete type this package // returns. func asError(err error, target **definition.Error) bool { e, ok := err.(*definition.Error) if ok { *target = e } return ok } /* ── 10. Bounds ───────────────────────────────────────────────────────────── */ // The size bound is the backend's, and both directions of it matter: a // definition at the limit must be storable and one character more must not. // The companion to TestSizeBound, for the other backend-only bound. The // fixture pins a version well above the bound and one exactly at it, which // leaves the step between them untested — an off-by-one there would refuse a // version PostgreSQL can store, or accept one it cannot. Both sides of the // step are named here so that cannot happen. func TestVersionBound(t *testing.T) { agent := func(version string) string { return "---\nid: sample-agent\nname: Sample Agent\npages:\n - candidates\n" + "version: " + version + "\n---\n\n# Sample Agent\n" } at := strconv.Itoa(definition.MaxVersion) if err := definition.ValidateAgent(agent(at)); err != nil { t.Errorf("version %s, exactly at the bound, was refused: %v", at, err) } over := strconv.FormatInt(int64(definition.MaxVersion)+1, 10) err := definition.ValidateAgent(agent(over)) if err == nil { t.Fatalf("version %s, one past the bound, was accepted", over) } r, ok := err.(*definition.Rejection) if !ok || !r.BackendOnly { t.Errorf("the version bound should be reported as a backend-only rule, got %v", err) } // The bound belongs to validation, not to parsing: a version the database // cannot store must still normalize to the number the author wrote, or the // record the editor shows and the record Go builds would disagree. parsed, err := definition.ParseAgent(agent(over), definition.Options{}) if err != nil { t.Fatalf("parsing a too-large version failed: %v", err) } if got := strconv.Itoa(parsed.Version); got != over { t.Errorf("parse clamped the version to %s; it should carry %s", got, over) } if len(parsed.Errors) != 0 { t.Errorf("parse reported a backend-only bound as an authoring error: %v", parsed.Errors) } } func TestSizeBound(t *testing.T) { head := "---\nid: sample-skill\nname: Sample Skill\npages:\n - candidates\n---\n\n" at := head + strings.Repeat("y", definition.MaxMarkdownLength-len(head)) if n := len([]rune(at)); n != definition.MaxMarkdownLength { t.Fatalf("fixture is %d characters, wanted exactly %d", n, definition.MaxMarkdownLength) } if err := definition.ValidateSkill(at); err != nil { t.Errorf("a definition exactly at the bound was refused: %v", err) } over := at + "y" err := definition.ValidateSkill(over) if err == nil { t.Fatal("a definition one character over the bound was accepted") } r, ok := err.(*definition.Rejection) if !ok || !r.BackendOnly { t.Errorf("the size bound should be reported as a backend-only rule, got %v", err) } }