package owliver import ( "testing" "github.com/krow/krow-backend/go-api/internal/domain" ) // Highlights — what is worth asking when nothing has been typed. // // Everything here is a pure function of a Context written out by hand, so the // assertions are about the ranking itself rather than about a fixture. The // database read that produces a real Context is exercised over HTTP, in // internal/httpserver. // ids is the intents a result names, in order. func ids(list []Suggestion) []string { out := make([]string, len(list)) for i, s := range list { out[i] = s.Intent } return out } // has reports whether an intent was offered. func has(list []Suggestion, intent string) bool { for _, s := range list { if s.Intent == intent { return true } } return false } // A workspace with nothing in it is asked nothing. // // The alternative — three questions about empty sets — is the padding this // whole path is supposed to refuse. "You have no positions, would you like to // know which position has the strongest pipeline?" is worse than silence. func TestHighlightsOfAnEmptyOrganizationAreEmpty(t *testing.T) { for _, page := range []string{"positions", "candidates", "control-center", "analytics", "talent-pool", "hired-history", "krow-forge", "activity"} { if got := Highlights(page, Context{}, domain.RoleAdmin); len(got) != 0 { t.Errorf("%s offered %v for an empty organization", page, ids(got)) } } } // The counts decide, and the loudest one leads. func TestHighlightsRankByTheData(t *testing.T) { cases := []struct { name string page string ctx Context wantTop string }{ { name: "unfinished drafts dominate a quiet workspace", page: "positions", ctx: Context{DraftPositions: 4, ActivePositions: 1}, wantTop: "position-drafts", }, { name: "a role nobody applied to outranks the drafts", page: "positions", ctx: Context{DraftPositions: 1, ActivePositions: 3, StarvedPositions: 2}, wantTop: "positions-attention", }, { // The tier, not the pile. This is the case weight × count gets // wrong: forty applications outnumber one abandoned role, and the // abandoned role is still the finding. name: "one starved role outranks a large pile of everything else", page: "positions", ctx: Context{ActivePositions: 9, StarvedPositions: 1, Applications: 40, Unscreened: 22}, wantTop: "positions-attention", }, { // And the thing that just happened is heard, however small. name: "a single unfinished draft is heard over a busy funnel", page: "positions", ctx: Context{ActivePositions: 9, DraftPositions: 1, Applications: 40, Unscreened: 22}, wantTop: "position-drafts", }, { name: "unscored candidates are the screening gap", page: "candidates", ctx: Context{Applications: 12, Unscreened: 9}, wantTop: "screening-gaps", }, { name: "a flagged interview is a risk before it is anything else", page: "candidates", ctx: Context{Applications: 12, Unscreened: 2, FlaggedRisks: 4}, wantTop: "candidate-risk", }, { name: "unverified profiles are the talent pool's gap", page: "talent-pool", ctx: Context{Profiles: 20, UnverifiedProfiles: 8}, wantTop: "talent-verification", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := Highlights(c.page, c.ctx, domain.RoleAdmin) if len(got) == 0 { t.Fatalf("%s offered nothing", c.page) } if got[0].Intent != c.wantTop { t.Fatalf("led with %q, want %q (whole list %v)", got[0].Intent, c.wantTop, ids(got)) } }) } } // A comparison needs two things to compare. // // "Which position has the strongest pipeline?" is not a question about a // workspace holding one position: the answer is a table of one row, which is // the same padding as a question about an empty set. func TestHighlightsDoNotOfferAComparisonOfOne(t *testing.T) { one := Highlights("positions", Context{ActivePositions: 1, Applications: 3}, domain.RoleAdmin) if has(one, "position-strength") { t.Fatalf("offered a pipeline comparison across one position: %v", ids(one)) } two := Highlights("positions", Context{ActivePositions: 2, Applications: 3}, domain.RoleAdmin) if !has(two, "position-strength") { t.Fatalf("two positions is a comparison and was not offered: %v", ids(two)) } } // Never more than three, and never the same reading twice. func TestHighlightsAreCappedAndDistinct(t *testing.T) { loud := Context{ ActivePositions: 9, DraftPositions: 7, StarvedPositions: 5, UnderfilledActive: 6, Applications: 40, Unscreened: 22, Shortlisted: 9, Interviewing: 6, Hired: 11, FlaggedRisks: 4, Staff: 30, Profiles: 60, UnverifiedProfiles: 25, Courses: 14, ActivityEvents: 900, } for page := range catalogue { got := Highlights(page, loud, domain.RoleAdmin) if len(got) > MaxSuggestions { t.Errorf("%s returned %d, the cap is %d", page, len(got), MaxSuggestions) } seen := map[string]bool{} for _, s := range got { if seen[s.Intent] { t.Errorf("%s repeated %q", page, s.Intent) } seen[s.Intent] = true if s.Text == "" || s.Intent == "" { t.Errorf("%s returned an incomplete suggestion: %+v", page, s) } // Nothing was typed, so nothing asked for a rendering. if s.Capability != "" { t.Errorf("%s carried a shape nobody asked for: %+v", page, s) } } } } // Permission is decided before relevance, exactly as it is in Suggest. // // A count cannot promote a reading the caller may not perform: talent's rows // are narrowed by the policy table, so an org-wide figure is not theirs to be // told even as a ranking input. func TestHighlightsRefuseWhatARoleCannotRead(t *testing.T) { loud := Context{ActivePositions: 9, DraftPositions: 7, Applications: 40, Unscreened: 22, Staff: 30, Profiles: 60} for _, page := range []string{"positions", "candidates", "hired-history", "control-center"} { if got := Highlights(page, loud, domain.RoleTalent); len(got) != 0 { t.Errorf("%s offered talent %v", page, ids(got)) } if got := Highlights(page, loud, domain.RoleAdmin); len(got) == 0 { t.Errorf("%s offered an admin nothing — the assertion above proves nothing", page) } } } // An unrecognised role is offered nothing, as everywhere else. func TestHighlightsDenyAnUnknownRole(t *testing.T) { loud := Context{ActivePositions: 9, Applications: 40, Unscreened: 22} for _, role := range []domain.Role{"", "root", "superuser", "Admin"} { if got := Highlights("positions", loud, role); len(got) != 0 { t.Errorf("role %q was offered %v", role, ids(got)) } } } // A page the catalogue does not hold answers with an empty slice, not nil. func TestHighlightsOfAnUnknownPageAreAnEmptySlice(t *testing.T) { loud := Context{ActivePositions: 9, Applications: 40} for _, page := range []string{"", "nowhere", "POSITIONS", "settings"} { got := Highlights(page, loud, domain.RoleAdmin) if got == nil { t.Fatalf("page %q returned nil, which a client cannot range over", page) } if len(got) != 0 { t.Errorf("page %q returned %v", page, ids(got)) } } } // The same state always produces the same three in the same order. func TestHighlightsAreDeterministic(t *testing.T) { ctx := Context{ActivePositions: 5, DraftPositions: 3, StarvedPositions: 2, Applications: 20, Unscreened: 7, Shortlisted: 4} first := ids(Highlights("positions", ctx, domain.RoleAdmin)) for range 25 { again := ids(Highlights("positions", ctx, domain.RoleAdmin)) if len(again) != len(first) { t.Fatalf("got %v, first run was %v", again, first) } for i := range first { if again[i] != first[i] { t.Fatalf("got %v, first run was %v", again, first) } } } } // Every declared Signal names an intent the catalogue actually holds, and is // monotonic: more of the thing it counts can never make the reading less // relevant. A signal that fell as its subject grew would be a sign error, and // the symptom would be a suggestion that disappears exactly when it matters. func TestSignalsAreMonotonic(t *testing.T) { small := Context{ ActivePositions: 2, DraftPositions: 1, StarvedPositions: 1, UnderfilledActive: 1, Applications: 5, Unscreened: 2, Shortlisted: 1, Interviewing: 1, Hired: 1, FlaggedRisks: 1, Staff: 2, Profiles: 3, UnverifiedProfiles: 1, Courses: 1, ActivityEvents: 10, } large := Context{ ActivePositions: 20, DraftPositions: 10, StarvedPositions: 10, UnderfilledActive: 10, Applications: 50, Unscreened: 20, Shortlisted: 10, Interviewing: 10, Hired: 10, FlaggedRisks: 10, Staff: 20, Profiles: 30, UnverifiedProfiles: 10, Courses: 10, ActivityEvents: 100, } for page, intents := range catalogue { for _, intent := range intents { if intent.Signal == nil { continue } lo, hi := intent.Signal(small), intent.Signal(large) if lo < 0 || hi < 0 { t.Errorf("%s/%s: a negative signal (%d, %d)", page, intent.ID, lo, hi) } if hi < lo { t.Errorf("%s/%s: signal fell as the workspace grew (%d → %d)", page, intent.ID, lo, hi) } } } } // A tier decides before a count does, everywhere. // // Stated as a property rather than as a list of pairs, because it is the whole // design: a signal is "how much does this matter when it is happening at all", // and a count that could climb into the tier above would make a large // organization's biggest pile outrank its most urgent finding. func TestATierAlwaysOutranksACount(t *testing.T) { for tier := 1; tier <= 9; tier++ { lowestAbove := when(1, tier+1) highestWithin := when(maxTiebreak*10, tier) // deliberately over the cap if highestWithin >= lowestAbove { t.Fatalf("tier %d saturates into tier %d: %d >= %d", tier, tier+1, highestWithin, lowestAbove) } } // Nothing happening scores nothing, whatever the tier claims. for tier := 1; tier <= 9; tier++ { for _, count := range []int{0, -1, -1000} { if got := when(count, tier); got != 0 { t.Fatalf("when(%d, %d) = %d, want 0", count, tier, got) } } } }