Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 232 additions & 3 deletions scripts/__tests__/challenge.schema.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { validateChallenge } from '../schemas/challenge.schema.js';
import { STAGE_IDS } from '../schemas/stage-ids.js';

// One golden fixture per challenge, built from the text of the six enjeux-*.html
// mockups in ocobo-revops/prototype, without the agent window. Only the Sales
// Compensation mockup has a partner block.
// mockups in ocobo-revops/prototype. Only the Sales Compensation mockup has a
// partner block, and the Revenue AI mockup has no agent window.
const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/challenges');
const loadFixture = (file) => matter(readFileSync(join(FIXTURES_DIR, file), 'utf8')).data;

Expand Down Expand Up @@ -57,6 +57,15 @@ describe('challenge golden fixtures', () => {
expect(ai.method.steps.every((s) => s.title)).toBe(true);
expect(ai.workstreams.title).toBeDefined();
});

it('the Revenue AI fixture has no agent window', () => {
expect(loadFixture('revenue-ai.md').aiBridge.demo).toBeUndefined();
});

// Spec #91 measured four body elements in every mockup window.
it.each(files.filter((f) => f !== 'revenue-ai.md'))('%s carries an agent window of four elements', (file) => {
expect(loadFixture(file).aiBridge.demo.elements).toHaveLength(4);
});
});

// ── caps ──────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -86,6 +95,11 @@ const CAPS = [
[['workstreams', 'items', 0, 'note'], 100],
[['aiBridge', 'title'], 92],
[['aiBridge', 'bullets', 0], 125],
[['aiBridge', 'demo', 'channel'], 28],
[['aiBridge', 'demo', 'agent'], 24],
[['aiBridge', 'demo', 'opening', 'text'], 70],
[['aiBridge', 'demo', 'footer'], 60],
[['aiBridge', 'demo', 'note'], 90],
[['method', 'title'], 30],
[['method', 'subtitle'], 75],
[['method', 'steps', 0, 'title'], 28],
Expand Down Expand Up @@ -148,6 +162,7 @@ const COUNTS = [
[['audience'], 4, 4],
[['workstreams', 'items'], 4, 6],
[['aiBridge', 'bullets'], 3, 3],
[['aiBridge', 'demo', 'elements'], 1, 4],
[['method', 'steps'], 3, 3],
[['method', 'tools'], 0, 9],
[['case', 'metrics'], 2, 2],
Expand Down Expand Up @@ -338,6 +353,219 @@ describe('challenge partner block', () => {
});
});

// ── agent window ──────────────────────────────────────────────────────────────

const DEMO = ['aiBridge', 'demo'];

// One valid element of each type, from the mockup windows.
const ELEMENTS = {
kpis: {
type: 'kpis',
items: [
{ value: '1,24 M€', label: 'commit' },
{ value: '82 %', label: "de l'objectif" },
{ value: '+6 pts', label: 'vs. S-1' },
],
},
figure: { type: 'figure', value: '3 840 €', caption: 'de commission si signé ce mois-ci' },
progress: { type: 'progress', percent: 82, target: 100 },
status: { type: 'status', label: 'Contrôle', value: '1 deal compté deux fois', state: 'alert' },
buttons: { type: 'buttons', labels: ['Ouvrir le compte', 'Préparer le RDV'] },
};

const withElement = (element) => withValue([...DEMO, 'elements'], [element]);

const ELEMENT_CAPS = [
['kpis', ['items', 0, 'value'], 10],
['kpis', ['items', 2, 'label'], 16],
['figure', ['value'], 12],
['figure', ['caption'], 48],
['status', ['label'], 24],
['status', ['value'], 32],
['buttons', ['labels', 1], 20],
];

// Every required field of each element type.
const ELEMENT_FIELDS = [
['kpis', 'items'],
['figure', 'value'],
['figure', 'caption'],
['progress', 'percent'],
['status', 'label'],
['status', 'value'],
['status', 'state'],
['buttons', 'labels'],
];

const elementWith = (type, path, value) => {
const element = structuredClone(ELEMENTS[type]);
const parent = path.slice(0, -1).reduce((node, key) => node[key], element);
parent[path.at(-1)] = value;
return element;
};

