@springbrand/message-panel 0.1.3-alpha.8 → 0.2.0-alpha.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/cloud-os/README.md +4 -3
  2. package/cloud-os/assets/followup-arrow.svg +3 -0
  3. package/cloud-os/assets/loading-corner.svg +3 -0
  4. package/cloud-os/assets/loading-mark.svg +16 -0
  5. package/cloud-os/assets/loading-spark.svg +3 -0
  6. package/cloud-os/assets/model-selected.svg +5 -0
  7. package/cloud-os/assets/thinking-star.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +1 -1
  9. package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
  10. package/cloud-os/chat/code-runner.tsx +106 -0
  11. package/cloud-os/chat/image-generation.tsx +76 -0
  12. package/cloud-os/chat/loading-state.tsx +25 -0
  13. package/cloud-os/chat/markdown-message.tsx +144 -41
  14. package/cloud-os/chat/range.ts +8 -0
  15. package/cloud-os/chat/rich-blocks.tsx +285 -227
  16. package/cloud-os/chat/surfaces.tsx +90 -0
  17. package/cloud-os/chat/tool-call.tsx +94 -0
  18. package/cloud-os/chat/tool-presentation.ts +31 -22
  19. package/cloud-os/chat/tool-rows.tsx +195 -143
  20. package/cloud-os/chat/tool-timeline.tsx +123 -0
  21. package/cloud-os/chat/transcript-model.ts +258 -20
  22. package/cloud-os/chat/web-search.tsx +135 -0
  23. package/cloud-os/composer/cloud-os-chat-input.tsx +38 -88
  24. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  25. package/cloud-os/file-view.tsx +195 -12
  26. package/cloud-os/index.ts +24 -3
  27. package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
  28. package/cloud-os/primitives/collapsible.tsx +21 -0
  29. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  30. package/cloud-os/styles/cloud-os.css +132 -1
  31. package/demo/camel-chat-showcase.tsx +21 -13
  32. package/demo/chat-scenarios.ts +214 -40
  33. package/demo/cloud-os-chat-showcase.tsx +37 -14
  34. package/demo/fixtures.ts +4 -5
  35. package/demo/message-panel-gallery.tsx +16 -2
  36. package/package.json +8 -4
  37. package/springbrand-pet/LICENSE.bloub +21 -0
  38. package/springbrand-pet/bot/cycles.test.ts +221 -0
  39. package/springbrand-pet/bot/cycles.ts +225 -0
  40. package/springbrand-pet/bot/decor.ts +285 -0
  41. package/springbrand-pet/bot/engine.test.ts +543 -0
  42. package/springbrand-pet/bot/engine.ts +558 -0
  43. package/springbrand-pet/bot/expressions.test.ts +135 -0
  44. package/springbrand-pet/bot/expressions.ts +192 -0
  45. package/springbrand-pet/bot/eyefit.ts +455 -0
  46. package/springbrand-pet/bot/face.test.ts +101 -0
  47. package/springbrand-pet/bot/face.ts +179 -0
  48. package/springbrand-pet/bot/math.ts +42 -0
  49. package/springbrand-pet/bot/profiles.ts +22 -0
  50. package/springbrand-pet/bot/repere.ts +31 -0
  51. package/springbrand-pet/bot/shape.test.ts +97 -0
  52. package/springbrand-pet/bot/shape.ts +310 -0
  53. package/springbrand-pet/bot/skins.test.ts +325 -0
  54. package/springbrand-pet/bot/skins.ts +161 -0
  55. package/springbrand-pet/bot/states.ts +616 -0
  56. package/springbrand-pet/drag.test.ts +19 -0
  57. package/springbrand-pet/index.tsx +569 -0
  58. package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  59. package/springbrand-pet/vitest.config.ts +9 -0
  60. package/src/camel/camel-chat-messages.tsx +78 -78
  61. package/src/camel/camel-tool-presentation.tsx +19 -15
  62. package/src/camel/camel-turn.ts +1 -17
  63. package/src/composer/composer-attachments.tsx +1 -1
  64. package/src/composer/composer.tsx +2 -2
  65. package/src/contracts.ts +0 -2
  66. package/src/message-panel.tsx +1 -24
  67. package/src/parts/index.tsx +103 -64
