247 lines
7.9 KiB
Go
247 lines
7.9 KiB
Go
package tools_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/testutil"
|
|
"github.com/krow/krow-backend/go-api/internal/tools"
|
|
)
|
|
|
|
// An ai_score of 0 is the absence of a score, not a score of zero. The product
|
|
// states this rule in candidateIntelligence.js: "null rather than zeros ... so
|
|
// an unscreened candidate shows '—' instead of a confident-looking 0".
|
|
//
|
|
// candidates_quality shipped without a test and broke the rule three ways: it
|
|
// counted every unscored candidate as weak, dragged the average down with their
|
|
// zeros, and offered no way to tell how many the average was over. On the real
|
|
// corpus that reported "16 weak, averaging 28" where the truth was "1 weak, 9
|
|
// scored, averaging 76" — a number that would push a manager to reject a pool
|
|
// that is in fact strong.
|
|
func TestCandidateQualityTreatsAnAbsentScoreAsAbsentNotAsZero(t *testing.T) {
|
|
h := testutil.New(t)
|
|
f := seedFunnel(t, h, "quality-zero") // Dana: ai_screened, scored 88
|
|
reg := liveRegistry(t, h)
|
|
ctx := context.Background()
|
|
|
|
// Three more, sharing Dana's org and posting.
|
|
for _, c := range []struct {
|
|
name, email, status string
|
|
score int
|
|
}{
|
|
{"Unscored Applicant", "u1-quality@example.test", "applied", 0},
|
|
{"Advanced Unscored", "u2-quality@example.test", "interview", 0},
|
|
{"Genuinely Weak", "u3-quality@example.test", "ai_screened", 30},
|
|
} {
|
|
if _, err := h.Pool.Exec(ctx, `
|
|
INSERT INTO job_applications (org_id, job_posting_id, applicant_name, email, status, ai_score)
|
|
SELECT org_id, job_posting_id, $2, $3, $4::application_status, $5
|
|
FROM job_applications WHERE id = $1::uuid`,
|
|
f.appID, c.name, c.email, c.status, c.score); err != nil {
|
|
t.Fatalf("seed %s: %v", c.name, err)
|
|
}
|
|
}
|
|
|
|
res := reg.Dispatch(ctx,
|
|
tools.Context{Principal: f.admin, RunID: "run_q1"},
|
|
"candidates_quality", json.RawMessage(`{}`))
|
|
if res.Error != nil {
|
|
t.Fatalf("candidates_quality failed: %+v", res.Error)
|
|
}
|
|
|
|
var got struct {
|
|
Applications int `json:"applications"`
|
|
Strong int `json:"strong"`
|
|
Weak int `json:"weak"`
|
|
Unscreened int `json:"unscreened"`
|
|
Scored int `json:"scored"`
|
|
Average int `json:"averageMatchScore"`
|
|
Basis int `json:"averageMatchScoreBasis"`
|
|
}
|
|
body, _ := json.Marshal(res.Data)
|
|
if err := json.Unmarshal(body, &got); err != nil {
|
|
t.Fatalf("decode %s: %v", body, err)
|
|
}
|
|
|
|
// Only the 30 is weak. The two zeros are unscored, not bad.
|
|
for _, c := range []struct {
|
|
field string
|
|
got int
|
|
want int
|
|
}{
|
|
{"applications", got.Applications, 4},
|
|
{"strong", got.Strong, 1}, // 88
|
|
{"weak", got.Weak, 1}, // 30 only — not the two zeros
|
|
{"unscreened", got.Unscreened, 1}, // the one at 'applied'
|
|
{"scored", got.Scored, 2}, // 88 and 30
|
|
{"averageMatchScore", got.Average, 59}, // (88+30)/2, not (88+30+0+0)/4
|
|
{"averageMatchScoreBasis", got.Basis, 2},
|
|
} {
|
|
if c.got != c.want {
|
|
t.Errorf("%s = %d, want %d — full payload: %s", c.field, c.got, c.want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// seedRatedWorkers creates an org with workers at the given krow scores. A score
|
|
// of 0 is how the product records "not yet rated" — worker_profiles.krow_score is
|
|
// NOT NULL, so there is no null to distinguish it, which is exactly the trap.
|
|
func seedRatedWorkers(t *testing.T, h *testutil.Harness, slug string,
|
|
workers []struct {
|
|
name string
|
|
krow int
|
|
rated float64
|
|
}) (string, authctx.Identity) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
org := freshOrg(t, h, slug)
|
|
boss := fmt.Sprintf("boss-%s@example.test", slug)
|
|
admin := authctx.Identity{
|
|
UserID: seedUser(t, h, org, boss, "admin"),
|
|
OrgID: org, Role: "admin", Email: boss,
|
|
}
|
|
for i, w := range workers {
|
|
if _, err := h.Pool.Exec(ctx, `
|
|
INSERT INTO worker_profiles
|
|
(org_id, full_name, email, krow_score, reliability_score,
|
|
attendance_score, performance_score, client_rating)
|
|
VALUES ($1::uuid, $2, $3, $4, $4, $4, $4, $5)`,
|
|
org, w.name, fmt.Sprintf("w%d-%s@example.test", i, slug), w.krow, w.rated); err != nil {
|
|
t.Fatalf("seed worker %s: %v", w.name, err)
|
|
}
|
|
}
|
|
return org, admin
|
|
}
|
|
|
|
// A krow_score of 0 means "Not yet scored" — the product says so in as many
|
|
// words (dataResolver.js) and its lowest band starts above 0 (TalentPool.jsx).
|
|
// Reading it as a score of zero made hires_performance report the four unrated
|
|
// workers as the four *weakest* performers by name, and dragged every average
|
|
// down with them: a workforce rated 4.7 of 5 was reported at 1.6.
|
|
func TestPerformanceNeverNamesAnUnratedWorkerAsWeakest(t *testing.T) {
|
|
h := testutil.New(t)
|
|
org, admin := seedRatedWorkers(t, h, "perf-unrated", []struct {
|
|
name string
|
|
krow int
|
|
rated float64
|
|
}{
|
|
{"Scored High", 90, 5},
|
|
{"Scored Low", 50, 4},
|
|
{"Never Rated", 0, 0},
|
|
{"Also Never Rated", 0, 0},
|
|
})
|
|
_ = org
|
|
reg := liveRegistry(t, h)
|
|
|
|
res := reg.Dispatch(context.Background(),
|
|
tools.Context{Principal: admin, RunID: "run_p1"},
|
|
"hires_performance", json.RawMessage(`{}`))
|
|
if res.Error != nil {
|
|
t.Fatalf("hires_performance failed: %+v", res.Error)
|
|
}
|
|
|
|
var got struct {
|
|
Workers int `json:"workers"`
|
|
Scored int `json:"scored"`
|
|
Unscored int `json:"unscored"`
|
|
AvgKrow int `json:"averageKrowScore"`
|
|
AvgRated float64 `json:"averageClientRating"`
|
|
Weakest []struct {
|
|
Name string `json:"name"`
|
|
} `json:"weakest"`
|
|
Strongest []struct {
|
|
Name string `json:"name"`
|
|
} `json:"strongest"`
|
|
}
|
|
body, _ := json.Marshal(res.Data)
|
|
if err := json.Unmarshal(body, &got); err != nil {
|
|
t.Fatalf("decode %s: %v", body, err)
|
|
}
|
|
|
|
// The headline the operator acts on.
|
|
for _, c := range []struct {
|
|
field string
|
|
got int
|
|
want int
|
|
}{
|
|
{"workers", got.Workers, 4},
|
|
{"scored", got.Scored, 2},
|
|
{"unscored", got.Unscored, 2},
|
|
{"averageKrowScore", got.AvgKrow, 70}, // (90+50)/2, not (90+50+0+0)/4 = 35
|
|
} {
|
|
if c.got != c.want {
|
|
t.Errorf("%s = %d, want %d — payload: %s", c.field, c.got, c.want, body)
|
|
}
|
|
}
|
|
if got.AvgRated != 4.5 { // (5+4)/2, not (5+4+0+0)/4 = 2.25
|
|
t.Errorf("averageClientRating = %v, want 4.5 — payload: %s", got.AvgRated, body)
|
|
}
|
|
|
|
// The part that names real people.
|
|
for _, w := range append(append([]struct {
|
|
Name string `json:"name"`
|
|
}{}, got.Weakest...), got.Strongest...) {
|
|
if w.Name == "Never Rated" || w.Name == "Also Never Rated" {
|
|
t.Errorf("%q has no rating but was ranked by score — payload: %s", w.Name, body)
|
|
}
|
|
}
|
|
if len(got.Weakest) == 0 || got.Weakest[0].Name != "Scored Low" {
|
|
t.Errorf("weakest should start at the lowest *rated* worker — payload: %s", body)
|
|
}
|
|
}
|
|
|
|
// TalentPool.jsx puts the lowest band at (krow_score || 0) > 0 && < 60, so an
|
|
// unrated worker is counted separately rather than as the least ready.
|
|
func TestTalentPoolCountsUnratedSeparatelyFromLowScoring(t *testing.T) {
|
|
h := testutil.New(t)
|
|
_, admin := seedRatedWorkers(t, h, "pool-bands", []struct {
|
|
name string
|
|
krow int
|
|
rated float64
|
|
}{
|
|
{"Ready", 90, 5},
|
|
{"Developing", 70, 4},
|
|
{"Early", 30, 3},
|
|
{"Unrated", 0, 0},
|
|
})
|
|
reg := liveRegistry(t, h)
|
|
|
|
res := reg.Dispatch(context.Background(),
|
|
tools.Context{Principal: admin, RunID: "run_p2"},
|
|
"talent_pool", json.RawMessage(`{}`))
|
|
if res.Error != nil {
|
|
t.Fatalf("talent_pool failed: %+v", res.Error)
|
|
}
|
|
|
|
var got struct {
|
|
Workers int `json:"workers"`
|
|
JobReady int `json:"jobReady"`
|
|
Developing int `json:"developing"`
|
|
Early int `json:"early"`
|
|
Unscored int `json:"unscored"`
|
|
}
|
|
body, _ := json.Marshal(res.Data)
|
|
if err := json.Unmarshal(body, &got); err != nil {
|
|
t.Fatalf("decode %s: %v", body, err)
|
|
}
|
|
for _, c := range []struct {
|
|
field string
|
|
got int
|
|
want int
|
|
}{
|
|
{"workers", got.Workers, 4},
|
|
{"jobReady", got.JobReady, 1},
|
|
{"developing", got.Developing, 1},
|
|
{"early", got.Early, 1}, // the 30 only — not the unrated worker
|
|
{"unscored", got.Unscored, 1},
|
|
} {
|
|
if c.got != c.want {
|
|
t.Errorf("%s = %d, want %d — payload: %s", c.field, c.got, c.want, body)
|
|
}
|
|
}
|
|
}
|