describe('challenge agent window', () => {
it('is optional', () => {
expect(issuesOf(without(DEMO))).toEqual([]);
});

it.each(Object.keys(ELEMENTS))('accepts a %s element', (type) => {
expect(issuesOf(withElement(ELEMENTS[type]))).toEqual([]);
});

it('accepts four elements of mixed types', () => {
const { figure, progress, status, buttons } = ELEMENTS;
expect(issuesOf(withValue([...DEMO, 'elements'], [figure, progress, status, buttons]))).toEqual([]);
});

it('rejects a fifth element', () => {
const { kpis, figure, progress, status, buttons } = ELEMENTS;
expect(issuesOf(withValue([...DEMO, 'elements'], [kpis, figure, progress, status, buttons]))).toEqual([
{ path: [...DEMO, 'elements'], message: expect.any(String) },
]);
});

it('rejects an unknown element type', () => {
expect(issuesOf(withElement({ type: 'chart', value: '12' }))).toEqual([
{ path: [...DEMO, 'elements', 0, 'type'], message: expect.any(String) },
]);
});

it('rejects an element without a type', () => {
const { type, ...figure } = ELEMENTS.figure;
expect(issuesOf(withElement(figure))).toEqual([
{ path: [...DEMO, 'elements', 0, 'type'], message: expect.any(String) },
]);
});

it.each(ELEMENT_CAPS)('%s %j accepts %i characters', (type, path, cap) => {
expect(issuesOf(withElement(elementWith(type, path, 'a'.repeat(cap))))).toEqual([]);
});

it.each(ELEMENT_CAPS)('%s %j rejects one character over cap %i', (type, path, cap) => {
expect(issuesOf(withElement(elementWith(type, path, 'a'.repeat(cap + 1))))).toEqual([
{ path: [...DEMO, 'elements', 0, ...path], message: `${cap + 1} characters, cap ${cap} (1 over)` },
]);
});

it.each(ELEMENT_FIELDS)('rejects a %s element without %s', (type, field) => {
const { [field]: omitted, ...element } = ELEMENTS[type];
expect(issuesOf(withElement(element))).toEqual([
{ path: [...DEMO, 'elements', 0, field], message: expect.any(String) },
]);
});

it.each(Object.keys(ELEMENTS))('rejects an unknown key in a %s element', (type) => {
expect(issuesOf(withElement({ ...ELEMENTS[type], icon: 'x' }))).toEqual([
{ path: [...DEMO, 'elements', 0], message: 'Unrecognized key: "icon"' },
]);
});

it('rejects an unknown key in a KPI', () => {
expect(issuesOf(withElement(elementWith('kpis', ['items', 0, 'trend'], 'x')))).toEqual([
{ path: [...DEMO, 'elements', 0, 'items', 0], message: 'Unrecognized key: "trend"' },
]);
});

it.each([2, 4])('rejects %i KPIs', (count) => {
const items = Array.from({ length: count }, (_, i) => ELEMENTS.kpis.items[i % 3]);
expect(issuesOf(withElement({ type: 'kpis', items }))).toEqual([
{ path: [...DEMO, 'elements', 0, 'items'], message: expect.any(String) },
]);
});

it.each([[[]], [['Un', 'Deux', 'Trois']]])('rejects buttons with %j labels', (labels) => {
expect(issuesOf(withElement({ type: 'buttons', labels }))).toEqual([
{ path: [...DEMO, 'elements', 0, 'labels'], message: expect.any(String) },
]);
});

it('accepts a single button', () => {
expect(issuesOf(withElement({ type: 'buttons', labels: ['Ouvrir le compte'] }))).toEqual([]);
});

it('accepts a progress bar without a target', () => {
expect(issuesOf(withElement({ type: 'progress', percent: 82 }))).toEqual([]);
});

it.each([
['percent', 0],
['percent', 100],
['target', 0],
['target', 100],
])('accepts progress %s %i', (field, value) => {
expect(issuesOf(withElement(elementWith('progress', [field], value)))).toEqual([]);
});

it.each([
['percent', -1],
['percent', 101],
['percent', 82.5],
['percent', '82'],
['target', 101],
['target', 99.5],
])('rejects progress %s %j', (field, value) => {
expect(issuesOf(withElement(elementWith('progress', [field], value)))).toEqual([
{ path: [...DEMO, 'elements', 0, field], message: expect.any(String) },
]);
});

it.each(['ok', 'alert'])('accepts status state %s', (state) => {
expect(issuesOf(withElement(elementWith('status', ['state'], state)))).toEqual([]);
});

it('rejects an unknown status state', () => {
expect(issuesOf(withElement(elementWith('status', ['state'], 'warning')))).toEqual([
{ path: [...DEMO, 'elements', 0, 'state'], message: expect.any(String) },
]);
});

it.each(['question', 'statement'])('accepts opening kind %s', (kind) => {
expect(issuesOf(withValue([...DEMO, 'opening', 'kind'], kind))).toEqual([]);
});

it('rejects an unknown opening kind', () => {
expect(issuesOf(withValue([...DEMO, 'opening', 'kind'], 'answer'))).toEqual([
{ path: [...DEMO, 'opening', 'kind'], message: expect.any(String) },
]);
});

it.each(['00:00', '09:12', '23:59'])('accepts time %s', (time) => {
expect(issuesOf(withValue([...DEMO, 'time'], time))).toEqual([]);
});

it.each(['24:00', '8:05', '08:60', '08h05', '08:05:00', ' 08:05'])('rejects time %j', (time) => {
expect(issuesOf(withValue([...DEMO, 'time'], time))).toEqual([
{ path: [...DEMO, 'time'], message: 'must be a 24-hour time, HH:MM' },
]);
});

it('asks to quote a time YAML read as a number', () => {
const { time } = matter('---\ntime: 14:32\n---\n').data;
expect(time).toBe(872);
expect(issuesOf(withValue([...DEMO, 'time'], time))).toEqual([
{ path: [...DEMO, 'time'], message: 'quote the time, e.g. "14:32"' },
]);
});

it.each([
[['channel']],
[['agent']],
[['time']],
[['opening']],
[['opening', 'kind']],
[['opening', 'text']],
[['elements']],
[['footer']],
])('rejects a missing %j', (path) => {
expect(issuesOf(without([...DEMO, ...path]))).toEqual([{ path: [...DEMO, ...path], message: expect.any(String) }]);
});

it('accepts a note under the window', () => {
expect(issuesOf(withValue([...DEMO, 'note'], 'Comptes et montants fictifs.'))).toEqual([]);
});
});

