Files
krow_backend/go-api/internal/owliver/suggest_test.go
Suriyakumarvijayanayagam dc785b917c
Some checks failed
CI / test (push) Failing after 4m41s
CI / fixture (push) Failing after 8s
Separate what a worker does from what a company needs filled
Owliver could offer neither create. The Create Position flow worked and no chip
anywhere suggested it, because the chip row is entirely the backend's static
catalogue and no intent in it wrote anything. The gap was never in the
frontend's trigger matching — every phrasing already routed.

`employee_roles` is the supply side of `job_postings`. A posting is what the
ORGANIZATION needs filled; this is what a WORKER says they do. They share a
vocabulary and almost nothing else: "3 years" on a posting is a minimum an
applicant must clear, and the same words here are what the person has. There is
deliberately no foreign key between them — supply and demand already meet
through `job_applications`, which carries the funnel, the interview and the
outcome, and a second weaker link would disagree with it the first time
somebody withdrew.

NO NEW COMPANY ENTITY, AND THAT IS THE LOAD-BEARING DECISION. "Create a company
position" reads like it needs a client record. `organizations` is the TENANT —
absent from the resource table, absent from the policy map, written only by the
seeder — so creating a row there from a chat flow would provision a new tenant,
and the position would carry an org_id the operator's session cannot see. The
operator could never view the record they just created. That breaks I5 and I1
to add a feature nobody asked for. The client stays free text on the posting,
per blueprint decision D2, and the flow simply offers the clients this
organization already staffs for as chips. No schema change, no endpoint change.

Create is operators-only, and that is an I1 decision rather than a deferral.
The worker is named explicitly on the row and is deliberately NOT derived from
the session, because an operator recording a role on somebody's behalf is the
whole point of the flow. Granting talent the same Create would let a talent
caller write a role under any worker_email in the tenant — the attribution hole
Phase 3D closed elsewhere. Talent reads its own via a ScopeEmail predicate,
which is in place now so the grant is one line when a talent console exists.

`created_by` is in gen_resources.py's SERVER_OWNED as well as the policy's
Derived list. Both are required and the pairing is easy to miss: Derived fills
the column from the session, SERVER_OWNED is what makes the descriptor ReadOnly
so a request body cannot set it in the first place. Without it,
TestDerivedColumnsAreReadOnlyOrTalentScoped fails — verified by mutation, not
by reading.

The two catalogue intents carry PHRASE terms only. A bare "position" or "role"
term scores 10, the same as every reading on that page, and wins the tie on
declaration order — so a create chip would have arrived by evicting
`positions-attention` from the exact ordered result TestPositionsSuggestions
asserts. An offer to create something must not displace the reading a person
actually asked for. Neither declares a Subject, on the precedent of
`position-spec-steps`: a Subject would let the bare query "summarize" match
through matchShape and survive filterOnTopic. Neither declares a Signal, so an
empty composer still reports what the organization needs rather than proposing
paperwork.

Chip text is the coupling with nothing else holding it together: no page
context declares `capabilities`, so every server suggestion dispatches as its
own TEXT and is answered by whichever skill's trigger that text matches. A
renamed chip would open nothing, silently. Asserted on the frontend side.

The down migration drops `employee_role_status` and keeps `english_level`,
which is shared with job_postings.english_required and
job_applications.english_level. Rolled back and re-applied against the
database to prove it, not asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-09-02 15:29:25 +05:30

783 lines
28 KiB
Go

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 --",
"<script>alert('pipeline')</script>",
"{{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])
}
}
}