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,
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('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('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',
'onTouchPanel',
'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 onTouchPanel/)
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 withTouch = assembleModSource("onTouchPanel(robot, 'forwardSwipe', (event) => {})\n")
assert.match(withTouch, /function onTouchPanel\(robot, gesture, handler\)/)
const plain = assembleModSource('robot.ui.showFace()\n')
assert.doesNotMatch(plain, /function onButton|function onImu|function onTouchPanel/)
})
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.removeFirstTouch = onTouchPanel(robot, 'tap', () => { robot.firstTouch += 1 })
robot.removeSecondTouch = onTouchPanel(robot, 'tap', () => { robot.secondTouch += 1 })
`)
const previousCalls = []
const previousButton = () => previousCalls.push('button')
const previousImu = () => previousCalls.push('imu')
const previousTouch = () => previousCalls.push('touch')
const robot = {
firstButton: 0,
secondButton: 0,
firstImu: 0,
secondImu: 0,
firstTouch: 0,
secondTouch: 0,
input: {
button: { a: { onEvent: previousButton } },
imu: { onEvent: previousImu },
touchPanel: { onEvent: previousTouch },
},
}
await evaluateModule(source).onContextCreated(robot)
robot.input.button.a.onEvent({ pressed: true })
robot.input.imu.onEvent({ motion: 'shake' })
robot.input.touchPanel.onEvent({ gesture: 'tap' })
assert.deepEqual(
[robot.firstButton, robot.secondButton, robot.firstImu, robot.secondImu, robot.firstTouch, robot.secondTouch],
[1, 1, 1, 1, 1, 1]
)
assert.deepEqual(previousCalls, ['button', 'imu', 'touch'])
robot.removeFirstButton()
robot.removeFirstImu()
robot.removeFirstTouch()
robot.input.button.a.onEvent({ pressed: true })
robot.input.imu.onEvent({ motion: 'shake' })
robot.input.touchPanel.onEvent({ gesture: 'tap' })
assert.deepEqual(
[robot.firstButton, robot.secondButton, robot.firstImu, robot.secondImu, robot.firstTouch, robot.secondTouch],
[1, 2, 1, 2, 1, 2]
)
robot.removeSecondButton()
robot.removeSecondImu()
robot.removeSecondTouch()
assert.equal(robot.input.button.a.onEvent, previousButton)
assert.equal(robot.input.imu.onEvent, previousImu)
assert.equal(robot.input.touchPanel.onEvent, previousTouch)
})
test('assembleModSource emits valid JavaScript for the new event blocks', () => {
// representative generator output for on_button / on_imu / on_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" +
"onTouchPanel(robot, 'forwardSwipe', (event) => {\n void (async () => {\n robot.ui.toggleDrawer()\n })().catch((error) => trace('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 onTouchPanel/)
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`)
}
})
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('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')
})
f1c988c28This file is pinned to the selected release. Download the original to open it in your design tool.