feat(credits): solde IA unique (option M), packs Stripe et polish billing

Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
Antigravity
2026-07-16 20:43:18 +00:00
parent 704fed1191
commit 556a0b2f3f
77 changed files with 35745 additions and 10430 deletions

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import {
assertDeckHasSubstance,
isSlideBodyEmpty,
listEmptyBodySlides,
normalizeSlideDeck,
} from '@/lib/ai/services/slide-content-quality'
describe('slide deck substance gate', () => {
it('detects title-only body slides (the bug that shipped empty decks)', () => {
const emptyBullets = { type: 'bullets', title: 'Contexte marché', items: [] }
expect(isSlideBodyEmpty(emptyBullets)).toBe(true)
const withItems = {
type: 'bullets',
title: 'Les délais ont doublé',
items: ['Lead time 12→24 jours', 'Priorités instables en sprint'],
}
expect(isSlideBodyEmpty(withItems)).toBe(false)
})
it('rejects decks that only have titles', () => {
const bad = normalizeSlideDeck({
title: 'Test',
slides: [
{ type: 'title', title: 'Test' },
{ type: 'bullets', title: 'Point A', items: [] },
{ type: 'bullets', title: 'Point B' },
{ type: 'summary', title: 'Fin', items: [] },
],
})
const gate = assertDeckHasSubstance(bad)
expect(gate.ok).toBe(false)
if (!gate.ok) {
expect(gate.emptyIndexes.length).toBeGreaterThan(0)
}
})
it('accepts a minimal solid deck', () => {
const good = normalizeSlideDeck({
title: 'Accélérer la livraison',
slides: [
{
type: 'title',
title: 'Accélérer la livraison',
subtitle: 'Trois leviers en 90 jours',
},
{
type: 'bullets',
title: 'Les délais ont doublé faute de priorisation',
items: [
'Lead time passé de 12 à 24 jours',
'40% des tickets changent de priorité en sprint',
'Dépendances inter-équipes bloquent 1 livraison sur 3',
],
},
{
type: 'cards',
title: 'Trois leviers actionnables',
cards: [
{ title: 'WIP limit', description: 'Max 2 items en cours par personne' },
{ title: 'Backlog unique', description: 'Un seul ordre partagé produit/tech' },
],
},
{
type: 'summary',
title: 'Prochaines décisions',
items: [
'Valider WIP limit cette semaine',
'Fusionner les backlogs sous 10 jours',
],
},
],
})
const gate = assertDeckHasSubstance(good)
expect(gate.ok).toBe(true)
expect(listEmptyBodySlides(good.slides)).toEqual([])
})
it('flags empty charts and empty stats as empty bodies', () => {
expect(isSlideBodyEmpty({ type: 'chart', title: 'KPI', data: [] })).toBe(true)
expect(
isSlideBodyEmpty({
type: 'chart',
title: 'KPI',
data: [
{ label: 'Q1', value: 10 },
{ label: 'Q2', value: 20 },
],
}),
).toBe(false)
expect(isSlideBodyEmpty({ type: 'stats', title: 'Chiffres', stats: [] })).toBe(true)
})
})

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import { extractFormulas, extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
assertDeckHasSubstance,
injectEquationSlidesFromFormulas,
normalizeSlideDeck,
} from '@/lib/ai/services/slide-content-quality'
import { buildPresentationHTML } from '@/lib/ai/tools/slides-html-builder'
import { encodeSlideIntentDescription, decodeSlideIntentDescription } from '@/lib/ai/services/slide-intent'
describe('extractFormulas for STEM notes', () => {
it('extracts display math $$...$$', () => {
const text = 'On étudie $$\\frac{dy}{dx} + P(x)y = Q(x)$$ et la solution.'
const f = extractFormulas(text)
expect(f.some((x) => x.includes('dy') || x.includes('frac'))).toBe(true)
})
it('extracts equation-like lines', () => {
const text = 'La dérivée:\ny\' + 2y = e^x\nPuis on intègre.'
const f = extractFormulas(text)
expect(f.length).toBeGreaterThan(0)
})
it('flags math domain for differential equations notes', () => {
const assets = extractSourceAssets(
'Cours sur les équations différentielles. $$y\' + P(x)y = Q(x)$$. Méthode du facteur intégrant.',
)
expect(assets.hasMath).toBe(true)
expect(assets.formulas.length).toBeGreaterThan(0)
})
})
describe('equation slides render with katex hooks', () => {
it('emits data-latex and katex CDN for equation slides', () => {
const deck = normalizeSlideDeck({
title: 'EDO',
theme: 'clinical-precision',
slides: [
{ type: 'title', title: 'Équations différentielles', subtitle: 'Cours' },
{
type: 'equation',
title: 'Forme générale',
equations: [{ latex: "y' + P(x)y = Q(x)", label: 'Linéaire 1er ordre' }],
explanation: 'Forme standard',
},
{
type: 'bullets',
title: 'Méthode',
items: ['Identifier P et Q', 'Calculer le facteur intégrant', 'Intégrer les deux membres'],
},
{
type: 'summary',
title: 'À retenir',
items: ['Forme standard', 'Facteur intégrant', 'Condition initiale'],
},
],
})
expect(assertDeckHasSubstance(deck).ok).toBe(true)
const html = buildPresentationHTML({
title: deck.title,
theme: deck.theme,
slides: deck.slides as any,
})
expect(html).toContain('katex')
expect(html).toContain('data-latex')
expect(html).toContain("y' + P(x)y = Q(x)")
// cards grid must not force 2 empty columns for 1 card
const oneCard = buildPresentationHTML({
title: 't',
theme: 'architectural-saas',
slides: [
{ type: 'title', title: 'T' },
{
type: 'cards',
title: 'One',
cards: [{ title: 'Seul', description: 'Plein largeur' }],
},
{ type: 'summary', title: 'S', items: ['a', 'b'] },
] as any,
})
expect(oneCard).toContain('repeat(1,minmax(0,1fr))')
})
})
describe('STEM gate + model + intent', () => {
it('fails substance when formulas required but no equation latex', () => {
const deck = normalizeSlideDeck({
title: 'EDO',
slides: [
{ type: 'title', title: 'EDO' },
{ type: 'bullets', title: 'Intro', items: ['a', 'b', 'c'] },
{ type: 'summary', title: 'Fin', items: ['x', 'y'] },
],
})
const gate = assertDeckHasSubstance(deck, {
requireFormulas: ["y' + P y = Q"],
})
expect(gate.ok).toBe(false)
})
it('injects equation slides from formulas', () => {
const thin = normalizeSlideDeck({
title: 'EDO',
theme: 'clinical-precision',
slides: [
{ type: 'title', title: 'EDO', subtitle: 'Cours' },
{ type: 'bullets', title: 'Méthode', items: ['A', 'B', 'C'] },
{ type: 'summary', title: 'Fin', items: ['1', '2'] },
],
})
const filled = injectEquationSlidesFromFormulas(thin, ["y' + P(x)y = Q(x)", '\\mu(x) = e^{\\int P}'], {
lang: 'fr',
})
expect(filled.slides.some((s) => s.type === 'equation')).toBe(true)
const gate = assertDeckHasSubstance(filled, {
requireFormulas: ["y' + P(x)y = Q(x)"],
})
expect(gate.ok).toBe(true)
})
it('encodes and decodes slide intent', () => {
const enc = encodeSlideIntentDescription({
purpose: 'course',
audience: 'student',
slideCount: 6,
template: 'auto',
})
const dec = decodeSlideIntentDescription(enc)
expect(dec.purpose).toBe('course')
expect(dec.audience).toBe('student')
expect(dec.slideCount).toBe(6)
})
})