bible-strong-avatar-lab/src/features/avatar/__tests__/avatar-definition-test.ts

717 lines
24 KiB
TypeScript

import {
AVATAR_DEFINITION_MAX_BYTES,
avatarDefinitionFileName,
createAvatarDefinition,
getStandardAnimationAvailabilityV1,
parseAvatarDefinition,
STANDARD_ANIMATIONS_V1,
STANDARD_ANIMATION_ORDER_V1,
validateAvatarDefinition,
type AvatarDefinition,
} from '@/features/avatar/avatarDefinition'
import {
defaultAvatarColors,
defaultAvatarEyes,
resolveAvatarBehavior,
type AvatarBehaviorLibrary,
type StudioAvatar,
} from '@/features/avatar/avatars'
import { createInitialSequences, createSequence } from '@/features/animation/sequences'
import { defaultExpression, initialExpressions } from '@/features/avatar/presets'
import { surfacePresets } from '@/features/avatar/surfaces'
import { loadStudioDocument } from '@/features/studio/studioDocument'
const avatarFixture = (): StudioAvatar => ({
id: 'avatar-fixture',
name: 'Fixture',
body: {
primary: {
...surfacePresets.cone,
width: 251.123456,
roundness: 0.25,
morphRoundness: 0.2,
tipRoundness: 0.3,
baseRoundness: 0.4,
},
nodes: [
{
id: 'shape-private',
name: 'Private label',
surface: { ...surfacePresets.cylinder, roundness: 0.45, morphRoundness: 0.2 },
position: [1.25, -2.5, 3.75],
rotation: [-10, 20, 30],
},
],
},
colors: { body: '#abcdef', eyes: '#123456' },
renderStyle: { type: 'vector' },
eyes: {
...defaultAvatarEyes,
widthLeft: defaultAvatarEyes.widthLeft + 3,
positionYRight: defaultAvatarEyes.positionYRight - 4,
},
})
const behaviorFixture = (): AvatarBehaviorLibrary => {
const expression = {
...initialExpressions[0],
semanticKey: 'happy-smile',
bodyColor: '#fedcba',
eyeColor: '#654321',
eyeMotion: 'microSaccades' as const,
bodyMotion: 'slowDrift' as const,
}
const sequence = createInitialSequences()[0]
return {
expressions: [expression],
sequences: [
{
...sequence,
semanticKey: 'happy',
playbackMode: 'once',
steps: [
{
...sequence.steps[0],
expressionId: expression.id,
holdMs: 1234,
transitionMs: 432,
transition: 'snappy',
},
],
blink: {
enabled: true,
initialDelayMs: 100,
minIntervalMs: 250,
maxIntervalMs: 120000,
durationMs: 50,
},
},
],
}
}
const definitionFixture = (): AvatarDefinition => {
const result = createAvatarDefinition({ avatar: avatarFixture(), behavior: behaviorFixture() })
if (!result.ok) throw new Error(JSON.stringify(result.errors))
return structuredClone(result.value) as AvatarDefinition
}
const expectError = (value: unknown, code: string, path?: string) => {
const result = validateAvatarDefinition(value)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ code, ...(path === undefined ? {} : { path }) }),
])
)
}
describe('avatar definition validation', () => {
it('validates, clones, deeply freezes, and JSON round-trips a v1 definition', () => {
const input = definitionFixture()
const result = validateAvatarDefinition(input)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).not.toBe(input)
expect(Object.isFrozen(result.value)).toBe(true)
expect(Object.isFrozen(result.value.expressions.neutral.eyes.left)).toBe(true)
expect(JSON.parse(JSON.stringify(result.value))).toEqual(input)
expect(validateAvatarDefinition(JSON.parse(JSON.stringify(result.value))).ok).toBe(true)
})
it('rejects unsupported versions, unknown fields, malformed keys, and missing neutral', () => {
expectError(
{ ...definitionFixture(), schemaVersion: 2 },
'unsupported_version',
'/schemaVersion'
)
expectError({ ...definitionFixture(), surprise: true }, 'additionalProperties', '/surprise')
const malformed = definitionFixture()
malformed.expressions['Happy Smile'] = malformed.expressions['happy-smile']
malformed.expressionOrder.push('Happy Smile')
expectError(malformed, 'pattern', '/expressions/Happy Smile')
const missingNeutral = definitionFixture()
delete missingNeutral.expressions.neutral
missingNeutral.expressionOrder = missingNeutral.expressionOrder.filter(key => key !== 'neutral')
expectError(missingNeutral, 'required', '/expressions/neutral')
})
it('rejects incomplete order lists and dangling animation references with JSON pointers', () => {
const incomplete = definitionFixture()
incomplete.expressionOrder = ['neutral']
expectError(incomplete, 'incomplete_order', '/expressionOrder')
const dangling = definitionFixture()
dangling.animations.happy.steps[0].expression = 'missing'
expectError(dangling, 'unknown_expression', '/animations/happy/steps/0/expression')
})
it('requires neutral to be the first expression-order entry', () => {
const definition = definitionFixture()
definition.expressionOrder = ['happy-smile', 'neutral']
expectError(definition, 'neutral_not_first', '/expressionOrder/0')
})
it('rejects non-finite numbers, non-plain objects, and invalid blink ranges', () => {
const nonFinite = definitionFixture()
nonFinite.body.primary.width = Number.POSITIVE_INFINITY
expectError(nonFinite, 'non_finite_number', '/body/primary/width')
const nonPlain = definitionFixture()
nonPlain.colors = new (class {
body = '#abcdef' as const
eyes = '#123456' as const
})()
expectError(nonPlain, 'non_plain_object', '/colors')
const interval = definitionFixture()
interval.animations.happy.blink.minIntervalMs = 500
interval.animations.happy.blink.maxIntervalMs = 499
expectError(interval, 'invalid_interval_range', '/animations/happy/blink/minIntervalMs')
})
it.each([
['dimension minimum', (value: AvatarDefinition) => (value.body.primary.width = 0), 'minimum'],
[
'dimension maximum',
(value: AvatarDefinition) => (value.body.primary.height = 10000.1),
'maximum',
],
[
'roundness minimum',
(value: AvatarDefinition) => (value.body.primary.roundness = -0.1),
'minimum',
],
[
'roundness maximum',
(value: AvatarDefinition) => (value.body.primary.roundness = 1.1),
'maximum',
],
[
'position minimum',
(value: AvatarDefinition) => (value.body.nodes[0].position[0] = -10000.1),
'minimum',
],
[
'position maximum',
(value: AvatarDefinition) => (value.body.nodes[0].position[1] = 10000.1),
'maximum',
],
[
'rotation minimum',
(value: AvatarDefinition) => (value.body.nodes[0].rotation[0] = -360.1),
'minimum',
],
[
'rotation maximum',
(value: AvatarDefinition) => (value.body.nodes[0].rotation[1] = 360.1),
'maximum',
],
[
'perspective minimum',
(value: AvatarDefinition) => (value.expressions.neutral.perspective = 0.09),
'minimum',
],
[
'perspective maximum',
(value: AvatarDefinition) => (value.expressions.neutral.perspective = 10.01),
'maximum',
],
[
'eye bound',
(value: AvatarDefinition) => (value.expressions.neutral.eyes.left.x = 10001),
'maximum',
],
[
'head bound',
(value: AvatarDefinition) => (value.expressions.neutral.head.z = -10001),
'minimum',
],
[
'hold minimum',
(value: AvatarDefinition) => (value.animations.happy.steps[0].holdMs = 99),
'minimum',
],
[
'hold maximum',
(value: AvatarDefinition) => (value.animations.happy.steps[0].holdMs = 60001),
'maximum',
],
[
'transition minimum',
(value: AvatarDefinition) => (value.animations.happy.steps[0].transitionMs = -1),
'minimum',
],
[
'transition maximum',
(value: AvatarDefinition) => (value.animations.happy.steps[0].transitionMs = 5001),
'maximum',
],
[
'blink initial delay',
(value: AvatarDefinition) => (value.animations.happy.blink.initialDelayMs = 60001),
'maximum',
],
[
'blink interval minimum',
(value: AvatarDefinition) => (value.animations.happy.blink.minIntervalMs = 249),
'minimum',
],
[
'blink interval maximum',
(value: AvatarDefinition) => (value.animations.happy.blink.maxIntervalMs = 120001),
'maximum',
],
[
'blink duration minimum',
(value: AvatarDefinition) => (value.animations.happy.blink.durationMs = 49),
'minimum',
],
[
'blink duration maximum',
(value: AvatarDefinition) => (value.animations.happy.blink.durationMs = 2001),
'maximum',
],
])('enforces the documented %s boundary', (_name, mutate, code) => {
const definition = definitionFixture()
mutate(definition)
expectError(definition, code)
})
it('accepts all inclusive numeric boundaries', () => {
const definition = definitionFixture()
definition.body.primary.width = 0.001
definition.body.primary.height = 10000
definition.body.primary.roundness = 0
definition.body.primary.morphRoundness = 1
definition.body.nodes[0].position = [-10000, 0, 10000]
definition.body.nodes[0].rotation = [-360, 0, 360]
definition.expressions.neutral.head = { x: -10000, y: 0, z: 10000 }
definition.expressions.neutral.perspective = 0.1
definition.expressions['happy-smile'].perspective = 10
definition.animations.happy.steps[0].holdMs = 60000
definition.animations.happy.steps[0].transitionMs = 5000
definition.animations.happy.blink = {
enabled: false,
initialDelayMs: 60000,
minIntervalMs: 250,
maxIntervalMs: 120000,
durationMs: 2000,
}
expect(validateAvatarDefinition(definition).ok).toBe(true)
})
it('enforces collection and text limits', () => {
const nodes = definitionFixture()
nodes.body.nodes = Array.from({ length: 17 }, () => structuredClone(nodes.body.nodes[0]))
expectError(nodes, 'maxItems', '/body/nodes')
const steps = definitionFixture()
steps.animations.happy.steps = Array.from({ length: 129 }, () => ({
...steps.animations.happy.steps[0],
}))
expectError(steps, 'maxItems', '/animations/happy/steps')
const text = definitionFixture()
text.name = 'n'.repeat(121)
text.animations.happy.metadata!.label = 'l'.repeat(121)
text.animations.happy.metadata!.description = 'd'.repeat(513)
text.animations.happy.metadata!.group = 'g'.repeat(65)
expectError(text, 'maxLength')
})
it('enforces expression and animation collection limits', () => {
const expressions = definitionFixture()
const pose = expressions.expressions['happy-smile']
expressions.expressions = { neutral: expressions.expressions.neutral }
expressions.expressionOrder = ['neutral']
for (let index = 0; index < 128; index += 1) {
const key = `pose-${index}`
expressions.expressions[key] = structuredClone(pose)
expressions.expressionOrder.push(key)
}
expectError(expressions, 'maxProperties', '/expressions')
const animations = definitionFixture()
const animation = animations.animations.happy
animations.animations = {}
animations.animationOrder = []
for (let index = 0; index < 65; index += 1) {
const key = `animation-${index}`
animations.animations[key] = structuredClone(animation)
animations.animationOrder.push(key)
}
expectError(animations, 'maxProperties', '/animations')
})
it('accepts exact collection and text maxima', () => {
const definition = definitionFixture()
definition.name = 'n'.repeat(120)
definition.animations.happy.metadata = {
label: 'l'.repeat(120),
description: 'd'.repeat(512),
group: 'g'.repeat(64),
}
definition.body.nodes = Array.from({ length: 16 }, () =>
structuredClone(definition.body.nodes[0])
)
definition.animations.happy.steps = Array.from({ length: 128 }, () => ({
...definition.animations.happy.steps[0],
}))
expect(validateAvatarDefinition(definition).ok).toBe(true)
})
it('enforces the 64-character semantic-key maximum', () => {
const definition = definitionFixture()
const expression = definition.expressions['happy-smile']
delete definition.expressions['happy-smile']
const key = `a${'b'.repeat(64)}`
definition.expressions[key] = expression
definition.expressionOrder = ['neutral', key]
expectError(definition, 'maxLength', `/expressions/${key}`)
})
it('requires lowercase six-digit colors and known secondary surface types', () => {
const color = definitionFixture()
color.colors.body = '#ABCDEF'
expectError(color, 'pattern', '/colors/body')
const surface = definitionFixture()
surface.body.nodes[0].surface.type = 'cursor' as 'sphere'
expectError(surface, 'enum', '/body/nodes/0/surface/type')
})
})
describe('bounded avatar JSON parser', () => {
it('rejects duplicate object members at the second member path', () => {
const result = parseAvatarDefinition('{"schema":1,"schema":2}')
expect(result).toEqual({
ok: false,
errors: [expect.objectContaining({ path: '/schema', code: 'duplicate_key' })],
})
})
it('rejects oversized UTF-8 input and excessive nesting', () => {
const oversized = parseAvatarDefinition(' '.repeat(AVATAR_DEFINITION_MAX_BYTES + 1))
expect(oversized).toEqual({
ok: false,
errors: [expect.objectContaining({ path: '', code: 'max_bytes' })],
})
const nested = parseAvatarDefinition(`${'['.repeat(33)}null${']'.repeat(33)}`)
expect(nested).toEqual({
ok: false,
errors: [expect.objectContaining({ code: 'max_depth' })],
})
})
it('accepts the exact byte limit and depth limit before schema validation', () => {
const json = JSON.stringify(definitionFixture())
const jsonBytes = new TextEncoder().encode(json).byteLength
const exactBytes = `${json}${' '.repeat(AVATAR_DEFINITION_MAX_BYTES - jsonBytes)}`
expect(new TextEncoder().encode(exactBytes)).toHaveLength(AVATAR_DEFINITION_MAX_BYTES)
expect(parseAvatarDefinition(exactBytes).ok).toBe(true)
const depth32 = `${'['.repeat(32)}null${']'.repeat(32)}`
const result = parseAvatarDefinition(depth32)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.errors.some(error => error.code === 'max_depth')).toBe(false)
})
it('rejects overlong decoded strings before schema validation', () => {
const result = parseAvatarDefinition(`{"name":"${'a'.repeat(513)}"}`)
expect(result).toEqual({
ok: false,
errors: [expect.objectContaining({ path: '/name', code: 'string_too_long' })],
})
})
it('applies semantic-key limits while tokenizing and treats prototype keys as data', () => {
const longKey = `a${'b'.repeat(64)}`
const longKeyResult = parseAvatarDefinition(`{"expressions":{"${longKey}":null}}`)
expect(longKeyResult).toEqual({
ok: false,
errors: [
expect.objectContaining({
path: `/expressions/${longKey}`,
code: 'string_too_long',
}),
],
})
const prototypeResult = parseAvatarDefinition('{"__proto__":{"polluted":true}}')
expect(prototypeResult.ok).toBe(false)
expect(({} as { polluted?: boolean }).polluted).toBeUndefined()
if (!prototypeResult.ok) {
expect(prototypeResult.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: '/__proto__', code: 'additionalProperties' }),
])
)
}
})
it('parses a valid definition and rejects malformed JSON', () => {
expect(parseAvatarDefinition(JSON.stringify(definitionFixture())).ok).toBe(true)
const malformed = parseAvatarDefinition('{"schema":]')
expect(malformed).toEqual({
ok: false,
errors: [expect.objectContaining({ code: 'invalid_json' })],
})
})
})
describe('Studio to avatar definition conversion', () => {
it('exports the complete bundled document with curated semantic keys', () => {
const document = loadStudioDocument({ getItem: () => null })
const avatar = document.library.avatars[0]
const behavior = resolveAvatarBehavior(avatar, {
expressions: document.expressions,
sequences: document.sequences,
})
const result = createAvatarDefinition({ avatar, behavior })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.expressionOrder).toHaveLength(28)
expect(result.value.expressionOrder[0]).toBe('neutral')
expect(result.value.animationOrder).toHaveLength(23)
expect(result.value.animationOrder).toContain('idle')
expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([
'upward-side-glance',
'curious-left',
])
expect(getStandardAnimationAvailabilityV1(result.value.expressions).available).toEqual(
STANDARD_ANIMATION_ORDER_V1
)
expect(JSON.stringify(result.value)).not.toContain('expression-00')
})
it('rejects newly created custom content until semantic keys are supplied', () => {
const expression = { ...initialExpressions[0], id: 'expression-custom', semanticKey: undefined }
const sequence = createSequence(expression.id)
expect(expression.semanticKey).toBeUndefined()
expect(sequence.semanticKey).toBeUndefined()
const result = createAvatarDefinition({
avatar: avatarFixture(),
behavior: { expressions: [expression], sequences: [sequence] },
})
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.errors.map(error => error.code)).toEqual(
expect.arrayContaining(['missing_semantic_key'])
)
}
})
it('preserves geometry, colors, timing, motion, and resolves avatar eye defaults', () => {
const avatar = avatarFixture()
const behavior = behaviorFixture()
const result = createAvatarDefinition({ avatar, behavior })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.body.primary).toEqual(avatar.body.primary)
expect(result.value.body.nodes).toEqual([
{
surface: avatar.body.nodes[0].surface,
position: avatar.body.nodes[0].position,
rotation: avatar.body.nodes[0].rotation,
},
])
expect(result.value.body.nodes[0]).not.toHaveProperty('id')
expect(result.value.body.nodes[0]).not.toHaveProperty('name')
expect(result.value.colors).toEqual(avatar.colors)
expect(result.value.expressions.neutral.eyes.left.width).toBe(defaultExpression.widthLeft + 3)
expect(result.value.expressions.neutral.eyes.right.y).toBe(defaultExpression.positionYRight - 4)
expect(result.value.expressions['happy-smile'].eyes.left.width).toBeCloseTo(
behavior.expressions[0].widthLeft + 3
)
expect(result.value.expressions['happy-smile'].colors).toEqual({
body: '#fedcba',
eyes: '#654321',
})
expect(result.value.expressions['happy-smile'].motion).toEqual({
eyes: 'microSaccades',
body: 'slowDrift',
})
expect(result.value.expressionOrder).toEqual(['neutral', 'happy-smile'])
expect(result.value.animations.happy).toMatchObject({
playbackMode: 'once',
steps: [
{
expression: 'happy-smile',
holdMs: 1234,
transitionMs: 432,
transition: 'snappy',
},
],
blink: behavior.sequences[0].blink,
})
})
it.each([
[undefined, 'missing_semantic_key'],
['Bad Key', 'invalid_semantic_key'],
['neutral', 'reserved_semantic_key'],
])('rejects an unexportable expression semantic key', (semanticKey, code) => {
const behavior = behaviorFixture()
behavior.expressions[0].semanticKey = semanticKey
const result = createAvatarDefinition({ avatar: avatarFixture(), behavior })
expect(result.ok).toBe(false)
if (!result.ok)
expect(result.errors).toEqual(expect.arrayContaining([expect.objectContaining({ code })]))
})
it('rejects duplicate keys and unresolved animation expression references', () => {
const duplicate = behaviorFixture()
duplicate.expressions.push({ ...duplicate.expressions[0], id: 'other-expression' })
const duplicateResult = createAvatarDefinition({ avatar: avatarFixture(), behavior: duplicate })
expect(duplicateResult.ok).toBe(false)
if (!duplicateResult.ok) {
expect(duplicateResult.errors).toEqual(
expect.arrayContaining([expect.objectContaining({ code: 'duplicate_semantic_key' })])
)
}
const dangling = behaviorFixture()
dangling.sequences[0].steps[0].expressionId = 'missing-expression'
const danglingResult = createAvatarDefinition({ avatar: avatarFixture(), behavior: dangling })
expect(danglingResult.ok).toBe(false)
if (!danglingResult.ok) {
expect(danglingResult.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: '/studio/animations/0/steps/0/expressionId',
code: 'unresolved_expression_reference',
}),
])
)
}
})
it('does not mutate Studio inputs', () => {
const avatar = avatarFixture()
const behavior = behaviorFixture()
const before = JSON.stringify({ avatar, behavior })
createAvatarDefinition({ avatar, behavior })
expect(JSON.stringify({ avatar, behavior })).toBe(before)
expect(defaultAvatarColors).toEqual({ body: '#5b7fe5', eyes: '#111316' })
})
})
describe('standard animation catalogue v1', () => {
it('commits the approved keys, order, steps, timing, and blink settings', () => {
expect(STANDARD_ANIMATION_ORDER_V1).toEqual([
'idle',
'happy',
'sad',
'thinking',
'excited',
'celebrate',
])
expect(STANDARD_ANIMATIONS_V1.idle).toEqual({
requiredExpressions: ['upward-side-glance', 'curious-left'],
playbackMode: 'loop',
steps: [
{
expression: 'upward-side-glance',
holdMs: 5200,
transitionMs: 500,
transition: 'smooth',
},
{
expression: 'curious-left',
holdMs: 5200,
transitionMs: 500,
transition: 'smooth',
},
],
blink: {
enabled: true,
initialDelayMs: 2600,
minIntervalMs: 3400,
maxIntervalMs: 6200,
durationMs: 280,
},
})
expect(Object.values(STANDARD_ANIMATIONS_V1).every(animation => animation.steps.length)).toBe(
true
)
expect(
Object.values(STANDARD_ANIMATIONS_V1).every(animation =>
animation.steps.every(
step =>
step.transitionMs === 500 &&
step.transition === 'smooth' &&
animation.requiredExpressions.includes(step.expression)
)
)
).toBe(true)
expect(
Object.fromEntries(
STANDARD_ANIMATION_ORDER_V1.map(key => [
key,
STANDARD_ANIMATIONS_V1[key].steps.map(step => step.expression),
])
)
).toEqual({
idle: ['upward-side-glance', 'curious-left'],
happy: ['joyful-down-right', 'joyful-wide', 'playful-right', 'gentle-downward-gaze'],
sad: ['sleepy-squint', 'eyes-closed', 'drowsy-closed'],
thinking: [
'curious-left',
'angry-left',
'skeptical-left',
'playful-right',
'skeptical-right',
],
excited: [
'joyful-down-right',
'playful-right',
'surprised-wide-left',
'surprised-left',
'joyful-wide',
],
celebrate: ['joyful-down-right', 'curious-left', 'playful-right'],
})
})
it('reports available standards and exact missing semantic expressions', () => {
const definition = definitionFixture()
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
expect(availability.available).toEqual([])
expect(availability.unavailable.find(item => item.key === 'idle')).toEqual({
key: 'idle',
missingExpressions: ['upward-side-glance', 'curious-left'],
})
const idleExpressions = {
...definition.expressions,
'upward-side-glance': definition.expressions.neutral,
'curious-left': definition.expressions.neutral,
}
expect(getStandardAnimationAvailabilityV1(idleExpressions).available).toEqual(['idle'])
})
it('creates a sanitized runtime-definition filename', () => {
expect(avatarDefinitionFileName(' Éric Avatar! ')).toBe('eric-avatar.avatar.json')
expect(avatarDefinitionFileName('***')).toBe('avatar.avatar.json')
})
})