516685526c
The ledger answers which contracted capacity is sold, to whom, at what margin. It says nothing about the motion — the repeatable practice that turns a customer conversation into a scoped deployment, and turns that deployment into something the next one reuses. Motion is deliberately not a parallel entity tree. DEMAND_STAGES already is the motion, so Motion binds reusable artefacts to the stages of a demand deal that already exists: an engagement hangs off one deal, cascade deleted, one per deal by unique constraint. Nine closed kinds, each declaring which stages it serves, and a starter library of twelve templates covering all eight open stages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
188 lines
7.0 KiB
TypeScript
188 lines
7.0 KiB
TypeScript
/**
|
||
* Tests for the motion scoring arithmetic.
|
||
*
|
||
* A qualification score is not a dashboard ornament: the band it lands in is
|
||
* what a seller is told to do about a deal, and the row is append-only
|
||
* evidence that the judgement was made. So the cases below pin the
|
||
* *decisions* — the empty book, the rounding direction, and above all the band
|
||
* boundaries, each of which a plausible-but-wrong implementation gets wrong by
|
||
* exactly one basis point and reports no error about.
|
||
*/
|
||
import { strict as assert } from 'node:assert';
|
||
import { describe, it } from 'node:test';
|
||
import { DEMAND_STAGES } from '../src/ontology';
|
||
import {
|
||
MOTION_BANDS,
|
||
MOTION_KINDS,
|
||
MOTION_KIND_DESCRIPTIONS,
|
||
MOTION_KIND_LABELS,
|
||
MOTION_KIND_STAGES,
|
||
isArtifactStatus,
|
||
isMotionKind,
|
||
isMotionVisibility,
|
||
motionBand,
|
||
motionScoreBasisPoints,
|
||
} from '../src/motion';
|
||
|
||
describe('motionScoreBasisPoints', () => {
|
||
it('returns 0 for an empty framework rather than NaN', () => {
|
||
// A framework with no dimensions renders somewhere. `NaN` renders as "NaN".
|
||
const score = motionScoreBasisPoints([]);
|
||
assert.equal(score, 0);
|
||
assert.ok(!Number.isNaN(score));
|
||
});
|
||
|
||
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
|
||
// scored recommended as the best one in the book.
|
||
const score = motionScoreBasisPoints([
|
||
{ id: 'fit', weight: 0, score: 4 },
|
||
{ id: 'budget', weight: 0, score: 4 },
|
||
]);
|
||
assert.equal(score, 0);
|
||
});
|
||
|
||
it('scores a full house at exactly the maximum', () => {
|
||
assert.equal(
|
||
motionScoreBasisPoints([
|
||
{ id: 'fit', weight: 30, score: 4 },
|
||
{ id: 'budget', weight: 70, score: 4 },
|
||
]),
|
||
10_000,
|
||
);
|
||
});
|
||
|
||
it('does not require weights to sum to 100', () => {
|
||
// Two frameworks expressing the same judgement must score the same, or the
|
||
// number means nothing across templates.
|
||
const outOf100 = motionScoreBasisPoints([
|
||
{ id: 'fit', weight: 50, score: 4 },
|
||
{ id: 'budget', weight: 50, score: 2 },
|
||
]);
|
||
const outOf6 = motionScoreBasisPoints([
|
||
{ id: 'fit', weight: 3, score: 4 },
|
||
{ id: 'budget', weight: 3, score: 2 },
|
||
]);
|
||
assert.equal(outOf100, 7500);
|
||
assert.equal(outOf6, 7500);
|
||
});
|
||
|
||
it('rounds half-up rather than truncating', () => {
|
||
// 10000 × 5 / (4 × 8) = 1562.5 exactly. Truncation gives 1562, which is
|
||
// the whole difference between this and a naive `Math.floor` — small, and
|
||
// it moves a score across a band boundary once every few hundred deals.
|
||
const score = motionScoreBasisPoints([
|
||
{ id: 'fit', weight: 5, score: 1 },
|
||
{ id: 'budget', weight: 3, score: 0 },
|
||
]);
|
||
assert.equal(score, 1563);
|
||
});
|
||
|
||
it('does not let a zero-weight dimension move the total', () => {
|
||
// The case that flatters: a dimension somebody scored 4 and weighted out
|
||
// of the framework must not drag the score up on its way past.
|
||
const withoutIt = motionScoreBasisPoints([{ id: 'fit', weight: 10, score: 2 }]);
|
||
const withIt = motionScoreBasisPoints([
|
||
{ id: 'fit', weight: 10, score: 2 },
|
||
{ id: 'vanity', weight: 0, score: 4 },
|
||
]);
|
||
assert.equal(withoutIt, 5000);
|
||
assert.equal(withIt, withoutIt);
|
||
});
|
||
|
||
it('always returns an integer inside 0–10000', () => {
|
||
for (const weight of [1, 3, 7, 17, 100]) {
|
||
for (const score of [0, 1, 2, 3, 4]) {
|
||
const basisPoints = motionScoreBasisPoints([
|
||
{ id: 'a', weight, score },
|
||
{ id: 'b', weight: weight + 1, score: 4 - score },
|
||
]);
|
||
assert.ok(Number.isInteger(basisPoints), `${weight}/${score} produced a fraction`);
|
||
assert.ok(basisPoints >= 0 && basisPoints <= 10_000, `${basisPoints} is out of range`);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('motionBand', () => {
|
||
/**
|
||
* The boundaries, one assertion per edge. Inclusive at `min`: an
|
||
* implementation using `>` instead of `>=` passes every other test in this
|
||
* 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');
|
||
});
|
||
|
||
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');
|
||
});
|
||
|
||
it('covers both ends of the scale', () => {
|
||
assert.equal(motionBand(0).label, 'Decline or defer');
|
||
assert.equal(motionBand(10_000).label, 'Strategic');
|
||
});
|
||
|
||
it('leaves no gap and no overlap across the whole range', () => {
|
||
// Written as a sweep because a hand-edited band table is exactly the kind
|
||
// of data where a typo produces a score that belongs to two bands, or none.
|
||
for (let basisPoints = 0; basisPoints <= 10_000; basisPoints += 1) {
|
||
const matches = MOTION_BANDS.filter(
|
||
(band) => basisPoints >= band.min && basisPoints <= band.max,
|
||
);
|
||
assert.equal(matches.length, 1, `${basisPoints} matched ${matches.length} bands`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('the kind register', () => {
|
||
it('labels and describes every kind', () => {
|
||
for (const kind of MOTION_KINDS) {
|
||
assert.ok(MOTION_KIND_LABELS[kind], `${kind} has no label`);
|
||
assert.ok(MOTION_KIND_DESCRIPTIONS[kind], `${kind} has no description`);
|
||
}
|
||
});
|
||
|
||
it('maps every kind onto stages that actually exist', () => {
|
||
// The stage list is the demand pipeline, not a second vocabulary. A typo
|
||
// here would produce a template nothing can ever be filed against.
|
||
for (const kind of MOTION_KINDS) {
|
||
const stages = MOTION_KIND_STAGES[kind];
|
||
assert.ok(stages.length > 0, `${kind} serves no stage`);
|
||
for (const stage of stages) {
|
||
assert.ok(DEMAND_STAGES.includes(stage), `${kind} claims unknown stage ${stage}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('never files a kind against a closed stage', () => {
|
||
// A won or lost deal has left the motion; offering to instantiate into it
|
||
// is an invitation to do work against a dead deal.
|
||
for (const kind of MOTION_KINDS) {
|
||
for (const stage of MOTION_KIND_STAGES[kind]) {
|
||
assert.ok(stage !== 'closed_won' && stage !== 'closed_lost', `${kind} serves ${stage}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('gives the playbook the whole live motion', () => {
|
||
assert.equal(MOTION_KIND_STAGES.playbook.length, 8);
|
||
});
|
||
});
|
||
|
||
describe('type guards', () => {
|
||
it('accepts members and rejects near misses', () => {
|
||
assert.equal(isMotionKind('playbook'), true);
|
||
assert.equal(isMotionKind('Playbook'), false);
|
||
assert.equal(isMotionVisibility('shared'), true);
|
||
assert.equal(isMotionVisibility('public'), false, 'shared is book-wide, not public');
|
||
assert.equal(isArtifactStatus('final'), true);
|
||
assert.equal(isArtifactStatus('published'), false);
|
||
});
|
||
});
|