717 lines
24 KiB
TypeScript
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')
|
|
})
|
|
})
|