// ── unknown keys ──────────────────────────────────────────────────────────────

describe('challenge unknown keys', () => {
Expand All @@ -352,7 +580,8 @@ describe('challenge unknown keys', () => {
[['bowtie', 'legend']],
[['symptoms', 'intro']],
[['workstreams', 'cta']],
[['aiBridge', 'demo']],
[['aiBridge', 'demo', 'avatar']],
[['aiBridge', 'demo', 'opening', 'emoji']],
[['method', 'cta']],
[['case', 'quote']],
[['diagnostic', 'cta']],
Expand Down
25 changes: 25 additions & 0 deletions scripts/__tests__/fixtures/challenges/customer-experience.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,31 @@ aiBridge:
- Les tickets, mails et appels sont lus pour détecter un risque ou une opportunité.
- Chaque chargé de compte reçoit un résumé du compte avant son rendez-vous.
- Les revues de compte se préparent en quelques minutes.
demo:
channel: Alerte compte · Kestrel
agent: Agent Customer
time: "09:12"
opening:
kind: statement
text: "Risque de départ détecté. Score de santé : 72 → 54."
elements:
- type: status
label: Support
value: 3 tickets critiques en 10 j
state: alert
- type: status
label: Usage
value: "-30 % de connexions"
state: alert
- type: status
label: Appel QBR
value: Concurrent cité 2 fois
state: alert
- type: buttons
labels:
- Ouvrir le compte
- Préparer le RDV
footer: Résumé envoyé à Julie avant son RDV de 14:00
method:
subtitle: De l'analyse du portefeuille à un revenu récurrent piloté.
steps:
Expand Down
28 changes: 28 additions & 0 deletions scripts/__tests__/fixtures/challenges/revenue-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,34 @@ aiBridge:
- Les comptes rendus d'appel mettent à jour le CRM sans saisie.
- Les deals à risque sont signalés avant la revue de pipe.
- Le dirigeant pose sa question en français et obtient le chiffre.
demo:
channel: "# direction"
agent: Agent Forecast
time: "08:05"
opening:
kind: question
text: Où en est-on du trimestre ?
elements:
- type: kpis
items:
- value: 1,24 M€
label: commit
- value: 82 %
label: de l'objectif
- value: +6 pts
label: vs. S-1
- type: progress
percent: 82
target: 100
- type: status
label: Brightlane · 180 k€
value: Sans activité 14 j
state: alert
- type: status
label: Kestrel · 95 k€
value: Date de signature repoussée
state: alert
footer: Calculé sur HubSpot, mis à jour ce matin
method:
subtitle: Du diagnostic à un chiffre que vos équipes tiennent seules.
steps:
Expand Down
25 changes: 25 additions & 0 deletions scripts/__tests__/fixtures/challenges/revenue-systems.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,31 @@ aiBridge:
- Les saisies se remplissent à partir des mails, des appels et des documents.
- Vos assistants IA se connectent au CRM dans un cadre de sécurité maîtrisé.
- Les règles de qualité de données sont surveillées en continu.
demo:
channel: Qualité de données
agent: Agent Data
time: "07:00"
opening:
kind: statement
text: Contrôle quotidien du CRM terminé.
elements:
- type: status
label: Doublons
value: 0,4 %
state: ok
- type: status
label: Champs obligatoires
value: 98 % remplis
state: ok
- type: status
label: Fiches complétées
value: 24 depuis les emails
state: ok
- type: status
label: Contacts sans compte
value: 12 à rattacher
state: alert
footer: Connecteur IA ↔ HubSpot · accès en lecture encadré
method:
subtitle: De l'audit de la stack à une équipe qui l'administre seule.
steps:
Expand Down
Loading
Loading