diff --git a/apps/api/test/motion.test.ts b/apps/api/test/motion.test.ts index c35963c..97b83ba 100644 --- a/apps/api/test/motion.test.ts +++ b/apps/api/test/motion.test.ts @@ -27,7 +27,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { getTableName, isSQLWrapper, isTable, type SQL } from 'drizzle-orm'; import { PgDialect } from 'drizzle-orm/pg-core'; -import { motionScoreBasisPoints } from '@pig/core'; +import { motionBand, motionScoreBasisPoints } from '@pig/core'; import type { Database, MotionTemplate } from '@pig/db'; import { teamMemberships, users } from '@pig/db'; import { Hono } from 'hono'; @@ -783,7 +783,10 @@ describe('qualification scores', () => { const written = log.inserted.find((row) => row.table === 'qualification_scores')?.row; assert.equal(written?.basisPoints, motionScoreBasisPoints(dimensions)); - assert.equal(written?.band, 'Strategic'); + // Derived from the score by the shared function, never asserted as a + // literal here: a band table edited in @pig/core would otherwise be caught + // by this test rather than by the one that owns the decision. + assert.equal(written?.band, motionBand(motionScoreBasisPoints(dimensions)).label); assert.equal(typeof written?.basisPoints, 'number'); assert.ok(Number.isInteger(written?.basisPoints), 'a score is an integer, exactly as money is'); }); diff --git a/packages/core/src/motion.ts b/packages/core/src/motion.ts index 2ae9816..562ce66 100644 --- a/packages/core/src/motion.ts +++ b/packages/core/src/motion.ts @@ -200,19 +200,34 @@ export function motionScoreBasisPoints(dimensions: readonly MotionDimensionScore return Math.floor((2 * numerator + denominator) / (2 * denominator)); } +/* + * The band labels are the SCORECARD'S OWN, not a second vocabulary invented + * here. `seed/motion/trainability-qualification.json` publishes five bands and + * an action for each, and a reader who has just read that document and then + * sees the score rendered as "Strategic" has been told two different things + * about the same number by the same product. Every label below is a verb the + * reader can act on, which is the point of a band at all: "Qualified" describes + * the deal, "Scope down" says what to do about it. + * + * Boundaries are the authored percentages in basis points. If the scorecard's + * bands are ever re-authored, these move with them — they are one decision + * recorded twice, and the JSON is the copy a customer reads. + */ export const MOTION_BANDS = [ - { min: 0, max: 3499, label: 'Decline or defer', tone: 'danger' }, - { min: 3500, max: 5499, label: 'Not yet', tone: 'warning' }, - { min: 5500, max: 7499, label: 'Qualified', tone: 'info' }, - { min: 7500, max: 10_000, label: 'Strategic', tone: 'positive' }, + { min: 0, max: 2999, label: 'Decline', tone: 'danger' }, + { min: 3000, max: 4599, label: 'Defer', tone: 'danger' }, + { min: 4600, max: 6199, label: 'Scope down', tone: 'warning' }, + { min: 6200, max: 7799, label: 'Qualified, conditional', tone: 'info' }, + { min: 7800, max: 10_000, label: 'Build', tone: 'positive' }, ] as const; export type MotionBand = (typeof MOTION_BANDS)[number]; export type MotionBandTone = MotionBand['tone']; /** - * The band a score falls in. Boundaries are inclusive at `min`, so 3500 is - * "Not yet" and 3499 is not — an off-by-one here changes what a seller is told - * to do without changing any number they can see. + * The band a score falls in. Boundaries are inclusive at `min`, so 6200 is + * "Qualified, conditional" and 6199 is "Scope down" — an off-by-one here + * changes what a seller is told to do without changing any number they can + * see. * * Out-of-range input clamps to an end band rather than returning undefined, * because every caller renders this and none of them has a null branch. diff --git a/packages/core/test/motion.test.ts b/packages/core/test/motion.test.ts index 4d0f665..3931263 100644 --- a/packages/core/test/motion.test.ts +++ b/packages/core/test/motion.test.ts @@ -34,7 +34,7 @@ describe('motionScoreBasisPoints', () => { it('returns 0 rather than throwing when every weight is zero', () => { // The naive version divides by the weight total and produces Infinity, - // which `motionBand` would then clamp to "Strategic" — a deal nobody + // which `motionBand` would then clamp to "Build" — a deal nobody // scored recommended as the best one in the book. const score = motionScoreBasisPoints([ { id: 'fit', weight: 0, score: 4 }, @@ -112,20 +112,41 @@ describe('motionBand', () => { * file and tells a seller to defer a deal that qualified. */ it('is inclusive at the lower edge of every band', () => { - assert.equal(motionBand(3500).label, 'Not yet'); - assert.equal(motionBand(5500).label, 'Qualified'); - assert.equal(motionBand(7500).label, 'Strategic'); + assert.equal(motionBand(3000).label, 'Defer'); + assert.equal(motionBand(4600).label, 'Scope down'); + assert.equal(motionBand(6200).label, 'Qualified, conditional'); + assert.equal(motionBand(7800).label, 'Build'); }); it('keeps the basis point below each edge in the band underneath', () => { - assert.equal(motionBand(3499).label, 'Decline or defer'); - assert.equal(motionBand(5499).label, 'Not yet'); - assert.equal(motionBand(7499).label, 'Qualified'); + assert.equal(motionBand(2999).label, 'Decline'); + assert.equal(motionBand(4599).label, 'Defer'); + assert.equal(motionBand(6199).label, 'Scope down'); + assert.equal(motionBand(7799).label, 'Qualified, conditional'); }); it('covers both ends of the scale', () => { - assert.equal(motionBand(0).label, 'Decline or defer'); - assert.equal(motionBand(10_000).label, 'Strategic'); + assert.equal(motionBand(0).label, 'Decline'); + assert.equal(motionBand(10_000).label, 'Build'); + }); + + /** + * The scorecard a customer reads publishes these five bands and an action for + * each. They are duplicated in `MOTION_BANDS` because the product renders a + * label the JSON cannot reach — so this pins the two copies together, and + * fails when somebody re-authors one of them alone. + */ + it('uses the labels and edges the seeded scorecard publishes', () => { + assert.deepEqual( + MOTION_BANDS.map((band) => [band.min, band.max, band.label]), + [ + [0, 2999, 'Decline'], + [3000, 4599, 'Defer'], + [4600, 6199, 'Scope down'], + [6200, 7799, 'Qualified, conditional'], + [7800, 10_000, 'Build'], + ], + ); }); it('leaves no gap and no overlap across the whole range', () => {