import { createAvatar } from '@/features/avatar/avatars' import { parse } from '@babel/parser' import { avatarDemoFileName, createAvatarExportPayload, generateJavaScriptAvatarHtml, generateJavaScriptAvatarModule, generateJavaScriptAvatarPackage, generateJavaScriptEsmHtml, generateJavaScriptEsmPackage, generateReactAvatarComponent, generateReactAvatarPackage, generateReactAvatarRuntime, generateReactViteMain, generateReactVitePackage, } from '@/features/export/exporter' import { createAvatarDefinition } from '@/features/avatar/avatarDefinition' import { resolveAvatarBehavior } from '@/features/avatar/avatars' import { loadStudioDocument } from '@/features/studio/studioDocument' import { initialExpressions } from '@/features/avatar/presets' import { createInitialSequences } from '@/features/animation/sequences' const storedZipFileNames = (archive: Uint8Array) => { const names: string[] = [] const decoder = new TextDecoder() const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) let offset = 0 while (offset + 30 <= archive.byteLength && view.getUint32(offset, true) === 0x04034b50) { const contentLength = view.getUint32(offset + 18, true) const nameLength = view.getUint16(offset + 26, true) const extraLength = view.getUint16(offset + 28, true) const nameStart = offset + 30 names.push(decoder.decode(archive.subarray(nameStart, nameStart + nameLength))) offset = nameStart + nameLength + extraLength + contentLength } return names } describe('avatar export', () => { const avatar = createAvatar('Strobi') const animations = createInitialSequences().filter(item => ['idle', 'listening'].includes(item.id) ) const payload = createAvatarExportPayload(avatar, initialExpressions, animations) it('uses distinct filenames for React and ESM demo archives', () => { expect(avatarDemoFileName('Strobi', 'react')).toBe('strobi-avatar-react.zip') expect(avatarDemoFileName('Strobi', 'javascript')).toBe('strobi-avatar-esm.zip') }) it('includes only the selected animations and their referenced expressions', () => { expect(Object.keys(payload.animations)).toEqual(['idle', 'listening']) expect(Object.keys(payload.expressions).sort()).toEqual( [ ...new Set(animations.flatMap(animation => animation.steps.map(step => step.expressionId))), ].sort() ) expect(payload).not.toHaveProperty('frames') expect(payload.avatar.name).toBe('Strobi') expect(payload.avatar.renderStyle).toEqual({ type: 'vector' }) expect(Object.values(payload.expressions).every(expression => !expression.semanticKey)).toBe( true ) }) it('preserves pixel rendering in standalone exports', () => { const pixelPayload = createAvatarExportPayload( { ...avatar, renderStyle: { type: 'pixel', resolution: 72 } }, initialExpressions, animations ) const source = generateJavaScriptAvatarModule(pixelPayload) expect(pixelPayload.avatar.renderStyle).toEqual({ type: 'pixel', resolution: 72 }) expect(source).toContain('paintPixelGeometry') expect(source).toContain('Math.max(8, Math.min(192') }) it('generates a standalone JavaScript module without a Web Component', () => { const source = generateJavaScriptAvatarModule(payload) expect(source).toContain('export function createAvatar') expect(source).toContain('play(animationName)') expect(source).toContain('export const availableAnimations') expect(source).toContain('pausedRemainingMs') expect(source).toContain('pausedBlinkDelay') expect(source).toContain('pausedBlink') expect(source).toContain('step.transition') expect(source).toContain('stepIndex = 0') expect(source).not.toContain('customElements.define') expect(source).not.toContain("from '") expect(() => parse(source, { sourceType: 'module' })).not.toThrow() }) it('uses the Studio geometry engine to render interpolated transition frames', () => { const source = generateJavaScriptAvatarModule(payload) expect(source).toContain('AvatarProceduralEngine') expect(source).toContain('expressionFields') expect(source).toContain('requestAnimationFrame') expect(source).not.toContain('avatarFrameIn') }) it('generates an HTML demo that works when opened directly from the filesystem', () => { const source = generateJavaScriptAvatarHtml(payload) expect(source).not.toContain("import { createAvatar } from './avatar.js'") expect(source).toContain('function createAvatar') expect(source).toContain('avatar.play("idle")') }) it('generates an interactive playground for the exported animations', () => { const source = generateJavaScriptAvatarHtml(payload) expect(source).toContain('id="animation-list"') expect(source).toContain('id="play-animation"') expect(source).toContain('id="pause-animation"') expect(source).toContain('id="stop-animation"') expect(source).toContain('availableAnimations.forEach') expect(source).toContain('avatar.play(activeAnimation)') expect(source).toContain('avatar.pause()') expect(source).toContain('avatar.stop()') expect(source).toContain("createAvatar('#avatar'") }) it('localizes the exported playground', () => { const source = generateJavaScriptAvatarHtml(payload, 'fr') expect(source).toContain('') expect(source).toContain('Choisissez une animation exportée') expect(source).toContain('▶ Lire') expect(source).toContain('En pause') }) it('keeps user-authored names and descriptions inside the generated script', () => { const source = generateJavaScriptAvatarHtml({ ...payload, avatar: { ...payload.avatar, name: '' }, }) expect(source).not.toContain('') expect(source).toContain('</title>') expect(source).toContain('\\u003c/script>') }) it('packages the direct-open demo and reusable module together', async () => { const archive = new Uint8Array(await generateJavaScriptAvatarPackage(payload).arrayBuffer()) const contents = new TextDecoder().decode(archive) expect(contents).toContain('index.html') expect(contents).toContain('avatar.js') }) it('generates a lightweight ESM integration backed by avatar-web', async () => { const document = loadStudioDocument({ getItem: () => null }) const studioAvatar = document.library.avatars[0] const definition = createAvatarDefinition({ avatar: studioAvatar, behavior: resolveAvatarBehavior(studioAvatar, { expressions: document.expressions, sequences: document.sequences, }), }) expect(definition.ok).toBe(true) if (!definition.ok) return const source = generateJavaScriptEsmHtml('strobi.avatar.json', 'Strobi') expect(source).toContain("from 'https://esm.sh/@bible-strong/avatar-web@0.1.0'") expect(source).toContain("fetch('./strobi.avatar.json')") expect(source).not.toContain('AvatarProceduralEngine') const archive = new Uint8Array( await generateJavaScriptEsmPackage(definition.value, 'Strobi').arrayBuffer() ) const contents = new TextDecoder().decode(archive) expect(storedZipFileNames(archive)).toEqual(['strobi.avatar.json', 'index.html', 'README.md']) expect(contents).toContain('esm.sh/@bible-strong/avatar-web@0.1.0') expect(contents).not.toContain('AvatarProceduralEngine') }) it('generates a ready-to-run React TypeScript demo backed by avatar-react', async () => { const document = loadStudioDocument({ getItem: () => null }) const studioAvatar = document.library.avatars[0] const definition = createAvatarDefinition({ avatar: studioAvatar, behavior: resolveAvatarBehavior(studioAvatar, { expressions: document.expressions, sequences: document.sequences, }), }) expect(definition.ok).toBe(true) if (!definition.ok) return const source = generateReactViteMain('../strobi.avatar.json', 'Strobi') expect(source).toContain("from '@bible-strong/avatar-react'") expect(source).toContain("from '../strobi.avatar.json'") expect(source).toContain('createAvatar(definition)') expect(source).toContain("kind: 'animation'") expect(source).toContain("kind: 'expression'") expect(() => parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] }) ).not.toThrow() const archive = new Uint8Array( await generateReactVitePackage(definition.value, 'Strobi').arrayBuffer() ) const contents = new TextDecoder().decode(archive) expect(storedZipFileNames(archive)).toEqual([ 'strobi.avatar.json', 'package.json', 'index.html', 'tsconfig.json', 'vite.config.ts', 'src/main.tsx', 'src/vite-env.d.ts', 'src/styles.css', 'README.md', ]) expect(contents).toContain('"@bible-strong/avatar-react": "0.1.0"') expect(contents).toContain('npm install') expect(contents).toContain('npm run dev') expect(contents).not.toContain('AvatarProceduralEngine') }) it('generates a typed React component backed by the local runtime', () => { const source = generateReactAvatarComponent(payload) expect(source).toContain("from 'react'") expect(source).toContain("from './avatar-runtime'") expect(source).toContain("from './strobi.avatar'") expect(source).toContain('export type { AnimationName }') expect(source).toContain('animation?: AnimationName') expect(source).toContain('forwardRef') expect(source).not.toContain('AvatarProceduralEngine') expect(source).toContain('runtime.createAvatar(host.current') expect(source).not.toContain('@ts-nocheck') expect(source).not.toContain('avatarFrameIn') expect(source).not.toContain("from 'motion") expect(() => parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] }) ).not.toThrow() }) it('packages the React runtime, avatar data and component separately', async () => { const archive = new Uint8Array(await generateReactAvatarPackage(payload).arrayBuffer()) const contents = new TextDecoder().decode(archive) expect(contents).toContain('avatar-runtime.ts') expect(contents).toContain('strobi.avatar.ts') expect(contents).toContain('Strobi.tsx') expect(contents).toContain('strobi.index.ts') expect(contents).toContain("from './avatar-runtime'") }) it('generates one avatar-independent and versioned React runtime', () => { const runtime = generateReactAvatarRuntime() expect(runtime).toContain('AVATAR_RUNTIME_VERSION = 1') expect(runtime).toContain('data.version !== AVATAR_RUNTIME_VERSION') expect(runtime).not.toContain(payload.avatar.name) expect(runtime).toContain('RuntimeAvatar') expect(() => parse(runtime, { sourceType: 'module', plugins: ['typescript'] })).not.toThrow() }) it('uses globally unique SVG identifiers across avatar runtimes', () => { const source = generateJavaScriptAvatarModule(payload) expect(source).toContain('globalThis.crypto.randomUUID()') expect(source).not.toContain('avatarInstanceCount') }) })