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')
})
})