package owliver import ( "fmt" "reflect" "strings" "testing" "github.com/krow/krow-backend/go-api/internal/definition" "github.com/krow/krow-backend/go-api/internal/domain" ) // These tests need no database and no server: the catalogue is static and the // ranking is a pure function of (page, query, role). That is the property worth // protecting — an endpoint the panel calls on every keystroke should be // testable at the speed of a string comparison. // intents is the ids Suggest returned, in order. func intents(got []Suggestion) []string { out := make([]string, len(got)) for i, s := range got { out[i] = s.Intent } return out } // ask is Suggest for an admin, the role the Owliver panel is placed for. func ask(page, query string) []Suggestion { return Suggest(page, query, domain.RoleAdmin) } /* ── Control Center ─────────────────────────────────────────────────────── */ func TestControlCenterSuggestions(t *testing.T) { cases := []struct { name string query string want []string }{ {"attention", "attention", []string{"attention-required"}}, {"pipeline", "pipeline", []string{"pipeline-health"}}, {"health", "health", []string{"platform-health"}}, {"recommendation", "what should i do", []string{"recommendations"}}, // A question about a subject this page does not hold. The Control // Center reads the platform, not the training library. {"irrelevant", "forklift certification renewal", nil}, {"empty", "", nil}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := intents(ask("control-center", c.query)) if len(c.want) == 0 { if len(got) != 0 { t.Fatalf("query %q: want no suggestions, got %v", c.query, got) } return } if !reflect.DeepEqual(got, c.want) { t.Fatalf("query %q: got %v, want %v", c.query, got, c.want) } }) } } /* ── Positions ──────────────────────────────────────────────────────────── */ func TestPositionsSuggestions(t *testing.T) { cases := []struct { name string query string want []string }{ // The page's own noun offers the page's readings, in declaration order. {"position", "position", []string{"position-drafts", "position-strength", "positions-attention"}}, // Pipeline on Positions is a question about the ROLES: which one is // converting, and where it is stuck. Two answers, not three — the third // slot is left empty rather than filled with the page's list of people // waiting, which is a different question wearing a nearby word. {"pipeline", "pipeline", []string{"position-strength", "pipeline-health"}}, {"attention", "attention", []string{"positions-attention"}}, {"risk", "risk", []string{"positions-attention"}}, {"drafts", "draft", []string{"position-drafts"}}, {"fill first", "what should i fill first", []string{"hiring-priority"}}, // Attendance is a workforce reading and belongs to another surface. It // must not fall through to this page's default report. {"irrelevant", "attendance last week", nil}, {"empty", "", nil}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := intents(ask("positions", c.query)) if len(c.want) == 0 { if len(got) != 0 { t.Fatalf("query %q: want no suggestions, got %v", c.query, got) } return } if !reflect.DeepEqual(got, c.want) { t.Fatalf("query %q: got %v, want %v", c.query, got, c.want) } }) } } /* ── Candidates ─────────────────────────────────────────────────────────── */ // Creating a record is offered on the words people actually type, and the two // creates are told apart by the words that distinguish them. // // This is the half of the feature that was missing entirely: the flow behind // "create a position" worked, and no chip anywhere offered it. Every phrasing // below reached the frontend's trigger matcher already — the gap was that the // panel never suggested any of them. func TestCreateIntentsAreOffered(t *testing.T) { for _, c := range []struct { query string want string }{ {"create position", "create-company-position"}, {"create positions", "create-company-position"}, {"create a position", "create-company-position"}, {"create new position", "create-company-position"}, {"new position", "create-company-position"}, {"post a job", "create-company-position"}, {"create a company position", "create-company-position"}, {"create an employee role", "create-employee-role"}, {"create employee role", "create-employee-role"}, {"add an employee role", "create-employee-role"}, {"new employee role", "create-employee-role"}, {"create worker role", "create-employee-role"}, } { t.Run(c.query, func(t *testing.T) { got := intents(ask("positions", c.query)) if len(got) == 0 || got[0] != c.want { t.Fatalf("query %q: got %v, want %s first", c.query, got, c.want) } }) } // And the supply-side create is on the page that reads the supply. if got := intents(ask("talent-pool", "create an employee role")); len(got) == 0 || got[0] != "create-employee-role" { t.Errorf("talent-pool: got %v, want create-employee-role first", got) } } // A create chip is never proposed to somebody who cannot create. // // The gate is the policy table, not a role list repeated here: employee-roles // and job-postings both grant Create to operators only, so talent is offered // neither — while still being offered their own readings elsewhere, which // TestTalentIsStillOfferedTheirOwnReadings holds. func TestTalentIsNeverOfferedACreate(t *testing.T) { for _, page := range []string{"positions", "talent-pool"} { for _, query := range []string{ "create position", "create a position", "new position", "post a job", "create an employee role", "add an employee role", "employee role", } { for _, s := range Suggest(page, query, domain.RoleTalent) { if strings.HasPrefix(s.Intent, "create-") { t.Errorf("talent was offered %q on %q for %q", s.Intent, page, query) } } } } } // The create chips arrive without evicting a reading. // // Their terms are phrases only for exactly this reason. A bare "position" term // would score 10 — the same as every reading on the page — and win the tie on // declaration order, silently pushing `positions-attention` out of the three. // The reading a person asked for must not be displaced by an offer to create // something, so this pins the page's own noun to the page's own answers. func TestCreateIntentsDoNotDisplaceReadings(t *testing.T) { for _, query := range []string{"position", "positions", "role", "roles", "draft"} { for _, s := range Suggest("positions", query, domain.RoleAdmin) { if strings.HasPrefix(s.Intent, "create-") { t.Errorf("%q offered %q; a bare page noun must answer with readings", query, s.Intent) } } } } func TestCandidatesSuggestions(t *testing.T) { cases := []struct { name string query string want []string }{ {"candidate", "candidate", []string{"candidates-attention", "top-candidates", "interview-ready"}}, {"score", "score", []string{"top-candidates", "screening-gaps"}}, {"interview", "interview", []string{"interview-ready"}}, {"decision", "decision", []string{"candidates-attention", "candidate-risk"}}, {"pipeline", "pipeline", []string{"pipeline-summary"}}, {"risk", "risk", []string{"candidate-risk"}}, {"irrelevant", "payroll export", nil}, {"empty", "", nil}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := intents(ask("candidates", c.query)) if len(c.want) == 0 { if len(got) != 0 { t.Fatalf("query %q: want no suggestions, got %v", c.query, got) } return } if !reflect.DeepEqual(got, c.want) { t.Fatalf("query %q: got %v, want %v", c.query, got, c.want) } }) } } /* ── Page context is the first filter ───────────────────────────────────── */ // One keyword, every page: the answers must differ, and none may name a // reading belonging to another surface. func TestSameKeywordDiffersByPage(t *testing.T) { const query = "pipeline" seen := map[string][]string{} for _, page := range Pages() { got := intents(ask(page, query)) if len(got) == 0 { continue } seen[page] = got for _, id := range got { if !declaredOn(page, id) { t.Fatalf("page %q returned %q, which it does not declare", page, id) } } } if len(seen) < 2 { t.Fatalf("%q matched on %d pages; the comparison needs at least two", query, len(seen)) } if reflect.DeepEqual(seen["positions"], seen["candidates"]) { t.Fatalf("positions and candidates both answered %q with %v", query, seen["positions"]) } // The pipeline reading on Candidates is the candidates' own. if !reflect.DeepEqual(seen["candidates"], []string{"pipeline-summary"}) { t.Fatalf("candidates answered %q with %v", query, seen["candidates"]) } } // An unknown page is not this package's error to raise — the service refuses it // during parsing. Reached directly it answers empty rather than borrowing // another page's readings. func TestUnknownPageIsEmpty(t *testing.T) { for _, page := range []string{"", "nowhere", "POSITIONS", "settings"} { if got := ask(page, "pipeline"); len(got) != 0 { t.Fatalf("page %q: got %v, want none", page, got) } } } func declaredOn(page, id string) bool { for _, i := range catalogue[page] { if i.ID == id { return true } } return false } /* ── Query handling ─────────────────────────────────────────────────────── */ func TestQueryNormalization(t *testing.T) { want := intents(ask("positions", "pipeline")) if len(want) == 0 { t.Fatal("the baseline query matched nothing") } // Every one of these is the same question typed differently: case, // surrounding whitespace, punctuation and control characters carry no // meaning, so all of them must rank identically. for _, query := range []string{ " pipeline ", "PIPELINE", "PiPeLiNe", "\tpipeline\n", "pipeline?", "\"pipeline\"", "pipeline!!!", "…pipeline…", "(pipeline)", "**pipeline**", "pipeline\x00\x01", "\u200bpipeline", } { if got := intents(ask("positions", query)); !reflect.DeepEqual(got, want) { t.Errorf("query %q: got %v, want %v", query, got, want) } } } // Hostile input is data like any other. There is no query to inject into — the // normalized text is compared against a fixed table of literals and never // reaches SQL, a template or a shell — so the property under test is that such // a query is ranked rather than refused, and that it can only ever produce // entries this page declares. func TestHostileInputIsJustText(t *testing.T) { for _, query := range []string{ "pipeline'; DROP TABLE job_postings; --", "pipeline\" OR 1=1 --", "", "{{7*7}} pipeline ${jndi:ldap://x/y}", "../../etc/passwd pipeline", "pipeline%00%0d%0aSet-Cookie:+x=1", strings.Repeat("' OR ''='", 40), } { for _, s := range ask("positions", query) { if !declaredOn("positions", s.Intent) { t.Errorf("query %q produced %q, which positions does not declare", query, s.Intent) } if !declaredText("positions", s) { t.Errorf("query %q produced unrecognised text %q", query, s.Text) } } } } // declaredText reports whether a suggestion's wording came from the catalogue — // either an intent's own Text, or its subject phrased in a shape it declares. // Nothing the caller typed may appear in a response. func declaredText(page string, got Suggestion) bool { for _, i := range catalogue[page] { if i.ID != got.Intent { continue } if got.Capability == "" { return got.Text == i.Text } for _, s := range shapes { if s.id == got.Capability { return got.Text == i.shaped(s) } } } return false } // Fewer than two meaningful characters is not a question yet. Punctuation and // whitespace are not meaningful. func TestShortQueriesAreEmpty(t *testing.T) { for _, query := range []string{"", " ", "\n\t ", "p", " p ", "?", "!!!", "-", "€", " , "} { if got := ask("positions", query); len(got) != 0 { t.Errorf("query %q: got %v, want none", query, got) } } } // The panel calls this on every keystroke, so a half-typed word has to match. func TestPrefixMatchingWhileTyping(t *testing.T) { full := intents(ask("positions", "pipeline")) for _, query := range []string{"pip", "pipe", "pipel", "pipelin", "pipeline", "pipelines"} { got := intents(ask("positions", query)) if len(got) == 0 { t.Fatalf("query %q matched nothing; the panel would blank mid-word", query) } if query != "pipelines" && !reflect.DeepEqual(got, full) { t.Errorf("query %q: got %v, want %v", query, got, full) } } } // A long paste ranks on its opening words rather than being refused. func TestOverlongQueryIsTruncatedNotRejected(t *testing.T) { query := "pipeline " + strings.Repeat("x", 5000) got := intents(ask("positions", query)) if len(got) == 0 { t.Fatal("an overlong query was refused instead of truncated") } if !reflect.DeepEqual(got, intents(ask("positions", "pipeline"))) { t.Fatalf("an overlong query ranked differently: %v", got) } } /* ── Shapes ─────────────────────────────────────────────────────────────── */ // Asking for a section type names it in the answer and phrases the suggestion // in those terms — the brief's "Show hiring activity as a flow". func TestShapedSuggestions(t *testing.T) { got := ask("positions", "show hiring activity as a flow") if len(got) != 1 { t.Fatalf("got %d suggestions, want 1: %+v", len(got), got) } want := Suggestion{ Text: "Show hiring activity as a flow", Intent: "hiring-operations", Capability: "flow", } if got[0] != want { t.Fatalf("got %+v, want %+v", got[0], want) } summarized := ask("positions", "summarize hiring activity") if len(summarized) != 1 || summarized[0].Capability != "summary" || summarized[0].Text != "Summarize hiring activity" { t.Fatalf("got %+v", summarized) } } // A shape alone is a question about the page. A shape after a subject is a // question about that subject, and the other readings that merely support the // shape are padding — which this endpoint does not do. func TestShapeAloneAnswersThePageButNeverPads(t *testing.T) { alone := ask("control-center", "summarize") if len(alone) != MaxSuggestions { t.Fatalf("a bare shape returned %d suggestions, want %d: %+v", len(alone), MaxSuggestions, alone) } for _, s := range alone { if s.Capability != "summary" { t.Fatalf("got capability %q, want summary: %+v", s.Capability, s) } } // "attention" names one reading; nothing else may ride along on the shape. withSubject := ask("control-center", "summarize what needs attention") if len(withSubject) != 1 || withSubject[0].Intent != "attention-required" { t.Fatalf("got %+v, want only attention-required", withSubject) } } // A shape an intent cannot be drawn as leaves its wording alone. func TestUnsupportedShapeIsNotClaimed(t *testing.T) { for _, s := range ask("create-position", "adjust the weights") { if s.Capability == "weights" { t.Fatalf("offered a weights rendering nothing declares: %+v", s) } } } /* ── Response limits ────────────────────────────────────────────────────── */ func TestNeverMoreThanThreeAndNeverDuplicated(t *testing.T) { // A query broad enough to match everything the page has. queries := []string{ "position pipeline attention risk draft hiring activity waiting candidates", "candidate score interview decision risk pipeline screening", "summarize", "attention risk", "who what how many", } for _, page := range Pages() { for _, query := range queries { got := Suggest(page, query, domain.RoleAdmin) if len(got) > MaxSuggestions { t.Fatalf("page %q query %q: %d suggestions, cap is %d", page, query, len(got), MaxSuggestions) } seenIntent, seenText := map[string]bool{}, map[string]bool{} for _, s := range got { if s.Text == "" || s.Intent == "" { t.Fatalf("page %q query %q: incomplete suggestion %+v", page, query, s) } if seenIntent[s.Intent] { t.Fatalf("page %q query %q: duplicate intent %q", page, query, s.Intent) } if seenText[strings.ToLower(s.Text)] { t.Fatalf("page %q query %q: duplicate text %q", page, query, s.Text) } seenIntent[s.Intent], seenText[strings.ToLower(s.Text)] = true, true } } } } // The same request must answer the same way every time — the panel re-issues it // on every keystroke, and a list that reshuffles under the cursor is unusable. func TestSuggestIsDeterministic(t *testing.T) { for _, page := range Pages() { first := Suggest(page, "attention risk pipeline summary", domain.RoleAdmin) for i := 0; i < 20; i++ { again := Suggest(page, "attention risk pipeline summary", domain.RoleAdmin) if !reflect.DeepEqual(first, again) { t.Fatalf("page %q: run %d differed\n first: %+v\n again: %+v", page, i, first, again) } } } } // Empty, never nil: `{"suggestions": []}` and not `{"suggestions": null}`. func TestNoMatchIsAnEmptySliceNotNil(t *testing.T) { for _, c := range []struct{ page, query string }{ {"positions", "sourdough"}, {"positions", ""}, {"nowhere", "pipeline"}, } { got := ask(c.page, c.query) if got == nil { t.Fatalf("page %q query %q: got nil, want an empty slice", c.page, c.query) } if len(got) != 0 { t.Fatalf("page %q query %q: got %v", c.page, c.query, got) } } } /* ── Authorization ──────────────────────────────────────────────────────── */ // Talent may list job applications, but only their own — so a reading across // the organization's pipeline is not theirs to be offered, even though the // operation itself is permitted. The same holds for postings, profiles, staff, // evidence and the audit log. // // The exception is stated rather than hidden: `courses` is the one resource in // the policy table that talent lists unscoped, because the training library is // shared platform-wide and everybody learns from it. So the two Forge readings // that ask only what the library holds survive, and every other Forge reading — // evaluation, workforce usage, gaps, all of which read evidence or profiles — // does not. If that ever widens, this test says exactly what widened. func TestTalentIsOfferedOnlyUnscopedReadings(t *testing.T) { pages := []string{ "control-center", "positions", "candidates", "candidates-analysis", "analytics", "activity", "talent-pool", "hired-history", "krow-forge", "create-position", } queries := []string{ "pipeline", "attention", "candidate", "position", "risk", "summarize", "hiring", "score", "activity", "who", "how many", "library", "skill", "published", "gaps", "evaluation", "weights", "credential", } allowed := map[string]bool{"krow-forge/forge-library": true, "krow-forge/forge-published": true} for _, page := range pages { for _, query := range queries { for _, s := range Suggest(page, query, domain.RoleTalent) { if !allowed[page+"/"+s.Intent] { t.Errorf("talent was offered %q on %q for %q", s.Intent, page, query) } } } } // And the operator's own Forge readings stay the operator's. for _, id := range []string{"forge-evaluation", "forge-workforce", "forge-gaps"} { for _, s := range Suggest("krow-forge", "evaluation workforce gaps", domain.RoleTalent) { if s.Intent == id { t.Errorf("talent was offered the operator reading %q", id) } } } if len(Suggest("krow-forge", "evaluation workforce gaps", domain.RoleAdmin)) == 0 { t.Error("admin was offered none of them either; the query no longer matches") } } // The filter is not a blanket refusal: what a talent caller may genuinely ask — // about their own account — is still offered. Otherwise the test above would // pass with the role check stubbed out to "deny". func TestTalentIsStillOfferedTheirOwnReadings(t *testing.T) { for _, query := range []string{"permission", "password", "my recent activity"} { if got := Suggest("profile", query, domain.RoleTalent); len(got) == 0 { t.Fatalf("talent was offered nothing on profile for %q", query) } } } // A role the API does not recognise authorizes nothing, matching policy.go. func TestUnknownRoleIsOfferedNothing(t *testing.T) { for _, role := range []domain.Role{"", "root", "superuser", "Admin"} { for _, page := range Pages() { if got := Suggest(page, "attention pipeline permission", role); len(got) != 0 { t.Fatalf("role %q was offered %v on %q", role, intents(got), page) } } } } // Every permission decision must come from the policy table, not from a list // kept here. This asserts the mechanism rather than a particular outcome: an // intent is offered exactly when policy.go allows every reading it declares. func TestPermissionsComeFromThePolicyTable(t *testing.T) { for _, role := range []domain.Role{domain.RoleAdmin, domain.RoleEmployer, domain.RoleTalent} { for page, list := range catalogue { for _, intent := range list { want := true for _, need := range intent.Reads { res, ok := domain.ResourceByPath[need.Resource] if !ok || !res.Supports(need.Op) || !res.Policy.Allows(need.Op, role) { want = false break } if need.OrgWide && res.Policy.ScopeFor(role).Kind != domain.ScopeNone { want = false break } } if got := intent.permitted(role); got != want { t.Errorf("%s/%s for %s: permitted=%v, policy says %v", page, intent.ID, role, got, want) } } } } } /* ── The catalogue itself ───────────────────────────────────────────────── */ func TestCatalogueIsWellFormed(t *testing.T) { for page, list := range catalogue { if definition.CanonicalPage(page) != page { t.Errorf("page key %q is not a canonical surface", page) } if len(list) == 0 { t.Errorf("page %q has no intents; omit the key instead", page) } ids, texts := map[string]bool{}, map[string]bool{} for _, intent := range list { where := fmt.Sprintf("%s/%s", page, intent.ID) if intent.ID == "" || intent.Text == "" { t.Errorf("%s: an intent needs both an id and a text", where) } if ids[intent.ID] { t.Errorf("%s: duplicate intent id on this page", where) } if texts[strings.ToLower(intent.Text)] { t.Errorf("%s: duplicate suggestion text on this page", where) } ids[intent.ID], texts[strings.ToLower(intent.Text)] = true, true if len(intent.Terms) == 0 { t.Errorf("%s: no terms, so it can never be suggested", where) } for _, term := range intent.Terms { if term != strings.ToLower(strings.TrimSpace(term)) || term == "" { t.Errorf("%s: term %q must be lower case and trimmed", where, term) } if _, _, meaningful := normalize(term); meaningful < MinQueryChars { t.Errorf("%s: term %q is shorter than the shortest query", where, term) } } if len(intent.Shapes) > 0 && intent.Subject == "" { t.Errorf("%s: declares shapes but no subject to phrase them with", where) } for _, id := range intent.Shapes { if id == "summary" { t.Errorf("%s: `summary` applies to every subject and is never declared", where) } if !knownShape(id) { t.Errorf("%s: shape %q is not in the Owliver vocabulary", where, id) } } for _, need := range intent.Reads { res, ok := domain.ResourceByPath[need.Resource] if !ok { t.Errorf("%s: reads %q, which is not a resource", where, need.Resource) continue } if !res.Supports(need.Op) { t.Errorf("%s: reads %q with an operation it does not serve", where, need.Resource) } // An intent nobody can be offered is dead weight, and usually a // typo in the resource path rather than a deliberate lockout. if !res.Policy.Allows(need.Op, domain.RoleAdmin) { t.Errorf("%s: reads %q, which not even admin may list", where, need.Resource) } } } } } // Every id in the catalogue must be a capability the frontend actually // declares, because the id in a response is what the panel dispatches on. The // list is the union of the manifests in // src/components/ai-assistant/capabilities/, transcribed alongside the // catalogue; an id here that is absent there would be a suggestion the panel // cannot run. func TestIntentIDsAreFrontendCapabilities(t *testing.T) { frontend := map[string]bool{} for _, id := range []string{ // CONTROL_CENTER_CAPABILITIES "platform-health", "workforce-summary", "hiring-operations", "pipeline-health", "attention-required", "recommendations", // POSITIONS_CAPABILITIES "position-drafts", "position-strength", "positions-attention", "hiring-priority", "candidates-waiting", // The two conversational writes. Not manifest ids: no context declares // `capabilities`, so every server chip dispatches as its own TEXT and is // answered by the skill whose trigger that text matches. They are listed // here because this test is the bijection that keeps a suggestion the // panel cannot run out of the catalogue, and the coupling that makes // these runnable — chip text to skill trigger — is asserted by // `npm test` on the frontend side. "create-company-position", "create-employee-role", // CANDIDATE_LIST_CAPABILITIES "candidates-attention", "top-candidates", "interview-ready", "screening-gaps", "pipeline-summary", "candidate-risk", // ADMIN_CANDIDATE_CAPABILITIES "recruitment-insights", "hiring-recommendations", // ADMIN_ANALYTICS_CAPABILITIES "hiring-trend", "department-performance", "position-conversion", // ACTIVITY_CAPABILITIES "audit-summary", "user-activity", "unusual-activity", "security-insights", // TALENT_POOL_CAPABILITIES "talent-priorities", "talent-summary", "talent-verification", "talent-availability", // HIRED_HISTORY_CAPABILITIES "hiring-outcomes", "hiring-strongest", "hiring-patterns", "hiring-recent", // FORGE_CAPABILITIES "forge-library", "forge-published", "forge-evaluation", "forge-workforce", "forge-gaps", // CREATE_POSITION_CAPABILITIES "vetting-weights", "position-benchmarks", "position-requirements", "position-spec-steps", // PROFILE_CAPABILITIES "profile-permissions", "profile-identity", "profile-security", "profile-preferences", "profile-activity", "profile-actions", } { frontend[id] = true } used := map[string]bool{} for page, list := range catalogue { for _, intent := range list { used[intent.ID] = true if !frontend[intent.ID] { t.Errorf("%s/%s names no frontend capability", page, intent.ID) } } } for id := range frontend { if !used[id] { t.Errorf("capability %q is declared here but suggested on no page", id) } } } func knownShape(id string) bool { for _, s := range shapes { if s.id == id { return true } } return false } // The shape vocabulary is closed and mirrors OWLIVER_CAPABILITIES. func TestShapeVocabularyMatchesTheFrontend(t *testing.T) { want := []string{"summary", "flow", "stats", "list", "table", "timeline", "progress", "weights", "insight", "card"} got := make([]string, len(shapes)) for i, s := range shapes { got[i] = s.id if len(s.terms) == 0 { t.Errorf("shape %q has no terms", s.id) } if s.id != "summary" && s.phrase == "" { t.Errorf("shape %q has no phrase to read inside a sentence", s.id) } } if !reflect.DeepEqual(got, want) { t.Fatalf("shapes are %v, want %v", got, want) } } // Nothing internal may reach a response: no terms, no resource names, no // scores. The struct is the whole contract, so this asserts its shape. func TestSuggestionExposesNothingInternal(t *testing.T) { fields := reflect.VisibleFields(reflect.TypeOf(Suggestion{})) if len(fields) != 3 { t.Fatalf("Suggestion has %d fields; the response contract is text, intent, capability", len(fields)) } want := map[string]string{ "Text": `json:"text"`, "Intent": `json:"intent"`, "Capability": `json:"capability,omitempty"`, } for _, f := range fields { if string(f.Tag) != want[f.Name] { t.Errorf("field %s has tag %q, want %q", f.Name, f.Tag, want[f.Name]) } } }