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 {
assembleModSource,
COLOR_OPTIONS,
EMOTION_OPTIONS,
escapeSingleQuoted,
NOTE_OPTIONS,
registerStackchanBlocks,
registerAsyncProcedureGenerators,
SINGING_NOTE_OPTIONS,
singingMoraToKoe,
singingScoreToKoe,
TOOLBOX,
} from './blocks.mjs'
function evaluateModule(source, parameters = {}) {
const names = Object.keys(parameters)
const values = Object.values(parameters)
const executable = source.replace(/^import .*$/gm, '').replace('export async function', 'async function')
return new Function(...names, `${executable}\nreturn { onContextCreated }`)(...values)
}
test('escapeSingleQuoted keeps a field_input value a valid single-quoted literal', () => {
assert.equal(escapeSingleQuoted('primary'), 'primary')
assert.equal(escapeSingleQuoted("a'b"), "a\\'b")
assert.equal(escapeSingleQuoted('a\\b'), 'a\\\\b')
// the escaped value must parse as the original string inside single quotes
for (const raw of ['primary', "a'b", 'a\\b', "x'\\y"]) {
// eslint-disable-next-line no-eval
assert.equal(eval(`'${escapeSingleQuoted(raw)}'`), raw)
}
})
test('singingMoraToKoe converts hiragana, katakana, yoon, moraic n, and long vowels', () => {
assert.equal(singingMoraToKoe('き'), 'ki')
assert.equal(singingMoraToKoe('キャ'), 'kya')
assert.equal(singingMoraToKoe('デャ'), 'dya')
assert.equal(singingMoraToKoe('ウォ'), 'o')
assert.equal(singingMoraToKoe('ヰ'), 'i')
assert.equal(singingMoraToKoe('ん'), 'n')
assert.equal(singingMoraToKoe('ー', 'ko'), 'o')
assert.throws(() => singingMoraToKoe('ー'), /前には母音/)
assert.throws(() => singingMoraToKoe('きら'), /かな1モーラ/)
assert.throws(() => singingMoraToKoe('っ'), /かな1モーラ/)
})
test('singingScoreToKoe converts tempo and note triples into exact koe notation', () => {
assert.equal(
singingScoreToKoe(120, [
['C4', 1, 'き'],
['C+4', 0.5, 'ラ'],
['G4', 2, 'ー'],
['R', 0.5, ''],
]),
'#C4,500ki#C+4,250ra#G4,1000a#R,250'
)
assert.throws(() => singingScoreToKoe(120, []), /音符または休符/)
assert.throws(() => singingScoreToKoe(120, [['C4', 1]]), /3項目/)
assert.throws(() => singingScoreToKoe(120, [['R', 1, 'ら']]), /休符には歌詞/)
assert.throws(() => singingScoreToKoe(120, [['H4', 1, 'ら']]), /歌唱音符/)
assert.throws(() => singingScoreToKoe(20, [['C4', 16, 'ら']]), /20〜8000/)
})
test('list-based singing blocks generate one score helper call from note triples', () => {
const generator = {
forBlock: {},
valueToCode: (_block, name) => (name === 'SCORE' ? "[['C4', 1, 'き'], ['R', 0.5, '']]" : ''),
}
registerStackchanBlocks({ defineBlocksWithJsonArray() {} }, generator, {
NONE: 99,
ATOMIC: 0,
FUNCTION_CALL: 1,
AWAIT: 2,
})
const fieldBlock = (fields) => ({ getFieldValue: (name) => fields[name] })
assert.equal(
generator.forBlock.stackchan_sing_score(fieldBlock({ BPM: 120 }), generator),
"await singScore(robot, 120, [['C4', 1, 'き'], ['R', 0.5, '']])\n"
)
assert.deepEqual(generator.forBlock.stackchan_song_note_tuple(fieldBlock({ NOTE: 'C+4', BEATS: 0.5, LYRIC: 'ラ' })), [
"['C+4', 0.5, 'ラ']",
0,
])
assert.deepEqual(generator.forBlock.stackchan_song_rest_tuple(fieldBlock({ BEATS: 2 })), ["['R', 2, '']", 0])
})
test('generated singScore helper calls robot.audio.sing and surfaces provider failures', async () => {
const source = assembleModSource("await singScore(robot, 120, [['C4', 1, 'き'], ['R', 0.5, '']])")
assert.match(source, /function singingScoreToKoe/)
let received = ''
const robot = {
audio: {
sing: async (koe) => {
received = koe
return { success: true, value: koe }
},
},
}
await evaluateModule(source).onContextCreated(robot)
assert.equal(received, '#C4,500ki#R,250')
const unavailable = { audio: { sing: async () => ({ success: false, reason: 'singing unavailable' }) } }
await assert.rejects(evaluateModule(source).onContextCreated(unavailable), /singing unavailable/)
})
test('singing blocks compile a typed score into tempo-exact koe notation', () => {
const generator = { forBlock: {} }
registerStackchanBlocks({ defineBlocksWithJsonArray() {} }, generator, {
NONE: 0,
FUNCTION_CALL: 1,
AWAIT: 2,
})
const event = (type, fields, next = null) => ({
type,
getFieldValue: (name) => fields[name],
getNextBlock: () => next,
})
const rest = event('stackchan_song_rest', { BEATS: 0.5 })
const longVowel = event('stackchan_song_note', { NOTE: 'G4', BEATS: 2, LYRIC: 'ー' }, rest)
const second = event('stackchan_song_note', { NOTE: 'C+4', BEATS: 0.5, LYRIC: 'ラ' }, longVowel)
const first = event('stackchan_song_note', { NOTE: 'C4', BEATS: 1, LYRIC: 'き' }, second)
const song = {
getFieldValue: (name) => (name === 'BPM' ? 120 : undefined),
getInputTargetBlock: (name) => (name === 'SCORE' ? first : null),
}
assert.equal(
generator.forBlock.stackchan_sing(song),
"await robot.audio.sing('#C4,500ki#C+4,250ra#G4,1000a#R,250')\n"
)
const legacyParent = { type: 'stackchan_sing' }
assert.equal(generator.forBlock.stackchan_song_note({ getParent: () => legacyParent }), '')
assert.equal(generator.forBlock.stackchan_song_rest({ getParent: () => legacyParent }), '')
assert.throws(
() => generator.forBlock.stackchan_song_note({ getParent: () => null }),
/旧形式の音符・休符ブロックは直接実行できません/
)
})
test('singing block generator rejects an empty or out-of-range score', () => {
const generator = { forBlock: {} }
registerStackchanBlocks({ defineBlocksWithJsonArray() {} }, generator, {
NONE: 0,
FUNCTION_CALL: 1,
AWAIT: 2,
})
const emptySong = {
getFieldValue: () => 120,
getInputTargetBlock: () => null,
}
const longNote = {
type: 'stackchan_song_note',
getFieldValue: (name) => ({ NOTE: 'C4', BEATS: 16, LYRIC: 'あ' })[name],
getNextBlock: () => null,
}
const slowSong = {
getFieldValue: () => 20,
getInputTargetBlock: () => longNote,
}
assert.throws(() => generator.forBlock.stackchan_sing(emptySong), /音符または休符/)
assert.throws(() => generator.forBlock.stackchan_sing(slowSong), /20〜8000/)
})
test('education blocks do not expose arbitrary JavaScript input', () => {
let definitions = []
registerStackchanBlocks(
{
defineBlocksWithJsonArray(value) {
definitions = value
},
},
{ forBlock: {} },
{ NONE: 0, FUNCTION_CALL: 1, AWAIT: 2 }
)
assert.equal(definitions.length > 0, true)
assert.deepEqual(
definitions
.filter((definition) => /(?:javascript|raw[_-]?js|eval)/i.test(definition.type))
.map((definition) => definition.type),
[]
)
assert.deepEqual(
definitions.flatMap((definition) =>
(definition.args0 ?? [])
.filter(
(argument) =>
argument.type === 'field_multilinetext' || /^(?:CODE|SOURCE|JAVASCRIPT|RAW_JS)$/i.test(argument.name ?? '')
)
.map((argument) => `${definition.type}:${argument.name ?? argument.type}`)
),
[]
)
})
test('head touch block exposes head sensor gestures and generates a petting handler', () => {
let definitions = []
const generator = { forBlock: {}, statementToCode: () => '' }
registerStackchanBlocks(
{
defineBlocksWithJsonArray(value) {
definitions = value
},
},
generator,
{ NONE: 0, FUNCTION_CALL: 1, AWAIT: 2 }
)
const definition = definitions.find(({ type }) => type === 'stackchan_on_head_touch')
assert.equal(definition.message0, '頭部タッチセンサが %1 とき %2 %3')
assert.equal(definition.tooltip, '頭上の静電容量式タッチセンサを操作したときに実行します')
assert.deepEqual(definition.args0[0].options, [
['タッチされた', 'press'],
['はなされた', 'release'],
['前方へスワイプされた', 'forwardSwipe'],
['後方へスワイプされた', 'backwardSwipe'],
['なでられた', 'petting'],
])
const code = generator.forBlock.stackchan_on_head_touch(
{ id: 'head-touch-id', getFieldValue: () => 'petting' },
generator
)
assert.match(code, /^runtime\.add\(onHeadTouch\(robot, 'petting',/)
assert.match(code, /reportVisualError\('VP_RUNTIME_HANDLER', 'head-touch-id', error, 'head touch'\)/)
})
test('generator reserves every identifier injected into the visual runtime scope', () => {
let reservedWords = ''
const generator = {
forBlock: {},
addReservedWords(words) {
reservedWords = words
},
}
registerStackchanBlocks({ defineBlocksWithJsonArray() {} }, generator, { NONE: 0, FUNCTION_CALL: 1, AWAIT: 2 })
assert.equal(generator.INFINITE_LOOP_TRAP, 'visualLoopGuard(%1);\n')
const reserved = new Set(reservedWords.split(','))
for (const name of [
'runtime',
'visualLoopGuard',
'createVisualRuntime',
'createVisualLoopGuard',
'reportVisualError',
'onButton',
'onImu',
'onHeadTouch',
'event',
]) {
assert.equal(reserved.has(name), true, `${name} must be reserved`)
}
})
test('assembleModSource wraps the body in onContextCreated', () => {
const source = assembleModSource('robot.face.setEmotion(Emotion.HAPPY)\n')
assert.match(source, /export async function onContextCreated\(robot\) \{/)
assert.match(source, /^ robot\.face\.setEmotion\(Emotion\.HAPPY\)$/m)
assert.match(source, /import \{ Emotion \} from 'face-state'/)
assert.match(source, /function createVisualLoopGuard\(\)/)
assert.match(source, /return function visualLoopGuard\(blockId\)/)
})
test('assembleModSource only imports what the body uses', () => {
const plain = assembleModSource('robot.ui.hideBalloon()\n')
assert.doesNotMatch(plain, /import/)
const withTimer = assembleModSource('Timer.repeat(() => {}, 1000)\n')
assert.match(withTimer, /import Timer from 'timer'/)
assert.doesNotMatch(withTimer, /stackchan-util/)
const withUtils = assembleModSource('await wait(randomBetween(1, 2))\n')
assert.match(withUtils, /import \{ randomBetween, wait \} from 'stackchan-util'/)
})
test('assembleModSource includes the hexToRgb helper when used', () => {
const source = assembleModSource("robot.face.setColor('primary', ...hexToRgb('#ff0000'))\n")
assert.match(source, /function hexToRgb\(hex\)/)
const helperIndex = source.indexOf('function hexToRgb')
const bodyIndex = source.indexOf('export async function onContextCreated')
assert.ok(helperIndex < bodyIndex, 'helper must be defined before the hook')
})
test('assembleModSource emits valid JavaScript', async () => {
const source = assembleModSource(
"robot.face.setColor('primary', ...hexToRgb('#30e0ff'))\n" +
'Timer.repeat(() => {\n' +
" void (async () => {\n await wait(randomBetween(100, 200))\n robot.face.setEmotion(Emotion.HAPPY)\n })().catch((error) => trace('x\\n'))\n" +
'}, 1000)\n'
)
// module-level syntax check: rewrite imports so plain Function() can parse it
const stripped = source.replace(/^import .*$/gm, '').replace('export async function', 'async function')
assert.doesNotThrow(() => new Function(stripped))
})
test('generated runtime disposes old input handlers before reloading on the same robot', async () => {
const source = assembleModSource(
"runtime.add(onButton(robot, 'a', 'press', () => { robot.pressCount = (robot.pressCount ?? 0) + 1 }))\n"
)
const { onContextCreated } = evaluateModule(source)
const original = () => {}
const robot = { input: { button: { a: { onEvent: original } } } }
await onContextCreated(robot)
const firstDispatcher = robot.input.button.a.onEvent
firstDispatcher({ pressed: true })
assert.equal(robot.pressCount, 1)
await onContextCreated(robot)
const secondDispatcher = robot.input.button.a.onEvent
assert.notEqual(secondDispatcher, firstDispatcher)
secondDispatcher({ pressed: true })
assert.equal(robot.pressCount, 2, 'only the reloaded handler must run')
robot.__visualProgram.dispose()
assert.equal(robot.input.button.a.onEvent, original)
assert.equal(robot.input.button.a.__visualWired, false)
})
test('generated runtime clears timers on reload and enforces the loop budget', async () => {
const active = new Set()
let nextTimer = 0
const Timer = {
repeat() {
const token = ++nextTimer
active.add(token)
return token
},
clear(token) {
active.delete(token)
},
}
const timerSource = assembleModSource('runtime.addTimer(Timer.repeat(() => {}, 1000))\n').replace(
"import Timer from 'timer'",
''
)
const { onContextCreated } = evaluateModule(timerSource, { Timer })
const robot = {}
await onContextCreated(robot)
assert.equal(active.size, 1)
await onContextCreated(robot)
assert.deepEqual([...active], [2], 'reload must clear the first timer')
robot.__visualProgram.dispose()
assert.equal(active.size, 0)
const loopSource = assembleModSource('for (let index = 0; index < 10001; index += 1) visualLoopGuard()\n')
await assert.rejects(evaluateModule(loopSource).onContextCreated({}), /ループの実行上限/)
})
test('overlapping event handlers keep independent loop budgets', async () => {
let releaseFirst
const firstGate = new Promise((resolve) => {
releaseFirst = resolve
})
const generator = {
forBlock: {},
statementToCode: (block) =>
block.id === 'first-event'
? "for (let index = 0; index < 9999; index += 1) visualLoopGuard('first-loop')\nawait robot.firstGate\nvisualLoopGuard('first-loop')\n"
: '',
}
registerStackchanBlocks({ defineBlocksWithJsonArray() {} }, generator, { NONE: 0, FUNCTION_CALL: 1, AWAIT: 2 })
const eventBlock = (id, button) => ({
id,
getFieldValue(name) {
return name === 'BUTTON' ? button : 'press'
},
})
const source = assembleModSource(
generator.forBlock.stackchan_on_button(eventBlock('first-event', 'a'), generator) +
generator.forBlock.stackchan_on_button(eventBlock('second-event', 'b'), generator)
)
const traces = []
const robot = { firstGate, input: { button: { a: {}, b: {} } } }
await evaluateModule(source, { trace: (line) => traces.push(line) }).onContextCreated(robot)
robot.input.button.a.onEvent({ pressed: true })
robot.input.button.b.onEvent({ pressed: true })
releaseFirst()
await new Promise((resolve) => setTimeout(resolve, 0))
assert.equal(traces.length, 1)
assert.equal(JSON.parse(traces[0].replace(/^#stackchan /, '')).block_id, 'first-loop')
})
test('an event handler reports the exact loop block that exhausted its budget', async () => {
const generator = {
forBlock: {},
statementToCode: () => "for (let index = 0; index < 10001; index += 1) visualLoopGuard('loop-id')\n",
}
registerStackchanBlocks({ defineBlocksWithJsonArray() {} }, generator, { NONE: 0, FUNCTION_CALL: 1, AWAIT: 2 })
const eventBlock = {
id: 'event-id',
getFieldValue(name) {
return name === 'BUTTON' ? 'a' : 'press'
},
}
const source = assembleModSource(generator.forBlock.stackchan_on_button(eventBlock, generator))
const traces = []
const robot = { input: { button: { a: {} } } }
await evaluateModule(source, { trace: (line) => traces.push(line) }).onContextCreated(robot)
robot.input.button.a.onEvent({ pressed: true })
await new Promise((resolve) => setTimeout(resolve, 0))
assert.equal(traces.length, 1)
const diagnostic = JSON.parse(traces[0].replace(/^#stackchan /, ''))
assert.equal(diagnostic.error_code, 'VP_RUNTIME_HANDLER')
assert.equal(diagnostic.block_id, 'loop-id')
assert.match(diagnostic.message, /^button a: Error: ループの実行上限/)
})
test('procedures inherit a fresh loop budget from each event invocation', async () => {
const generator = {
forBlock: {},
definitions_: {},
INDENT: ' ',
addReservedWords() {},
getProcedureName: (name) => name,
getVariableName: (name) => name,
injectId: (template, block) => template.replace('%1', `'${block.id}'`),
prefixLines: (text, prefix) => text.replace(/^(?!$)/gm, prefix),
statementToCode: (block) => {
if (block.id === 'procedure-definition') {
return " for (let index = 0; index < 9998; index += 1) visualLoopGuard('procedure-loop')\n"
}
return generator.forBlock.procedures_callnoreturn(
{
getFieldValue: () => 'guardedProcedure',
getVars: () => [],
},
generator
)
},
valueToCode: () => '',
scrub_: (_block, code) => code,
}
registerStackchanBlocks({ defineBlocksWithJsonArray() {} }, generator, { NONE: 0, FUNCTION_CALL: 1, AWAIT: 2 })
const definition = {
id: 'procedure-definition',
getFieldValue: () => 'guardedProcedure',
getInput: (name) => name === 'STACK',
getVars: () => [],
}
generator.forBlock.procedures_defnoreturn(definition, generator)
assert.match(generator.definitions_['%guardedProcedure'], /^async function guardedProcedure\(visualLoopGuard\)/)
const eventBlock = {
id: 'event-id',
getFieldValue(name) {
return name === 'BUTTON' ? 'a' : 'press'
},
}
const body =
`${generator.definitions_['%guardedProcedure']}\n` + generator.forBlock.stackchan_on_button(eventBlock, generator)
const source = assembleModSource(body)
const traces = []
const robot = { input: { button: { a: {} } } }
await evaluateModule(source, { trace: (line) => traces.push(line) }).onContextCreated(robot)
robot.input.button.a.onEvent({ pressed: true })
await new Promise((resolve) => setTimeout(resolve, 0))
robot.input.button.a.onEvent({ pressed: true })
await new Promise((resolve) => setTimeout(resolve, 0))
assert.deepEqual(traces, [])
})
test('assembleModSource injects event dispatch helpers only when used', () => {
const withButton = assembleModSource("onButton(robot, 'a', 'press', (event) => {})\n")
assert.match(withButton, /function onButton\(robot, name, edge, handler\)/)
assert.doesNotMatch(withButton, /function onImu/)
assert.doesNotMatch(withButton, /function onHeadTouch/)
const withImu = assembleModSource("onImu(robot, 'shake', (event) => {})\n")
assert.match(withImu, /function onImu\(robot, motion, handler\)/)
assert.match(withImu, /imu\.start\?\.\(\)/) // IMU must be started
const withHeadTouch = assembleModSource("onHeadTouch(robot, 'forwardSwipe', (event) => {})\n")
assert.match(withHeadTouch, /function onHeadTouch\(robot, gesture, handler\)/)
const plain = assembleModSource('robot.ui.showFace()\n')
assert.doesNotMatch(plain, /function onButton|function onImu|function onHeadTouch/)
})
test('event dispatch helpers preserve duplicate handlers and remove only their own callback', async () => {
const source = assembleModSource(`
robot.removeFirstButton = onButton(robot, 'a', 'press', () => { robot.firstButton += 1 })
robot.removeSecondButton = onButton(robot, 'a', 'press', () => { robot.secondButton += 1 })
robot.removeFirstImu = onImu(robot, 'shake', () => { robot.firstImu += 1 })
robot.removeSecondImu = onImu(robot, 'shake', () => { robot.secondImu += 1 })
robot.removeFirstHeadTouch = onHeadTouch(robot, 'press', () => { robot.firstHeadTouch += 1 })
robot.removeSecondHeadTouch = onHeadTouch(robot, 'press', () => { robot.secondHeadTouch += 1 })
`)
const previousCalls = []
const previousButton = () => previousCalls.push('button')
const previousImu = () => previousCalls.push('imu')
const previousHeadTouch = () => previousCalls.push('headTouch')
const robot = {
firstButton: 0,
secondButton: 0,
firstImu: 0,
secondImu: 0,
firstHeadTouch: 0,
secondHeadTouch: 0,
input: {
button: { a: { onEvent: previousButton } },
imu: { onEvent: previousImu },
touchPanel: { onEvent: previousHeadTouch },
},
}
await evaluateModule(source).onContextCreated(robot)
robot.input.button.a.onEvent({ pressed: true })
robot.input.imu.onEvent({ motion: 'shake' })
robot.input.touchPanel.onEvent({ gesture: 'press', ticks: 100 })
assert.deepEqual(
[
robot.firstButton,
robot.secondButton,
robot.firstImu,
robot.secondImu,
robot.firstHeadTouch,
robot.secondHeadTouch,
],
[1, 1, 1, 1, 1, 1]
)
assert.deepEqual(previousCalls, ['button', 'imu', 'headTouch'])
robot.removeFirstButton()
robot.removeFirstImu()
robot.removeFirstHeadTouch()
robot.input.button.a.onEvent({ pressed: true })
robot.input.imu.onEvent({ motion: 'shake' })
robot.input.touchPanel.onEvent({ gesture: 'press', ticks: 200 })
assert.deepEqual(
[
robot.firstButton,
robot.secondButton,
robot.firstImu,
robot.secondImu,
robot.firstHeadTouch,
robot.secondHeadTouch,
],
[1, 2, 1, 2, 1, 2]
)
robot.removeSecondButton()
robot.removeSecondImu()
robot.removeSecondHeadTouch()
assert.equal(robot.input.button.a.onEvent, previousButton)
assert.equal(robot.input.imu.onEvent, previousImu)
assert.equal(robot.input.touchPanel.onEvent, previousHeadTouch)
})
test('head touch helper recognizes petting in both directions within 1.5 seconds', async () => {
const source = assembleModSource(
"runtime.add(onHeadTouch(robot, 'petting', (event) => { robot.pettingEvents.push(event) }))\n"
)
const previousEvents = []
const previous = (event) => previousEvents.push(event)
const touchPanel = { onEvent: previous }
const robot = { pettingEvents: [], input: { touchPanel } }
await evaluateModule(source).onContextCreated(robot)
const emit = (gesture, ticks) => touchPanel.onEvent({ gesture, position: 0, intensity: 3, ticks })
emit('forwardSwipe', 100)
emit('backwardSwipe', 1600)
emit('backwardSwipe', 1700)
emit('forwardSwipe', 3600)
emit('backwardSwipe', 3700)
emit('backwardSwipe', 5600)
emit('forwardSwipe', 5700)
emit('forwardSwipe', 7600)
emit('forwardSwipe', 7700)
emit('backwardSwipe', 9600)
emit('forwardSwipe', 11200)
assert.deepEqual(
robot.pettingEvents.map(({ gesture, ticks }) => [gesture, ticks]),
[
['petting', 1600],
['petting', 3700],
['petting', 5700],
]
)
assert.deepEqual(
previousEvents.map(({ gesture, ticks }) => [gesture, ticks]),
[
['forwardSwipe', 100],
['backwardSwipe', 1600],
['backwardSwipe', 1700],
['forwardSwipe', 3600],
['backwardSwipe', 3700],
['backwardSwipe', 5600],
['forwardSwipe', 5700],
['forwardSwipe', 7600],
['forwardSwipe', 7700],
['backwardSwipe', 9600],
['forwardSwipe', 11200],
]
)
robot.__visualProgram.dispose()
assert.equal(touchPanel.onEvent, previous)
assert.equal(touchPanel.__visualLastForwardSwipeTicks, undefined)
assert.equal(touchPanel.__visualLastBackwardSwipeTicks, undefined)
})
test('assembleModSource emits valid JavaScript for the new event blocks', () => {
// representative generator output for on_button / on_imu / on_head_touch /
// on_drawer_button / set_pose / light_blink / drawer_control
const body =
"onButton(robot, 'a', 'release', (event) => {\n void (async () => {\n robot.face.setEmotion(Emotion.HAPPY)\n })().catch((error) => trace('button a handler failed: ' + error + '\\n'))\n})\n" +
"onImu(robot, 'shake', (event) => {\n void (async () => {\n await robot.audio.say(String('わっ'))\n })().catch((error) => trace('imu handler failed: ' + error + '\\n'))\n})\n" +
"onHeadTouch(robot, 'petting', (event) => {\n void (async () => {\n robot.ui.toggleDrawer()\n })().catch((error) => trace('head touch handler failed: ' + error + '\\n'))\n})\n" +
"robot.ui.drawer?.addDrawerButton({ key: 'k', label: 'ボタン', callback: () => {\n void (async () => {\n robot.ui.showFace()\n })().catch((error) => trace('drawer handler failed: ' + error + '\\n'))\n} })\n" +
'await robot.motion.setPose({ rotation: { p: (30 * Math.PI) / 180, y: (-45 * Math.PI) / 180, r: 0 } }, 0.5)\n' +
"robot.lighting.lightBlink('a', ...hexToRgb('#ff4040'), 250)\n"
const source = assembleModSource(body)
assert.match(source, /function onButton/)
assert.match(source, /function onImu/)
assert.match(source, /function onHeadTouch/)
assert.match(source, /function hexToRgb/)
const stripped = source.replace(/^import .*$/gm, '').replace('export async function', 'async function')
assert.doesNotThrow(() => new Function(stripped))
})
test('block option tables are well-formed', () => {
for (const [label, value] of EMOTION_OPTIONS) {
assert.ok(label.length > 0)
assert.match(value, /^[A-Z]+$/)
}
for (const [label, value] of COLOR_OPTIONS) {
assert.ok(label.length > 0)
assert.match(value, /^#[0-9a-f]{6}$/)
}
for (const [label, value] of NOTE_OPTIONS) {
assert.ok(label.length > 0)
const hz = Number(value)
assert.ok(Number.isInteger(hz) && hz >= 20 && hz <= 20000, `note ${label} has a valid frequency`)
}
for (const [label, value] of SINGING_NOTE_OPTIONS) {
assert.ok(label.length > 0)
assert.match(value, /^[A-G](?:[+-])?[0-8]$/)
}
})
test('toolbox categories do not repeat an identical block entry', () => {
for (const category of TOOLBOX.contents) {
if (!Array.isArray(category.contents)) continue
const entries = category.contents.filter((entry) => entry.kind === 'block').map((entry) => JSON.stringify(entry))
assert.equal(new Set(entries).size, entries.length, `${category.name} contains a duplicate block entry`)
}
})
test('singing toolbox starts with a list of note triples and hides legacy statement-score blocks', () => {
const speech = TOOLBOX.contents.find((category) => category.name === 'おしゃべり')
const types = speech.contents.filter((entry) => entry.kind === 'block').map((entry) => entry.type)
assert.ok(types.includes('stackchan_sing_score'))
assert.ok(types.includes('stackchan_song_note_tuple'))
assert.ok(types.includes('stackchan_song_rest_tuple'))
assert.equal(types.includes('stackchan_sing'), false)
assert.equal(types.includes('stackchan_song_note'), false)
assert.equal(types.includes('stackchan_song_rest'), false)
const sing = speech.contents.find((entry) => entry.type === 'stackchan_sing_score')
assert.equal(sing.inputs.SCORE.block.type, 'lists_create_with')
assert.equal(sing.inputs.SCORE.block.extraState.itemCount, 4)
})
test('procedure generators are async so speech and wait blocks remain valid inside functions', () => {
const generator = {
forBlock: {},
definitions_: {},
INDENT: ' ',
INFINITE_LOOP_TRAP: '',
getProcedureName: (name) => name,
getVariableName: (name) => name,
statementToCode: () => " await robot.audio.say('やあ')\n",
valueToCode: () => '42',
scrub_: (_block, code) => code,
}
registerAsyncProcedureGenerators(generator, { NONE: 99, AWAIT: 4, FUNCTION_CALL: 2 })
const definition = {
getFieldValue: () => 'greet',
getInput: (name) => name === 'STACK' || name === 'RETURN',
getVars: () => ['count'],
}
generator.forBlock.procedures_defreturn(definition, generator)
assert.match(generator.definitions_['%greet'], /^async function greet\(visualLoopGuard, count\)/)
assert.match(generator.definitions_['%greet'], /await robot\.audio\.say/)
const call = {
getFieldValue: () => 'greet',
getVars: () => ['count'],
}
assert.deepEqual(generator.forBlock.procedures_callreturn(call, generator), ['await greet(visualLoopGuard, 42)', 4])
assert.equal(generator.forBlock.procedures_callnoreturn(call, generator), 'await greet(visualLoopGuard, 42);\n')
})
e777de3cbThis file is pinned to the selected release. Download the original to open it in your design tool.