@@ -0,0 +1,543 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { BotEngine } from './engine'
3
+ import { radiusAtAngle } from './shape'
4
+ import { EXPRESSION_BY_ID } from './expressions'
5
+ import { REST_GAZE } from './face'
6
+ import { SHAPE_BY_ID } from './skins'
7
+ import { SEQUENCE, STATES, type StateId } from './states'
8
+
9
+ /** Points d'ancrage d'un path genere par closedPath (on ignore les controles). */
10
+ function anchors(d: string): Array<[number, number]> {
11
+ const out: Array<[number, number]> = []
12
+ const head = /^M(-?[\d.]+) (-?[\d.]+)/.exec(d)
13
+ if (head) out.push([+head[1]!, +head[2]!])
14
+ for (const seg of d.matchAll(/C[-\d. ]+? (-?[\d.]+) (-?[\d.]+)(?=C|Z)/g)) {
15
+ out.push([+seg[1]!, +seg[2]!])
16
+ }
17
+ return out
18
+ }
19
+
20
+ function footprint(d: string) {
21
+ const pts = anchors(d)
22
+ const xs = pts.map((p) => p[0])
23
+ const ys = pts.map((p) => p[1])
24
+ return {
25
+ w: (Math.max(...xs) - Math.min(...xs)) / 100,
26
+ h: (Math.max(...ys) - Math.min(...ys)) / 100
27
+ }
28
+ }
29
+
30
+ /** Empreintes relevees sur la video (largeur x hauteur, en diametres de boule). */
31
+ const EMPREINTES: Array<[StateId, number, number, number, number]> = [
32
+ // etat, date, largeur, hauteur, tolerance
33
+ ['idle', 0.5, 2.0, 2.0, 0.05],
34
+ ['egg', 0.9, 1.653, 2.0, 0.06],
35
+ ['hexagon', 0.9, 1.82, 2.01, 0.07],
36
+ ['exclaim', 0.9, 0.263, 0.842, 0.03],
37
+ ['alert', 0.8, 0.421, 0.753, 0.04],
38
+ ['sleep', 0.6, 0.317, 0.317, 0.03],
39
+ ['comet', 1.0, 0.258, 0.258, 0.03]
40
+ ]
41
+
42
+ describe('silhouettes', () => {
43
+ for (const [id, t, w, h, tol] of EMPREINTES) {
44
+ it(`"${id}" a l empreinte mesuree sur la video`, () => {
45
+ const e = new BotEngine(100, id)
46
+ const { w: gw, h: gh } = footprint(e.sample(t).bodyPath)
47
+ expect(Math.abs(gw - w)).toBeLessThan(tol)
48
+ expect(Math.abs(gh - h)).toBeLessThan(tol)
49
+ })
50
+ }
51
+
52
+ it('la boule au repos est un cercle, pas un ovale', () => {
53
+ const e = new BotEngine(100, 'idle')
54
+ const { w, h } = footprint(e.sample(0.5).bodyPath)
55
+ expect(Math.abs(w - h)).toBeLessThan(0.03)
56
+ })
57
+
58
+ it('le triangle est plus large que haut, pointe en haut', () => {
59
+ const e = new BotEngine(100, 'play')
60
+ const { w, h } = footprint(e.sample(0.9).bodyPath)
61
+ expect(w).toBeGreaterThan(h)
62
+ expect(Math.abs(w - 1.99)).toBeLessThan(0.08)
63
+ })
64
+ })
65
+
66
+ describe('moteur', () => {
67
+ it('est une fonction pure du temps : deux lectures a la meme date sont identiques', () => {
68
+ const a = new BotEngine(100, 'orbit')
69
+ const b = new BotEngine(100, 'orbit')
70
+ expect(a.sample(1.3).bodyPath).toBe(b.sample(1.3).bodyPath)
71
+ // et relire une date deja passee redonne la meme image
72
+ const first = a.sample(0.7).bodyPath
73
+ a.sample(2.5)
74
+ expect(a.sample(0.7).bodyPath).toBe(first)
75
+ })
76
+
77
+ it('reste rejouable PENDANT un fondu entre etats', () => {
78
+ // le piege : purger l'etat precedent une fois le fondu fini rend cette
79
+ // date irrecuperable, et le fondu disparait a la relecture
80
+ const e = new BotEngine(100, 'idle')
81
+ e.setState('egg', 1)
82
+ const pendant = e.sample(1.2).bodyPath
83
+ e.sample(3)
84
+ expect(e.sample(1.2).bodyPath).toBe(pendant)
85
+ })
86
+
87
+ it('ne part pas dans le decor sur une date anterieure au changement d etat', () => {
88
+ // avant le changement, il n'y a rien a fondre : on doit voir l'etat sortant
89
+ const e = new BotEngine(100, 'idle')
90
+ const avant = e.sample(0.5).bodyPath
91
+ e.setState('egg', 1)
92
+ expect(e.sample(0.5).bodyPath).toBe(avant)
93
+ })
94
+
95
+ it('anime vraiment : la forme evolue entre deux dates', () => {
96
+ const e = new BotEngine(100, 'thinking')
97
+ const paths = [0.1, 0.4, 0.8, 1.2].map((t) => e.sample(t).bodyPath)
98
+ expect(new Set(paths).size).toBe(paths.length)
99
+ })
100
+
101
+ it('interpole la silhouette pendant une transition, sans saut', () => {
102
+ const e = new BotEngine(100, 'idle')
103
+ e.setState('egg', 1)
104
+ const largeurs = [1, 1.1, 1.2, 1.3, 1.4].map((t) => footprint(e.sample(t).bodyPath).w)
105
+ // strictement decroissant : la boule se retrecit vers l'oeuf
106
+ for (let i = 1; i < largeurs.length; i++) {
107
+ expect(largeurs[i]!).toBeLessThan(largeurs[i - 1]!)
108
+ }
109
+ expect(largeurs[0]!).toBeCloseTo(2, 1)
110
+ expect(footprint(e.sample(2).bodyPath).w).toBeCloseTo(1.65, 1)
111
+ })
112
+
113
+ it('ne fait jamais depasser le corps du viewBox', () => {
114
+ for (const s of STATES) {
115
+ const e = new BotEngine(100, s.id)
116
+ for (const t of [0.2, 0.9, 1.8, 3]) {
117
+ const pts = anchors(e.sample(t).bodyPath)
118
+ for (const [x, y] of pts) {
119
+ expect(Math.abs(x)).toBeLessThan(158)
120
+ expect(Math.abs(y)).toBeLessThan(158)
121
+ }
122
+ }
123
+ }
124
+ })
125
+ })
126
+
127
+ describe('forme personnalisee', () => {
128
+ const radii = (id: string) => SHAPE_BY_ID.get(id)!.radii
129
+ const hauteur = (e: BotEngine, t: number) => footprint(e.sample(t).bodyPath).h
130
+
131
+ it('remplace la silhouette des etats au repos', () => {
132
+ const rond = new BotEngine(100, 'idle')
133
+ const goutte = new BotEngine(100, 'idle', radii('goutte'))
134
+ expect(goutte.sample(1).bodyPath).not.toBe(rond.sample(1).bodyPath)
135
+ })
136
+
137
+ it('laisse intacts les etats qui dessinent leur propre forme', () => {
138
+ for (const id of ['exclaim', 'alert', 'sleep', 'egg', 'hexagon'] as const) {
139
+ const nu = new BotEngine(100, id)
140
+ const habille = new BotEngine(100, id, radii('goutte'))
141
+ expect(habille.sample(1).bodyPath).toBe(nu.sample(1).bodyPath)
142
+ }
143
+ })
144
+
145
+ it('morphe vers la nouvelle forme au lieu de sauter', () => {
146
+ const e = new BotEngine(100, 'idle', radii('cercle'))
147
+ // le cercle fait 2.0 de haut, la capsule 1.24 : la hauteur est parlante
148
+ expect(hauteur(e, 1)).toBeCloseTo(2, 1)
149
+ e.setShape(radii('capsule'), 1)
150
+
151
+ const etapes = [1.06, 1.14, 1.26].map((t) => hauteur(e, t))
152
+ // strictement decroissant, et jamais deja arrive
153
+ for (let i = 1; i < etapes.length; i++) {
154
+ expect(etapes[i]!).toBeLessThan(etapes[i - 1]!)
155
+ }
156
+ expect(etapes[0]!).toBeLessThan(2)
157
+ expect(etapes[etapes.length - 1]!).toBeGreaterThan(1.24)
158
+
159
+ // arrive apres la duree du morph
160
+ expect(hauteur(e, 1 + BotEngine.SHAPE_MORPH + 0.05)).toBeCloseTo(1.24, 1)
161
+ })
162
+
163
+ it('reste une fonction pure du temps pendant un morph de forme', () => {
164
+ const e = new BotEngine(100, 'idle', radii('cercle'))
165
+ e.setShape(radii('capsule'), 1)
166
+ const milieu = e.sample(1.1).bodyPath
167
+ // on depasse la fin du morph, puis on relit la date passee
168
+ e.sample(3)
169
+ expect(e.sample(1.1).bodyPath).toBe(milieu)
170
+ })
171
+
172
+ it('garde les yeux dans la silhouette sur une forme non circulaire', () => {
173
+ for (const id of ['nuage', 'capsule', 'goutte', 'triangle', 'squircle'] as const) {
174
+ const f = new BotEngine(100, 'idle', radii(id)).sample(1)
175
+ expect(f.eyes).toHaveLength(2)
176
+ for (const eye of f.eyes) {
177
+ const parts = /matrix\(([^)]+)\)/.exec(eye.matrix)![1]!.split(',').map(Number)
178
+ const ex = parts[4]!
179
+ const ey = parts[5]!
180
+ const bord = radiusAtAngle(radii(id), Math.atan2(ey, ex)) * 100
181
+ // le centre de l'oeil doit rester franchement a l'interieur du contour
182
+ expect(Math.hypot(ex, ey)).toBeLessThan(bord)
183
+ }
184
+ }
185
+ })
186
+ })
187
+
188
+ describe('etats', () => {
189
+ /**
190
+ * La sequence est le CATALOGUE : les 14 etats releves sur la video, ceux que
191
+ * la palette propose et que la planche montre. Tout etat hors sequence est une
192
+ * transition d'interface, choisie et non mesuree — il ne doit donc jamais
193
+ * apparaitre dans le catalogue, et le rester est precisement ce qu'on verifie.
194
+ */
195
+ it('garde les 14 etats de la video dans la sequence, et rien d autre', () => {
196
+ expect(SEQUENCE).toHaveLength(14)
197
+ expect(new Set(SEQUENCE).size).toBe(14)
198
+ for (const id of SEQUENCE) expect(STATES.some((s) => s.id === id), id).toBe(true)
199
+ })
200
+
201
+ it('tient les transitions d interface hors du catalogue', () => {
202
+ const horsSequence = STATES.filter((s) => !SEQUENCE.includes(s.id)).map((s) => s.id)
203
+ expect(horsSequence).toEqual(['swirl'])
204
+ })
205
+
206
+ it('montre le visage sur les etats a visage, le cache sur les autres', () => {
207
+ const avec: StateId[] = ['idle', 'wink', 'wide', 'notify', 'egg', 'hexagon']
208
+ const sans: StateId[] = ['thinking', 'alert', 'exclaim', 'sleep']
209
+ for (const id of avec) expect(new BotEngine(100, id).sample(0.9).eyes.length).toBe(2)
210
+ for (const id of sans) expect(new BotEngine(100, id).sample(0.9).eyes.length).toBe(0)
211
+ })
212
+
213
+ it('creuse une encoche autour de la pastille de notification', () => {
214
+ const f = new BotEngine(100, 'notify').sample(1)
215
+ expect(f.notif).not.toBeNull()
216
+ expect(f.notch).not.toBeNull()
217
+ // marge constante mesuree : 0.054 rayon
218
+ expect((f.notch!.r - f.notif!.r) / 100).toBeCloseTo(0.054, 2)
219
+ // la pastille est posee sur la circonference
220
+ expect(Math.hypot(f.notif!.x, f.notif!.y) / 100).toBeCloseTo(1.003, 1)
221
+ })
222
+
223
+ it('trace les anneaux devant ET derriere le corps', () => {
224
+ const f = new BotEngine(100, 'orbit').sample(1.4)
225
+ expect(f.arcs.length).toBeGreaterThan(3)
226
+ expect(f.arcs.some((a) => a.back.length > 0)).toBe(true)
227
+ expect(f.arcs.some((a) => a.front.length > 0)).toBe(true)
228
+ })
229
+
230
+ it('fait spiraler les particules vers le centre pendant l eclatement', () => {
231
+ const e = new BotEngine(100, 'burst')
232
+ const rayon = (t: number) => {
233
+ const d = e.sample(t).dots[0]
234
+ return d ? Math.hypot(d.x, d.y) : 0
235
+ }
236
+ expect(rayon(0.15)).toBeGreaterThan(rayon(0.45))
237
+ expect(rayon(0.45)).toBeGreaterThan(0)
238
+ })
239
+ })
240
+
241
+ describe('regard qui suit le pointeur', () => {
242
+ /** Abscisse de l'oeil interieur, en px de viewBox. */
243
+ const oeilX = (e: BotEngine, t: number) => +/matrix\([^,]+,[^,]+,[^,]+,[^,]+,(-?[\d.]+)/
244
+ .exec(e.sample(t).eyes[0]!.matrix)![1]!
245
+
246
+ /** Amplitude de son deplacement sur une plage de temps. */
247
+ function amplitude(e: BotEngine, de: number, a: number) {
248
+ const xs: number[] = []
249
+ for (let t = de; t <= a; t += 0.1) xs.push(oeilX(e, t))
250
+ return Math.max(...xs) - Math.min(...xs)
251
+ }
252
+
253
+ it('ne touche pas au regard tant qu aucune cible n est posee', () => {
254
+ const nu = new BotEngine(100, 'idle')
255
+ const vise = new BotEngine(100, 'idle')
256
+ vise.setLook(null, 0)
257
+ expect(vise.sample(1).eyes[0]!.matrix).toBe(nu.sample(1).eyes[0]!.matrix)
258
+ })
259
+
260
+ it('porte le regard vers le lacet vise, en absolu', () => {
261
+ const droite = new BotEngine(100, 'idle')
262
+ const gauche = new BotEngine(100, 'idle')
263
+ droite.setLook({ yaw: 45, pitch: 0, mix: 1, spin: 0, wander: 0 }, 0)
264
+ gauche.setLook({ yaw: -45, pitch: 0, mix: 1, spin: 0, wander: 0 }, 0)
265
+ const t = 1 + BotEngine.LOOK_MORPH
266
+ expect(oeilX(droite, t)).toBeGreaterThan(oeilX(gauche, t) + 40)
267
+ })
268
+
269
+ it('remplace le lacet de la pose au lieu de s y ajouter', () => {
270
+ // C'est ce qui rend le changement d'expression fluide : l'appelant n'a pas a
271
+ // retrancher le lacet de l'expression, donc il n'a pas a en connaitre la
272
+ // valeur — qui, pendant un morph, n'est pas encore celle d'arrivee.
273
+ // Deux expressions qui ne different QUE par leur lacet : l'abscisse d'un oeil
274
+ // depend aussi du tangage et de l'ecart des yeux, donc les faire varier
275
+ // ensemble ne prouverait rien.
276
+ const modele = EXPRESSION_BY_ID.get('neutre')!
277
+ const gauchier = { ...modele, gaze: { ...modele.gaze, yaw: -30 } }
278
+ const droitier = { ...modele, gaze: { ...modele.gaze, yaw: 60 } }
279
+ const cercle = SHAPE_BY_ID.get('cercle')!.radii
280
+
281
+ const a = new BotEngine(100, 'idle', cercle, gauchier)
282
+ const b = new BotEngine(100, 'idle', cercle, droitier)
283
+ // sans cible, les deux regardent franchement ailleurs l'un de l'autre
284
+ expect(Math.abs(oeilX(a, 1) - oeilX(b, 1))).toBeGreaterThan(40)
285
+
286
+ a.setLook({ yaw: -26, pitch: 0, mix: 1, spin: 0, wander: 0 }, 1)
287
+ b.setLook({ yaw: -26, pitch: 0, mix: 1, spin: 0, wander: 0 }, 1)
288
+ // ...et le meme lacet vise les pose exactement au meme endroit
289
+ const t = 1 + BotEngine.LOOK_MORPH
290
+ expect(oeilX(a, t)).toBeCloseTo(oeilX(b, t), 5)
291
+ })
292
+
293
+ it('parcourt le tour demande sans changer le point d arrivee', () => {
294
+ const direct = new BotEngine(100, 'idle')
295
+ const tourne = new BotEngine(100, 'idle')
296
+ direct.setLook({ yaw: -26, pitch: 0, mix: 1, spin: 0, wander: 0 }, 0)
297
+ tourne.setLook({ yaw: -26, pitch: 0, mix: 1, spin: 360, wander: 0 }, 0)
298
+ const t = 1 + BotEngine.LOOK_MORPH
299
+ // un tour complet est le meme angle : l image doit etre identique au pixel
300
+ expect(tourne.sample(t).eyes[0]!.matrix).toBe(direct.sample(t).eyes[0]!.matrix)
301
+ // ...alors qu a mi-tour la face est a l oppose du spectateur
302
+ const mi = new BotEngine(100, 'idle')
303
+ mi.setLook({ yaw: -26, pitch: 0, mix: 1, spin: 180, wander: 0 }, 0)
304
+ expect(mi.sample(t).eyes).toHaveLength(0)
305
+ })
306
+
307
+ it('garde les deux yeux visibles aux amplitudes extremes', () => {
308
+ // au-dela, l oeil exterieur passe derriere le limbe de la sphere et le
309
+ // moteur le retire : la butee de BloubBot.vue doit rester en dessous
310
+ for (const yaw of [-42, -26, -10]) {
311
+ for (const pitch of [-3, 10, 23]) {
312
+ const e = new BotEngine(100, 'idle')
313
+ e.setLook({ yaw, pitch, mix: 1, spin: 0, wander: 0 }, 0)
314
+ expect(e.sample(1).eyes, `yaw ${yaw} pitch ${pitch}`).toHaveLength(2)
315
+ }
316
+ }
317
+ })
318
+
319
+ it('eteint la derive automatique quand le pointeur commande', () => {
320
+ const libre = new BotEngine(100, 'idle')
321
+ const tenu = new BotEngine(100, 'idle')
322
+ tenu.setLook({ yaw: REST_GAZE.yaw, pitch: 0, mix: 1, spin: 0, wander: 0 }, 0)
323
+
324
+ // Meme cible que le regard de repos : ce qui reste de mouvement
325
+ // n'est donc QUE la derive. Elle doit s'etre eteinte, a ceci pres que le
326
+ // flottement du corps (±0,006 rayon, `float`) n'est pas concerne.
327
+ expect(amplitude(tenu, 1, 8)).toBeLessThan(2)
328
+ expect(amplitude(libre, 1, 8)).toBeGreaterThan(5 * amplitude(tenu, 1, 8))
329
+ })
330
+
331
+ it('revient au regard de l etat quand la cible est relachee', () => {
332
+ const nu = new BotEngine(100, 'idle')
333
+ const e = new BotEngine(100, 'idle')
334
+ e.setLook({ yaw: -20, pitch: -10, mix: 1, spin: 0, wander: 0 }, 0)
335
+ e.sample(1)
336
+ e.setLook(null, 1)
337
+ // le retour est progressif, puis complet
338
+ expect(oeilX(e, 1.05)).not.toBeCloseTo(oeilX(nu, 1.05), 1)
339
+ const fini = 1 + BotEngine.LOOK_MORPH
340
+ expect(oeilX(e, fini)).toBeCloseTo(oeilX(nu, fini), 5)
341
+ })
342
+
343
+ it('reste une fonction pure du temps pendant le rattrapage', () => {
344
+ const e = new BotEngine(100, 'idle')
345
+ e.setLook({ yaw: -18, pitch: -8, mix: 1, spin: 0, wander: 0 }, 1)
346
+ const milieu = e.sample(1.1).eyes[0]!.matrix
347
+ // relire une date passee doit redonner exactement la meme image
348
+ e.sample(3)
349
+ expect(e.sample(1.1).eyes[0]!.matrix).toBe(milieu)
350
+ })
351
+
352
+ it('laisse la derive intacte sur les vignettes, qui ne visent jamais', () => {
353
+ // une vignette figee n appelle pas setLook : son regard doit deriver comme avant
354
+ const e = new BotEngine(100, 'idle')
355
+ expect(amplitude(e, 0, 6)).toBeGreaterThan(4)
356
+ })
357
+ })
358
+
359
+ describe('robustesse du regard', () => {
360
+ it('refuse une cible non finie plutot que de s en souvenir', () => {
361
+ /**
362
+ * Le moteur GARDE la derniere cible : un `NaN` pose une seule fois se
363
+ * propagerait a chaque image et le bot ne se reposerait plus jamais. Arrive
364
+ * pour de vrai — `getBoundingClientRect` sur une boite de taille nulle donne
365
+ * `0 / 0` chez l'appelant.
366
+ */
367
+ const sain = new BotEngine(100, 'idle')
368
+ const e = new BotEngine(100, 'idle')
369
+ e.setLook({ yaw: NaN, pitch: 10, mix: 1, spin: 0, wander: 0 }, 0)
370
+ expect(e.sample(1).eyes[0]!.matrix).toBe(sain.sample(1).eyes[0]!.matrix)
371
+
372
+ // et une cible saine posee ensuite fonctionne toujours
373
+ e.setLook({ yaw: -26, pitch: 10, mix: 1, spin: 0, wander: 0 }, 1)
374
+ expect(e.sample(1 + BotEngine.LOOK_MORPH).eyes[0]!.matrix).not.toBe(
375
+ sain.sample(1 + BotEngine.LOOK_MORPH).eyes[0]!.matrix
376
+ )
377
+ })
378
+
379
+ it('garde une tete tournee vivante quand aucun pointeur ne la commande', () => {
380
+ /**
381
+ * Regression corrigee : `mix` eteignait la derive en meme temps qu'il prenait
382
+ * la direction. Arriver sur la vue au clavier ou au tactile donnait alors un
383
+ * avatar completement fige, ce qui contredit la definition de l'etat de repos
384
+ * (« derive du regard et clignements »).
385
+ */
386
+ const oeilX = (e: BotEngine, t: number) => +/matrix\([^,]+,[^,]+,[^,]+,[^,]+,(-?[\d.]+)/
387
+ .exec(e.sample(t).eyes[0]!.matrix)![1]!
388
+ const amplitude = (e: BotEngine) => {
389
+ const xs: number[] = []
390
+ for (let t = 1; t <= 8; t += 0.1) xs.push(oeilX(e, t))
391
+ return Math.max(...xs) - Math.min(...xs)
392
+ }
393
+
394
+ const sansPointeur = new BotEngine(100, 'idle')
395
+ sansPointeur.setLook({ yaw: -26, pitch: 10, mix: 1, spin: 0, wander: 1 }, 0)
396
+ const avecPointeur = new BotEngine(100, 'idle')
397
+ avecPointeur.setLook({ yaw: -26, pitch: 10, mix: 1, spin: 0, wander: 0 }, 0)
398
+
399
+ // la tete est tournee dans les deux cas...
400
+ expect(oeilX(sansPointeur, 1)).toBeLessThan(0)
401
+ expect(oeilX(avecPointeur, 1)).toBeLessThan(0)
402
+ // ...mais seule celle que personne ne commande continue de deriver
403
+ expect(amplitude(sansPointeur)).toBeGreaterThan(5 * amplitude(avecPointeur))
404
+ })
405
+ })
406
+
407
+ /**
408
+ * `reset` : repartir sur un etat SANS historique.
409
+ *
410
+ * C'est une methode publique de plus sur ce qui doit devenir une API, donc elle merite son
411
+ * test direct et pas seulement la couverture indirecte du lecteur hors ecran.
412
+ */
413
+ describe('reset', () => {
414
+ it('oublie l etat precedent, la ou setState le garde pour le fondre', () => {
415
+ const avecFondu = new BotEngine(100, 'idle')
416
+ avecFondu.setState('egg', 0)
417
+ const remis = new BotEngine(100, 'idle')
418
+ remis.reset('egg', 0)
419
+
420
+ // au debut du morph, l'un melange encore le repos, l'autre est deja l'oeuf
421
+ expect(avecFondu.sample(0).bodyPath).not.toBe(remis.sample(0).bodyPath)
422
+ // et l'oeuf seul est bien ce qu'un moteur neuf sur `egg` rend
423
+ expect(remis.sample(0).bodyPath).toBe(new BotEngine(100, 'egg').sample(0).bodyPath)
424
+ })
425
+
426
+ /*
427
+ * Compare a date ABSOLUE egale, et pas une pose datee a 0 contre une datee a 5 : la derive
428
+ * au repos depend du temps absolu, donc deux dates differentes ne donnent jamais le meme
429
+ * chemin, meme sur un etat dont la pose est fixe. La comparaison ne dirait rien.
430
+ */
431
+ it('date l etat ou on le lui dit', () => {
432
+ const tot = new BotEngine(100, 'idle')
433
+ tot.reset('alert', 0)
434
+ const tard = new BotEngine(100, 'idle')
435
+ tard.reset('alert', 5)
436
+ // le "!" traverse : a la meme date absolue, l'un en est a 5 s et l'autre au debut
437
+ expect(tot.sample(5).bodyPath).not.toBe(tard.sample(5).bodyPath)
438
+ expect(tard.state).toBe('alert')
439
+ })
440
+
441
+ it('laisse sample une fonction pure du temps', () => {
442
+ const e = new BotEngine(100, 'idle')
443
+ e.reset('orbit', 0)
444
+ const tot = e.sample(0.3).bodyPath
445
+ e.sample(9)
446
+ expect(e.sample(0.3).bodyPath).toBe(tot)
447
+ })
448
+ })
449
+
450
+ /**
451
+ * Un changement d'etat qui arrive PENDANT un fondu.
452
+ *
453
+ * Le moteur ne garde qu'une case d'historique, donc l'origine du nouveau melange devenait la
454
+ * pose PLEINE de l'etat qu'on quittait, au lieu de l'image partiellement melangee qui etait
455
+ * a l'ecran : un saut. Il melange desormais depuis la pose composite figee au moment du
456
+ * changement.
457
+ *
458
+ * Mesure : le deplacement d'un oeil sur les deux images qui suivent un changement. Un
459
+ * changement ESPACE en produit 10 a 14 px, et c'est voulu — l'`easeOutQuint` releve sur la
460
+ * video demarre raide. Ce qui ne l'etait pas, c'est les 26 a 43 px d'un changement en plein
461
+ * fondu.
462
+ */
463
+ describe('changement d etat pendant un fondu', () => {
464
+ const IMAGE = 1 / 60
465
+
466
+ /** Deplacement max d'un oeil sur les deux images qui suivent `at`. */
467
+ function sautApres(changements: Array<[StateId, number]>, at: number) {
468
+ const e = new BotEngine(100, 'idle')
469
+ const distances: Array<{ t: number; d: number[] }> = []
470
+ let i = 0
471
+ for (let t = 0; t <= at + 4 * IMAGE; t += IMAGE) {
472
+ while (i < changements.length && changements[i]![1] <= t + 1e-9) {
473
+ e.setState(changements[i]![0], changements[i]![1])
474
+ i++
475
+ }
476
+ distances.push({
477
+ t,
478
+ d: e.sample(t).eyes.map((y) => {
479
+ const n = y.matrix.match(/-?\d+\.?\d*/g)!.map(Number)
480
+ return Math.hypot(n[4]!, n[5]!)
481
+ })
482
+ })
483
+ }
484
+ let pire = 0
485
+ for (let k = 1; k < distances.length; k++) {
486
+ const a = distances[k - 1]!
487
+ const b = distances[k]!
488
+ if (a.d.length !== b.d.length || b.t <= at || b.t > at + 2.5 * IMAGE) continue
489
+ pire = Math.max(pire, ...a.d.map((v, j) => Math.abs(v - b.d[j]!)))
490
+ }
491
+ return pire
492
+ }
493
+
494
+ /** Reference : le meme changement, mais espace. C'est le mouvement normal. */
495
+ const NORMAL = 14
496
+
497
+ it('ne saute pas plus qu un changement espace', () => {
498
+ const espace = sautApres([['wide', 0.5], ['idle', 2]], 2)
499
+ expect(espace).toBeLessThan(NORMAL)
500
+
501
+ // 100 ms apres le premier, donc en plein fondu de 0,55 s
502
+ expect(sautApres([['wide', 0.5], ['idle', 0.6]], 0.6)).toBeLessThanOrEqual(espace)
503
+ })
504
+
505
+ it('tient sur des changements enchaines', () => {
506
+ const suite: Array<[StateId, number]> = [
507
+ ['wide', 0.5],
508
+ ['idle', 0.55],
509
+ ['egg', 0.6],
510
+ ['orbit', 0.65],
511
+ ['notify', 0.7]
512
+ ]
513
+ for (const [, at] of suite.slice(1)) {
514
+ expect(sautApres(suite, at), `changement a ${at}s`).toBeLessThan(NORMAL)
515
+ }
516
+ })
517
+
518
+ /**
519
+ * Et la lecture ESPACEE ne change pas : les blocs d'un montage durent au moins le plus
520
+ * long fondu, donc rien n'y est jamais fige. Verifie sur la sequence complete, image par
521
+ * image — c'est ce qui protege les animations relevees.
522
+ */
523
+ it('ne change rien a une lecture espacee', () => {
524
+ const suite: Array<[StateId, number]> = [
525
+ ['thinking', 1],
526
+ ['wink', 2],
527
+ ['alert', 3]
528
+ ]
529
+ const avecHistorique = new BotEngine(100, 'idle')
530
+ let i = 0
531
+ const images: string[] = []
532
+ for (let t = 0; t < 4; t += IMAGE) {
533
+ while (i < suite.length && suite[i]![1] <= t + 1e-9) {
534
+ avecHistorique.setState(suite[i]![0], suite[i]![1])
535
+ i++
536
+ }
537
+ images.push(avecHistorique.sample(t).bodyPath)
538
+ }
539
+ // aucune image vide, et le "!" d'`alert` bouge encore pendant qu'il se fond
540
+ expect(images.every((p) => p.length > 0)).toBe(true)
541
+ expect(new Set(images.slice(-12)).size).toBeGreaterThan(1)
542
+ })
543
+ })