Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import assert from 'node:assert/strict'
import test from 'node:test'
import {
addFaceAssetToProject,
applyFaceAssetToSource,
createFaceAsset,
FACE_ASSET_VERSION,
parseFaceAsset,
shapeFaceDefinition,
} from './face-assets.mjs'
test('Shape face asset round-trips with geometry and normalized theme values', () => {
const asset = createFaceAsset({
name: '笑顔',
emotion: 'HAPPY',
primary: '#ABCDEF',
mouth: 2,
shape: {
eyes: { left: { x: 44, y: 38, radius: 11 }, right: { x: 156, y: 40, radius: 9 } },
mouth: { x: 98, y: 90, minWidth: 36, maxWidth: 104 },
},
})
const restored = parseFaceAsset(JSON.stringify(asset))
assert.equal(restored.version, FACE_ASSET_VERSION)
assert.equal(restored.kind, 'shape')
assert.equal(restored.colors.primary, '#abcdef')
assert.equal(restored.mouth, 1)
assert.equal(restored.shape.eyes.left.x, 44)
assert.equal(restored.shape.eyes.left.shape, 'circle')
assert.equal(restored.shape.eyes.left.radius, 11)
assert.equal(restored.shape.eyes.left.eyelidWidth, 22)
assert.equal(restored.shape.eyes.left.eyelidHeight, 22)
assert.equal(restored.shape.mouth.visible, true)
assert.equal(restored.shape.mouth.maxWidth, 104)
})
test('Shape face creation restores defaults for null optional groups', () => {
const asset = createFaceAsset({ colors: null, canvas: null, shape: null })
assert.deepEqual(asset.canvas, { left: 60, top: 60, width: 200, height: 120 })
assert.equal(asset.shape.eyes.left.x, 30)
assert.equal(asset.shape.eyes.left.shape, 'circle')
assert.equal(asset.shape.eyes.left.eyelidWidth, 16)
assert.equal(asset.shape.eyes.left.eyelidHeight, 16)
assert.equal(asset.shape.mouth.visible, true)
assert.equal(asset.shape.mouth.maxWidth, 90)
})
test('eyelids stay centered on and large enough to cover their irises', () => {
const asset = createFaceAsset({
shape: {
eyes: {
left: { x: 1, y: 1, radius: 20, eyelidWidth: 4, eyelidHeight: 6 },
},
},
})
assert.deepEqual(asset.shape.eyes.left, {
x: 20,
y: 20,
shape: 'circle',
radius: 20,
eyelidWidth: 40,
eyelidHeight: 40,
})
})
test('round rect irises and hidden mouths round-trip into generated Face source', () => {
const asset = createFaceAsset({
mouth: 0.7,
shape: {
eyes: {
left: { shape: 'roundRect', x: 42, y: 38, width: 30, height: 18, r: 5 },
right: { shape: 'circle', x: 158, y: 40, radius: 10 },
},
mouth: { visible: false, x: 100, y: 90 },
},
})
const restored = parseFaceAsset(JSON.stringify(asset))
assert.deepEqual(restored.shape.eyes.left, {
x: 42,
y: 38,
shape: 'roundRect',
width: 30,
height: 18,
r: 5,
eyelidWidth: 30,
eyelidHeight: 18,
})
assert.equal(restored.shape.mouth.visible, false)
const definition = shapeFaceDefinition(restored)
assert.match(definition, /shape: 'roundRect', width: 30, height: 18, r: 5/)
assert.doesNotMatch(definition, /new Mouth/)
const source = 'export async function onContextCreated(robot) {\n const runtime = createVisualRuntime(robot)\n}\n'
const result = applyFaceAssetToSource(source, restored)
assert.doesNotMatch(result, /import \{ Mouth \}/)
assert.doesNotMatch(result, /setMouthOpen/)
})
test('legacy circle eyes and mouths without visibility migrate to current defaults', () => {
const legacy = createFaceAsset({ name: '旧Shapeフェイス' })
delete legacy.shape.eyes.left.shape
delete legacy.shape.eyes.right.shape
delete legacy.shape.mouth.visible
const restored = parseFaceAsset(JSON.stringify(legacy))
assert.equal(restored.shape.eyes.left.shape, 'circle')
assert.equal(restored.shape.eyes.right.shape, 'circle')
assert.equal(restored.shape.mouth.visible, true)
})
test('Shape face source creates a real FaceBase implementation', () => {
const definition = shapeFaceDefinition(
createFaceAsset({
canvas: { left: 48, top: 52, width: 220, height: 132 },
shape: {
eyes: { left: { x: 42 }, right: { x: 178 } },
mouth: { x: 110, y: 96 },
},
})
)
assert.match(definition, /FaceBase\.template/)
assert.match(definition, /new Eye\(\{ cx: 42/)
assert.match(definition, /new Eye\(\{ cx: 178/)
assert.match(definition, /new Mouth\(\{ cx: 110, cy: 96/)
})
test('face asset replaces the active Face through the public UI capability', () => {
const source = 'export async function onContextCreated(robot) {\n const runtime = createVisualRuntime(robot)\n}\n'
const result = applyFaceAssetToSource(
source,
createFaceAsset({
emotion: 'SAD',
secondary: '#010203',
shape: { eyes: { left: { x: 45 }, right: { x: 155 } } },
})
)
assert.match(result, /import \{ FaceBase \} from 'behaviors\/face'/)
assert.match(result, /import \{ Eye \} from 'parts\/eye'/)
assert.match(result, /import \{ Mouth \} from 'parts\/mouth'/)
assert.match(result, /const _StackchanVisualShapeFace = FaceBase\.template/)
assert.match(result, /robot\.ui\.setFace\(new _StackchanVisualShapeFace\(\{\}\)\)/)
assert.match(result, /Emotion\.SAD/)
assert.match(result, /setColor\('secondary', 1, 2, 3\)/)
assert.match(result, /setMouthOpen\(0\)/)
})
test('external Shape assets reject unknown fields and invalid geometry', () => {
const valid = createFaceAsset({ name: '検証', emotion: 'HAPPY' })
for (const invalid of [
{
format: 'tech.stackchan.face',
version: 1,
name: '廃止した簡易形式',
emotion: 'HAPPY',
colors: { primary: '#ffffff', secondary: '#202020' },
mouth: 0.4,
},
{ ...valid, extra: true },
{ ...valid, name: '' },
{ ...valid, emotion: 'UNKNOWN' },
{ ...valid, colors: { ...valid.colors, primary: 'red' } },
{ ...valid, mouth: 2 },
{ ...valid, canvas: { ...valid.canvas, left: 300 } },
{
...valid,
shape: {
...valid.shape,
eyes: { ...valid.shape.eyes, left: { ...valid.shape.eyes.left, x: valid.canvas.width + 1 } },
},
},
{
...valid,
shape: {
...valid.shape,
eyes: {
...valid.shape.eyes,
left: { ...valid.shape.eyes.left, eyelidWidth: valid.shape.eyes.left.radius * 2 - 1 },
},
},
},
{
...valid,
shape: {
...valid.shape,
eyes: {
...valid.shape.eyes,
left: {
x: 30,
y: 33,
shape: 'roundRect',
width: 20,
height: 12,
r: 7,
eyelidWidth: 20,
eyelidHeight: 12,
},
},
},
},
{
...valid,
shape: {
...valid.shape,
mouth: { ...valid.shape.mouth, minWidth: 100, maxWidth: 20 },
},
},
{
...valid,
shape: {
...valid.shape,
mouth: { ...valid.shape.mouth, visible: 'yes' },
},
},
]) {
assert.throws(() => parseFaceAsset(JSON.stringify(invalid)), /顔アセット|Shape顔/)
}
})
test('staging a Shape face leaves the current project unchanged until persistence succeeds', () => {
const current = {
assets: [],
settings: { educationalProfile: true, embedAssets: true, faceAsset: null },
}
const next = addFaceAssetToProject(current, createFaceAsset({ name: '追加の顔', emotion: 'HAPPY' }))
assert.deepEqual(current.assets, [])
assert.equal(current.settings.faceAsset, null)
assert.notEqual(next.assets, current.assets)
assert.notEqual(next.settings, current.settings)
assert.equal(next.settings.faceAsset, 'assets/追加の顔.stackchan-face.json')
assert.equal(JSON.parse(next.assets[0].data).kind, 'shape')
})
test('editing a staged Shape face replaces its stable asset path even when its name changes', () => {
const original = addFaceAssetToProject(
{ assets: [], settings: { educationalProfile: true, embedAssets: true, faceAsset: null } },
createFaceAsset({ name: '元の顔', emotion: 'NEUTRAL' })
)
const originalPath = original.settings.faceAsset
const edited = addFaceAssetToProject(original, createFaceAsset({ name: '変更後の顔', emotion: 'HOT' }), {
replacePath: originalPath,
})
assert.equal(edited.assets.length, 1)
assert.equal(edited.assets[0].path, originalPath)
assert.equal(edited.settings.faceAsset, originalPath)
assert.equal(JSON.parse(edited.assets[0].data).name, '変更後の顔')
assert.equal(JSON.parse(edited.assets[0].data).emotion, 'HOT')
assert.throws(
() =>
addFaceAssetToProject(original, createFaceAsset({ name: '不明な顔' }), {
replacePath: 'assets/missing.stackchan-face.json',
}),
/更新対象/
)
})
126616f26This file is pinned to the selected release. Download the original to open it in your design tool.