// Headless test: verify custom function blocks generate JS correctly.
const Blockly = require('blockly');
const { javascriptGenerator, Order } = require('blockly/javascript');
const FN_COLOR = '#E91E63';
const OVAL_COLOR = '#FF8C1A';
function escXml(s) {
return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
// --- Simulated app blocks (statement blocks return strings; value blocks tuples) ---
javascriptGenerator.forBlock['rb_js_set_var'] = function (block) {
const name = block.getFieldValue('name') || 'myVar';
let value = '';
const t = block.getInputTargetBlock('value');
if (t) {
const tuple = javascriptGenerator.blockToCode(t, Order.ATOMIC);
value = Array.isArray(tuple) ? tuple[0] : tuple || '';
}
const code = `myVar${''} = ${value || 'undefined'};`;
return block.outputConnection ? [code, Order.ATOMIC] : code; // statement -> string
};
javascriptGenerator.forBlock['rb_js_const_number'] = function (block) {
return [String(block.getFieldValue('value') ?? 0), Order.ATOMIC];
};
javascriptGenerator.forBlock['rb_js_console'] = function (block) {
let value = '';
const t = block.getInputTargetBlock('value');
if (t) {
const tuple = javascriptGenerator.blockToCode(t, Order.ATOMIC);
value = Array.isArray(tuple) ? tuple[0] : tuple || '';
}
const code = `console.log(${value || "''"});`;
return block.outputConnection ? [code, Order.ATOMIC] : code;
};
// --- Minimal app block definitions ---
Blockly.Blocks['rb_js_set_var'] = {
init() {
this.appendDummyInput().appendField('set var').appendField('myVar', 'name');
this.appendValueInput('value');
this.setPreviousStatement(true);
this.setNextStatement(true);
},
};
Blockly.Blocks['rb_js_const_number'] = {
init() {
this.appendDummyInput().appendField('value').appendField(new Blockly.FieldNumber(0), 'value');
this.setOutput(true);
},
};
Blockly.Blocks['rb_js_console'] = {
init() {
this.appendDummyInput().appendField('print');
this.appendValueInput('value');
this.setPreviousStatement(true);
this.setNextStatement(true);
},
};
// --- Register the custom function block types (mirrors customFunctionBlocks.ts) ---
function parseFnData(block) {
try {
const d = JSON.parse(String(block.data || '{}'));
if (d && typeof d === 'object' && typeof d.name === 'string') return d;
} catch {}
return null;
}
function paramSegsOf(segments) {
return (segments || []).filter((s) => s.kind === 'param');
}
function encodeName(name) {
return String(name).replace(/%/g, '\\u0025').replace(/\s+/g, '_').replace(/\W/g, (ch) => {
return '%' + ch.codePointAt(0).toString(16).toUpperCase();
});
}
function fnParamNames(data) {
return paramSegsOf(data.segments).map((s) => encodeName(s.name));
}
Blockly.Blocks['rb_custom_fn'] = {
init() {
this.setColour(FN_COLOR);
this.appendDummyInput();
this.setInputsInline(true);
},
domToMutation(m) {
try {
const data = {
name: m.getAttribute('name') || '',
segments: JSON.parse(m.getAttribute('segments') || '[]'),
asyncFn: m.getAttribute('async') === 'true',
returns: m.getAttribute('returns') === 'true',
};
this.data = JSON.stringify(data);
// rebuild layout: define + param value inputs + body statement
for (const input of [...this.inputList]) this.removeInput(input.name);
const row = this.appendDummyInput();
row.appendField('define');
row.appendField(data.name, 'name');
let pendingText = '';
let idx = 0;
for (const seg of data.segments) {
if (seg.kind === 'text') pendingText += seg.text;
else {
const vi = this.appendValueInput('PARAM' + idx);
if (pendingText) vi.appendField(pendingText);
pendingText = '';
if (seg.type === 'boolean') vi.setCheck('Boolean');
idx++;
}
}
const props = this.appendDummyInput();
if (pendingText) props.appendField(pendingText);
props.appendField(data.asyncFn ? 'TRUE' : 'FALSE', 'asyncFn');
props.appendField('async');
props.appendField(data.returns ? 'TRUE' : 'FALSE', 'returns');
props.appendField('return value');
this.appendStatementInput('body');
this.setInputsInline(true);
} catch (e) {
throw e;
}
},
mutationToDom() {
const m = Blockly.utils.xml.createElement('mutation');
const d = parseFnData(this) || { name: '', segments: [] };
m.setAttribute('name', String(d.name || ''));
m.setAttribute('segments', JSON.stringify(d.segments || []));
m.setAttribute('async', d.asyncFn ? 'true' : 'false');
m.setAttribute('returns', d.returns ? 'true' : 'false');
return m;
},
};
Blockly.Blocks['rb_custom_call'] = {
init() {
this.setColour(FN_COLOR);
this.setPreviousStatement(true);
this.setNextStatement(true);
this.appendDummyInput();
this.setInputsInline(true);
},
domToMutation(m) {
const data = {
name: m.getAttribute('name') || '',
segments: JSON.parse(m.getAttribute('segments') || '[]'),
returns: m.getAttribute('returns') === 'true',
};
this.data = JSON.stringify(data);
for (const input of [...this.inputList]) this.removeInput(input.name);
const head = this.appendDummyInput();
head.appendField(data.name, 'fnName');
let pendingText = '';
let idx = 0;
for (const seg of data.segments) {
if (seg.kind === 'text') pendingText += seg.text;
else {
const vi = this.appendValueInput('PARAM' + idx);
if (pendingText) vi.appendField(pendingText);
pendingText = '';
if (seg.type === 'boolean') vi.setCheck('Boolean');
idx++;
}
}
const trail = this.appendDummyInput();
if (pendingText) trail.appendField(pendingText);
this.setInputsInline(true);
},
mutationToDom() {
const m = Blockly.utils.xml.createElement('mutation');
const d = parseFnData(this) || { name: '', segments: [] };
m.setAttribute('name', String(d.name || ''));
m.setAttribute('segments', JSON.stringify(d.segments || []));
return m;
},
};
Blockly.Blocks['rb_custom_param_var'] = {
init() {
this.setColour(OVAL_COLOR);
this.setOutput(true);
this.appendDummyInput().appendField('param', 'name');
this.setInputsInline(true);
},
};
// generators use second param `gen` (mirrors the fix)
javascriptGenerator.forBlock['rb_custom_fn'] = function (block, gen) {
const data = parseFnData(block);
const name = encodeName(block.getFieldValue('name') || (data && data.name) || 'myFunction');
const asyncFn = block.getFieldValue('asyncFn') === 'TRUE';
const params = (data ? fnParamNames(data) : []).join(', ');
const body = gen.statementToCode(block, 'body');
return `${asyncFn ? 'async ' : ''}function ${name}(${params}) {\n${body}\n}`;
};
function callCode(block, gen, isValue) {
const data = parseFnData(block);
const name = encodeName((data && data.name) || 'myFunction');
const params = paramSegsOf((data && data.segments) || []);
const args = params
.map((_, i) => {
try {
return gen.valueToCode(block, 'PARAM' + i, Order.ATOMIC) || 'undefined';
} catch {
return 'undefined';
}
})
.join(', ');
return isValue ? `${name}(${args})` : `${name}(${args});`;
}
javascriptGenerator.forBlock['rb_custom_call'] = function (block, gen) {
return callCode(block, gen, false);
};
javascriptGenerator.forBlock['rb_custom_call_value'] = function (block, gen) {
return [callCode(block, gen, true), Order.ATOMIC];
};
javascriptGenerator.forBlock['rb_custom_param_var'] = function (block) {
return [encodeName(String(block.getFieldValue('name') || '')), Order.ATOMIC];
};
// --- Build a workspace with a function hat containing body blocks + a caller ---
const xml = `
greet
FALSE
FALSE
who
greet
who
greet
42
`;
const ws = new Blockly.Workspace();
Blockly.Xml.domToWorkspace(Blockly.utils.xml.textToDom(xml), ws);
let ok = true;
function check(label, actual, expected) {
const pass = actual === expected;
if (!pass) ok = false;
console.log((pass ? 'PASS' : 'FAIL') + ' ' + label);
if (!pass) {
console.log(' expected: ' + JSON.stringify(expected));
console.log(' actual: ' + JSON.stringify(actual));
}
}
// Generate from the whole workspace
javascriptGenerator.init(ws);
const whole = javascriptGenerator.workspaceToCode(ws);
const expectedWhole = `function greet(who) {
console.log(who);
}
greet(42);`;
check('workspaceToCode', whole, expectedWhole);
// Generate the fn hat alone via blockToCode (as generateCodeFromBlock does)
const fnBlock = ws.getTopBlocks(true).find((b) => b.type === 'rb_custom_fn');
const fnTuple = javascriptGenerator.blockToCode(fnBlock);
check('fn blockToCode is string', typeof fnTuple === 'string', true);
check('fn blockToCode', fnTuple, `function greet(who) {
console.log(who);
}`);
// Caller alone
const callBlock = ws.getTopBlocks(true).find((b) => b.type === 'rb_custom_call');
const callStr = javascriptGenerator.blockToCode(callBlock);
check('call blockToCode is string', typeof callStr === 'string', true);
check('call blockToCode', callStr, 'greet(42);');
// Caller with an empty param socket must not emit `greet(, )` (syntax error);
// the empty arg falls back to `undefined`.
const emptyXml = `` +
`` +
``;
const ws2 = new Blockly.Workspace();
Blockly.Xml.domToWorkspace(Blockly.utils.xml.textToDom(emptyXml), ws2);
javascriptGenerator.init(ws2);
const emptyCall = javascriptGenerator.workspaceToCode(ws2);
check('empty-arg caller -> undefined', emptyCall, 'greet(undefined);');
process.exit(ok ? 0 : 1);