diff --git a/scripts/__tests__/challenge.schema.test.js b/scripts/__tests__/challenge.schema.test.js index b8c48b8..698c929 100644 --- a/scripts/__tests__/challenge.schema.test.js +++ b/scripts/__tests__/challenge.schema.test.js @@ -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; @@ -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 ────────────────────────────────────────────────────────────────────── @@ -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], @@ -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], @@ -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', () => { @@ -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']], diff --git a/scripts/__tests__/fixtures/challenges/customer-experience.md b/scripts/__tests__/fixtures/challenges/customer-experience.md index 9c56f32..3d57aa2 100644 --- a/scripts/__tests__/fixtures/challenges/customer-experience.md +++ b/scripts/__tests__/fixtures/challenges/customer-experience.md @@ -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: diff --git a/scripts/__tests__/fixtures/challenges/revenue-performance.md b/scripts/__tests__/fixtures/challenges/revenue-performance.md index 075ea75..bedf110 100644 --- a/scripts/__tests__/fixtures/challenges/revenue-performance.md +++ b/scripts/__tests__/fixtures/challenges/revenue-performance.md @@ -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: diff --git a/scripts/__tests__/fixtures/challenges/revenue-systems.md b/scripts/__tests__/fixtures/challenges/revenue-systems.md index 91caa8b..79a3dc2 100644 --- a/scripts/__tests__/fixtures/challenges/revenue-systems.md +++ b/scripts/__tests__/fixtures/challenges/revenue-systems.md @@ -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: diff --git a/scripts/__tests__/fixtures/challenges/sales-compensation.md b/scripts/__tests__/fixtures/challenges/sales-compensation.md index 8ad56ad..cfbc4ca 100644 --- a/scripts/__tests__/fixtures/challenges/sales-compensation.md +++ b/scripts/__tests__/fixtures/challenges/sales-compensation.md @@ -85,6 +85,30 @@ aiBridge: - Le commercial pose sa question en français et obtient le détail de sa commission. - Les scénarios de plan se simulent en quelques minutes, sur vos vraies données. - Les anomalies de calcul sont repérées avant la paie. + demo: + channel: Assistant variable + agent: Assistant variable + time: "10:18" + opening: + kind: question + text: Combien me rapporte le deal Brightlane ? + elements: + - type: figure + value: 3 840 € + caption: de commission si signé ce mois-ci + - type: status + label: Taux de base + value: 8 % × 48 k€ + state: ok + - type: status + label: Accélérateur + value: Palier 110 % atteint + state: ok + - type: status + label: Contrôle + value: 1 deal compté deux fois + state: alert + footer: Calcul vérifié avant la paie method: subtitle: De l'analyse du plan actuel à un variable qui se pilote seul. steps: diff --git a/scripts/__tests__/fixtures/challenges/sales-experience.md b/scripts/__tests__/fixtures/challenges/sales-experience.md index c5b8726..46b620f 100644 --- a/scripts/__tests__/fixtures/challenges/sales-experience.md +++ b/scripts/__tests__/fixtures/challenges/sales-experience.md @@ -76,6 +76,31 @@ aiBridge: - "Les appels alimentent le CRM : qualification, objections, prochaines étapes." - Les écarts au processus remontent dans les tableaux de bord des managers. - Le playbook arrive dans l'outil du commercial, au moment où il en a besoin. + demo: + channel: Agent CRM · Northwind + agent: Agent CRM + time: "14:32" + opening: + kind: statement + text: "Appel Northwind terminé (28 min). Fiche mise à jour :" + elements: + - type: status + label: Besoin + value: Structurer 3 SDR + state: ok + - type: status + label: Objection + value: Prix vs. outil actuel + state: ok + - type: status + label: Prochaine étape + value: Démo le 12 + state: ok + - type: status + label: Décideur + value: Non identifié + state: alert + footer: Écart au process signalé au manager method: subtitle: De l'analyse du funnel à une organisation qui tient sans nous. steps: diff --git a/scripts/schemas/challenge.schema.js b/scripts/schemas/challenge.schema.js index 626a0fb..9c03830 100644 --- a/scripts/schemas/challenge.schema.js +++ b/scripts/schemas/challenge.schema.js @@ -39,6 +39,66 @@ const workstream = z.strictObject({ note: cappedText(100).optional(), }); +// Agent window (spec #91, section "aiBridge.demo"): a frame and up to four +// typed body elements, rendered in order. Every mockup window has four. +const percentage = z.number().int().min(0).max(100); + +const demoElement = z.discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('kpis'), + items: z + .array( + z.strictObject({ + value: cappedText(10), + label: cappedText(16), + }), + ) + .length(3), + }), + z.strictObject({ + type: z.literal('figure'), + value: cappedText(12), + caption: cappedText(48), + }), + z.strictObject({ + type: z.literal('progress'), + percent: percentage, + target: percentage.optional(), + }), + z.strictObject({ + type: z.literal('status'), + label: cappedText(24), + value: cappedText(32), + state: z.enum(['ok', 'alert']), + }), + z.strictObject({ + type: z.literal('buttons'), + labels: z.array(cappedText(20)).min(1).max(2), + }), +]); + +const demo = z.strictObject({ + channel: cappedText(28), + agent: cappedText(24), + // YAML reads an unquoted 14:32 as the base 60 number 872. Not trimmed, + // unlike the text fields: it is a fixed-format value, and #99 compares it + // exactly between the French and English files, so a stray space fails here. + time: z + .string({ + error: (iss) => (typeof iss.input === 'number' ? 'quote the time, e.g. "14:32"' : undefined), + }) + .regex(/^([01]\d|2[0-3]):[0-5]\d$/, { error: 'must be a 24-hour time, HH:MM' }), + opening: z.strictObject({ + // A question renders in quotes, a statement as plain agent text. + kind: z.enum(['question', 'statement']), + text: cappedText(70), + }), + elements: z.array(demoElement).min(1).max(4), + footer: cappedText(60), + // Shown under the window (assumption A3 in spec #91: fictional accounts). + note: cappedText(90).optional(), +}); + export const challengeSchema = z.strictObject({ key: z.enum(CHALLENGE_KEYS), order: z.number().int().min(1).max(6), @@ -94,6 +154,8 @@ export const challengeSchema = z.strictObject({ aiBridge: z.strictObject({ title: cappedText(92), bullets: z.array(cappedText(125)).length(3), + // Revenue AI has no agent window. + demo: demo.optional(), }), method: z.strictObject({ title: cappedText(30).optional(),