/ tests / test_block_interpreter.py
test_block_interpreter.py
  1  """Unit tests for the Blockly workspace interpreter.
  2  
  3  Constructs a BlockInterpreter directly (no HTTP / DB / brain required for most
  4  blocks — we only need the real wiring for restai_call_project / restai_classifier,
  5  which these tests don't exercise). Each test hand-crafts a minimal workspace JSON
  6  and asserts the resulting output or a specific log entry.
  7  """
  8  import asyncio
  9  
 10  import pytest
 11  
 12  from restai.projects.block_interpreter import BlockInterpreter
 13  
 14  
 15  def _interp(workspace, input_text="hello"):
 16      return BlockInterpreter(
 17          workspace_json=workspace,
 18          input_text=input_text,
 19          brain=None,
 20          user=None,
 21          db=None,
 22      )
 23  
 24  
 25  def _run(workspace, input_text="hello"):
 26      return asyncio.run(_interp(workspace, input_text).execute())
 27  
 28  
 29  def _set_output(value_block):
 30      """Wrap a value block in the usual restai_set_output so execute() returns it."""
 31      return {
 32          "blocks": {
 33              "blocks": [
 34                  {
 35                      "type": "restai_set_output",
 36                      "inputs": {"VALUE": {"block": value_block}},
 37                  }
 38              ]
 39          },
 40          "variables": [],
 41      }
 42  
 43  
 44  def _num(n):
 45      return {"type": "math_number", "fields": {"NUM": n}}
 46  
 47  
 48  def _text(s):
 49      return {"type": "text", "fields": {"TEXT": s}}
 50  
 51  
 52  def _bool(b):
 53      return {"type": "logic_boolean", "fields": {"BOOL": "TRUE" if b else "FALSE"}}
 54  
 55  
 56  def _list(*items):
 57      return {
 58          "type": "lists_create_with",
 59          "extraState": {"itemCount": len(items)},
 60          "inputs": {f"ADD{i}": {"block": item} for i, item in enumerate(items)},
 61      }
 62  
 63  
 64  # ---------------------------------------------------------------- Logic
 65  
 66  
 67  def test_logic_null_returns_empty_string():
 68      # logic_null returns None; restai_set_output converts to ""
 69      workspace = _set_output({"type": "logic_null"})
 70      assert _run(workspace) == ""
 71  
 72  
 73  def test_logic_ternary_true_branch():
 74      workspace = _set_output({
 75          "type": "logic_ternary",
 76          "inputs": {
 77              "IF": {"block": _bool(True)},
 78              "THEN": {"block": _text("yes")},
 79              "ELSE": {"block": _text("no")},
 80          },
 81      })
 82      assert _run(workspace) == "yes"
 83  
 84  
 85  def test_logic_ternary_false_branch():
 86      workspace = _set_output({
 87          "type": "logic_ternary",
 88          "inputs": {
 89              "IF": {"block": _bool(False)},
 90              "THEN": {"block": _text("yes")},
 91              "ELSE": {"block": _text("no")},
 92          },
 93      })
 94      assert _run(workspace) == "no"
 95  
 96  
 97  # ---------------------------------------------------------------- Math
 98  
 99  
100  @pytest.mark.parametrize("op,expected", [
101      ("ROOT", 3.0),  # sqrt(9)
102      ("ABS", 9.0),
103      ("NEG", -9.0),
104      ("POW10", 1e9),
105  ])
106  def test_math_single_ops(op, expected):
107      workspace = _set_output({
108          "type": "math_single",
109          "fields": {"OP": op},
110          "inputs": {"NUM": {"block": _num(9)}},
111      })
112      # output is str() of the result
113      assert _run(workspace) == str(expected)
114  
115  
116  def test_math_trig_sin_90_is_one():
117      workspace = _set_output({
118          "type": "math_trig",
119          "fields": {"OP": "SIN"},
120          "inputs": {"NUM": {"block": _num(90)}},
121      })
122      # sin(90°) == 1
123      assert abs(float(_run(workspace)) - 1.0) < 1e-9
124  
125  
126  def test_math_constant_pi():
127      import math
128      workspace = _set_output({
129          "type": "math_constant",
130          "fields": {"CONSTANT": "PI"},
131      })
132      assert abs(float(_run(workspace)) - math.pi) < 1e-12
133  
134  
135  def test_math_round_up_down():
136      up = _set_output({
137          "type": "math_round",
138          "fields": {"OP": "ROUNDUP"},
139          "inputs": {"NUM": {"block": _num(2.1)}},
140      })
141      assert _run(up) == "3"
142      down = _set_output({
143          "type": "math_round",
144          "fields": {"OP": "ROUNDDOWN"},
145          "inputs": {"NUM": {"block": _num(2.9)}},
146      })
147      assert _run(down) == "2"
148  
149  
150  @pytest.mark.parametrize("op,expected", [
151      ("SUM", 15.0),
152      ("MIN", 1.0),
153      ("MAX", 5.0),
154      ("AVERAGE", 3.0),
155  ])
156  def test_math_on_list_ops(op, expected):
157      lst = _list(_num(1), _num(2), _num(3), _num(4), _num(5))
158      workspace = _set_output({
159          "type": "math_on_list",
160          "fields": {"OP": op},
161          "inputs": {"LIST": {"block": lst}},
162      })
163      assert float(_run(workspace)) == expected
164  
165  
166  def test_math_modulo():
167      workspace = _set_output({
168          "type": "math_modulo",
169          "inputs": {
170              "DIVIDEND": {"block": _num(10)},
171              "DIVISOR": {"block": _num(3)},
172          },
173      })
174      assert float(_run(workspace)) == 1.0
175  
176  
177  def test_math_constrain():
178      workspace = _set_output({
179          "type": "math_constrain",
180          "inputs": {
181              "VALUE": {"block": _num(100)},
182              "LOW": {"block": _num(0)},
183              "HIGH": {"block": _num(10)},
184          },
185      })
186      assert float(_run(workspace)) == 10.0
187  
188  
189  def test_math_number_property_even():
190      workspace = _set_output({
191          "type": "math_number_property",
192          "fields": {"PROPERTY": "EVEN"},
193          "inputs": {"NUMBER_TO_CHECK": {"block": _num(4)}},
194      })
195      assert _run(workspace) == "True"
196  
197  
198  def test_math_number_property_prime():
199      workspace = _set_output({
200          "type": "math_number_property",
201          "fields": {"PROPERTY": "PRIME"},
202          "inputs": {"NUMBER_TO_CHECK": {"block": _num(7)}},
203      })
204      assert _run(workspace) == "True"
205  
206  
207  # ---------------------------------------------------------------- Text
208  
209  
210  def test_text_getSubstring_first_last():
211      """substring from first to last == whole string"""
212      workspace = _set_output({
213          "type": "text_getSubstring",
214          "fields": {"WHERE1": "FIRST", "WHERE2": "LAST"},
215          "inputs": {"STRING": {"block": _text("hello world")}},
216      })
217      assert _run(workspace) == "hello world"
218  
219  
220  def test_text_getSubstring_from_start_from_start():
221      """substring 1-5 of 'hello world' → 'hello'"""
222      workspace = _set_output({
223          "type": "text_getSubstring",
224          "fields": {"WHERE1": "FROM_START", "WHERE2": "FROM_START"},
225          "inputs": {
226              "STRING": {"block": _text("hello world")},
227              "AT1": {"block": _num(1)},
228              "AT2": {"block": _num(5)},
229          },
230      })
231      assert _run(workspace) == "hello"
232  
233  
234  def test_text_replace():
235      workspace = _set_output({
236          "type": "text_replace",
237          "inputs": {
238              "FROM": {"block": _text("foo")},
239              "TO": {"block": _text("bar")},
240              "TEXT": {"block": _text("foo baz foo")},
241          },
242      })
243      assert _run(workspace) == "bar baz bar"
244  
245  
246  def test_text_count():
247      workspace = _set_output({
248          "type": "text_count",
249          "inputs": {
250              "SUB": {"block": _text("a")},
251              "TEXT": {"block": _text("banana")},
252          },
253      })
254      assert _run(workspace) == "3"
255  
256  
257  def test_text_reverse():
258      workspace = _set_output({
259          "type": "text_reverse",
260          "inputs": {"TEXT": {"block": _text("abc")}},
261      })
262      assert _run(workspace) == "cba"
263  
264  
265  def test_text_append_statement():
266      """text_append mutates a variable — set a var, append, then output it."""
267      workspace = {
268          "blocks": {
269              "blocks": [
270                  {
271                      "type": "variables_set",
272                      "fields": {"VAR": {"id": "V1"}},
273                      "inputs": {"VALUE": {"block": _text("Hello, ")}},
274                      "next": {"block": {
275                          "type": "text_append",
276                          "fields": {"VAR": {"id": "V1"}},
277                          "inputs": {"TEXT": {"block": _text("world")}},
278                          "next": {"block": {
279                              "type": "restai_set_output",
280                              "inputs": {"VALUE": {"block": {
281                                  "type": "variables_get",
282                                  "fields": {"VAR": {"id": "V1"}},
283                              }}},
284                          }},
285                      }},
286                  }
287              ]
288          },
289          "variables": [{"id": "V1", "name": "greeting"}],
290      }
291      assert _run(workspace) == "Hello, world"
292  
293  
294  # ---------------------------------------------------------------- Lists
295  
296  
297  def test_lists_create_and_length():
298      workspace = _set_output({
299          "type": "lists_length",
300          "inputs": {"VALUE": {"block": _list(_num(1), _num(2), _num(3))}},
301      })
302      assert _run(workspace) == "3"
303  
304  
305  def test_lists_getIndex_first():
306      workspace = _set_output({
307          "type": "lists_getIndex",
308          "fields": {"MODE": "GET", "WHERE": "FIRST"},
309          "inputs": {"VALUE": {"block": _list(_text("a"), _text("b"), _text("c"))}},
310      })
311      assert _run(workspace) == "a"
312  
313  
314  def test_lists_getIndex_last():
315      workspace = _set_output({
316          "type": "lists_getIndex",
317          "fields": {"MODE": "GET", "WHERE": "LAST"},
318          "inputs": {"VALUE": {"block": _list(_text("a"), _text("b"), _text("c"))}},
319      })
320      assert _run(workspace) == "c"
321  
322  
323  def test_lists_getIndex_from_start():
324      workspace = _set_output({
325          "type": "lists_getIndex",
326          "fields": {"MODE": "GET", "WHERE": "FROM_START"},
327          "inputs": {
328              "VALUE": {"block": _list(_text("a"), _text("b"), _text("c"))},
329              "AT": {"block": _num(2)},
330          },
331      })
332      # 1-based: index 2 → 'b'
333      assert _run(workspace) == "b"
334  
335  
336  def test_lists_indexOf_found():
337      workspace = _set_output({
338          "type": "lists_indexOf",
339          "fields": {"END": "FIRST"},
340          "inputs": {
341              "VALUE": {"block": _list(_text("a"), _text("b"), _text("c"))},
342              "FIND": {"block": _text("b")},
343          },
344      })
345      # 1-based, found at position 2
346      assert _run(workspace) == "2"
347  
348  
349  def test_lists_indexOf_missing():
350      workspace = _set_output({
351          "type": "lists_indexOf",
352          "fields": {"END": "FIRST"},
353          "inputs": {
354              "VALUE": {"block": _list(_text("a"), _text("b"))},
355              "FIND": {"block": _text("z")},
356          },
357      })
358      assert _run(workspace) == "0"
359  
360  
361  def test_lists_sort_numeric_ascending():
362      lst = _list(_num(3), _num(1), _num(2))
363      workspace = _set_output({
364          "type": "lists_split",
365          "fields": {"MODE": "JOIN"},
366          "inputs": {
367              "INPUT": {"block": {
368                  "type": "lists_sort",
369                  "fields": {"TYPE": "NUMERIC", "DIRECTION": "1"},
370                  "inputs": {"LIST": {"block": lst}},
371              }},
372              "DELIM": {"block": _text(",")},
373          },
374      })
375      assert _run(workspace) == "1,2,3"
376  
377  
378  def test_lists_split_and_join_roundtrip():
379      workspace = _set_output({
380          "type": "lists_split",
381          "fields": {"MODE": "JOIN"},
382          "inputs": {
383              "INPUT": {"block": {
384                  "type": "lists_split",
385                  "fields": {"MODE": "SPLIT"},
386                  "inputs": {
387                      "INPUT": {"block": _text("a,b,c")},
388                      "DELIM": {"block": _text(",")},
389                  },
390              }},
391              "DELIM": {"block": _text("-")},
392          },
393      })
394      assert _run(workspace) == "a-b-c"
395  
396  
397  def test_lists_reverse():
398      lst = _list(_num(1), _num(2), _num(3))
399      workspace = _set_output({
400          "type": "lists_split",
401          "fields": {"MODE": "JOIN"},
402          "inputs": {
403              "INPUT": {"block": {
404                  "type": "lists_reverse",
405                  "inputs": {"LIST": {"block": lst}},
406              }},
407              "DELIM": {"block": _text(",")},
408          },
409      })
410      assert _run(workspace) == "3,2,1"
411  
412  
413  def test_lists_setIndex_set_statement():
414      """Build a list, SET index 2 to 'X', then join + output."""
415      workspace = {
416          "blocks": {
417              "blocks": [
418                  {
419                      "type": "variables_set",
420                      "fields": {"VAR": {"id": "L"}},
421                      "inputs": {"VALUE": {"block": _list(_text("a"), _text("b"), _text("c"))}},
422                      "next": {"block": {
423                          "type": "lists_setIndex",
424                          "fields": {"MODE": "SET", "WHERE": "FROM_START"},
425                          "inputs": {
426                              "LIST": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "L"}}}},
427                              "AT": {"block": _num(2)},
428                              "TO": {"block": _text("X")},
429                          },
430                          "next": {"block": {
431                              "type": "restai_set_output",
432                              "inputs": {"VALUE": {"block": {
433                                  "type": "lists_split",
434                                  "fields": {"MODE": "JOIN"},
435                                  "inputs": {
436                                      "INPUT": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "L"}}}},
437                                      "DELIM": {"block": _text(",")},
438                                  },
439                              }}},
440                          }},
441                      }},
442                  }
443              ]
444          },
445          "variables": [{"id": "L", "name": "lst"}],
446      }
447      assert _run(workspace) == "a,X,c"
448  
449  
450  # ---------------------------------------------------------------- Flow control
451  
452  
453  def test_controls_flow_break_in_repeat():
454      """Counter-var increments inside a repeat-10 loop but breaks at iteration 3."""
455      workspace = {
456          "blocks": {
457              "blocks": [
458                  {
459                      "type": "variables_set",
460                      "fields": {"VAR": {"id": "C"}},
461                      "inputs": {"VALUE": {"block": _num(0)}},
462                      "next": {"block": {
463                          "type": "controls_repeat_ext",
464                          "inputs": {
465                              "TIMES": {"block": _num(10)},
466                              "DO": {"block": {
467                                  "type": "variables_set",
468                                  "fields": {"VAR": {"id": "C"}},
469                                  "inputs": {"VALUE": {"block": {
470                                      "type": "math_arithmetic",
471                                      "fields": {"OP": "ADD"},
472                                      "inputs": {
473                                          "A": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "C"}}}},
474                                          "B": {"block": _num(1)},
475                                      },
476                                  }}},
477                                  "next": {"block": {
478                                      "type": "controls_if",
479                                      "inputs": {
480                                          "IF0": {"block": {
481                                              "type": "logic_compare",
482                                              "fields": {"OP": "GTE"},
483                                              "inputs": {
484                                                  "A": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "C"}}}},
485                                                  "B": {"block": _num(3)},
486                                              },
487                                          }},
488                                          "DO0": {"block": {
489                                              "type": "controls_flow_statements",
490                                              "fields": {"FLOW": "BREAK"},
491                                          }},
492                                      },
493                                  }},
494                              }},
495                          },
496                          "next": {"block": {
497                              "type": "restai_set_output",
498                              "inputs": {"VALUE": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "C"}}}}},
499                          }},
500                      }},
501                  }
502              ]
503          },
504          "variables": [{"id": "C", "name": "counter"}],
505      }
506      # math_arithmetic coerces to float, so 0 + 1 + 1 + 1 = 3.0
507      assert _run(workspace) == "3.0"
508  
509  
510  def test_controls_flow_continue_in_for_each():
511      """Sum 1+2+...+5 but skip 3 via continue."""
512      workspace = {
513          "blocks": {
514              "blocks": [
515                  {
516                      "type": "variables_set",
517                      "fields": {"VAR": {"id": "S"}},
518                      "inputs": {"VALUE": {"block": _num(0)}},
519                      "next": {"block": {
520                          "type": "controls_forEach",
521                          "fields": {"VAR": {"id": "I"}},
522                          "inputs": {
523                              "LIST": {"block": _list(_num(1), _num(2), _num(3), _num(4), _num(5))},
524                              "DO": {"block": {
525                                  "type": "controls_if",
526                                  "inputs": {
527                                      "IF0": {"block": {
528                                          "type": "logic_compare",
529                                          "fields": {"OP": "EQ"},
530                                          "inputs": {
531                                              "A": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "I"}}}},
532                                              "B": {"block": _num(3)},
533                                          },
534                                      }},
535                                      "DO0": {"block": {
536                                          "type": "controls_flow_statements",
537                                          "fields": {"FLOW": "CONTINUE"},
538                                      }},
539                                  },
540                                  "next": {"block": {
541                                      "type": "variables_set",
542                                      "fields": {"VAR": {"id": "S"}},
543                                      "inputs": {"VALUE": {"block": {
544                                          "type": "math_arithmetic",
545                                          "fields": {"OP": "ADD"},
546                                          "inputs": {
547                                              "A": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "S"}}}},
548                                              "B": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "I"}}}},
549                                          },
550                                      }}},
551                                  }},
552                              }},
553                          },
554                          "next": {"block": {
555                              "type": "restai_set_output",
556                              "inputs": {"VALUE": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "S"}}}}},
557                          }},
558                      }},
559                  }
560              ]
561          },
562          "variables": [{"id": "S", "name": "sum"}, {"id": "I", "name": "i"}],
563      }
564      # 1 + 2 + 4 + 5 = 12 (float because math_arithmetic coerces)
565      assert _run(workspace) == "12.0"
566  
567  
568  # ---------------------------------------------------------------- Procedures
569  
570  
571  def _proc_double():
572      """procedures_defreturn double(x) = x * 2"""
573      return {
574          "type": "procedures_defreturn",
575          "fields": {"NAME": "double"},
576          "extraState": {"params": [{"name": "x", "id": "param_x"}]},
577          "inputs": {
578              "RETURN": {"block": {
579                  "type": "math_arithmetic",
580                  "fields": {"OP": "MULTIPLY"},
581                  "inputs": {
582                      "A": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "param_x"}}}},
583                      "B": {"block": _num(2)},
584                  },
585              }},
586          },
587      }
588  
589  
590  def test_procedure_return_value():
591      workspace = {
592          "blocks": {
593              "blocks": [
594                  _proc_double(),
595                  {
596                      "type": "restai_set_output",
597                      "inputs": {"VALUE": {"block": {
598                          "type": "procedures_callreturn",
599                          "extraState": {"name": "double", "params": ["param_x"]},
600                          "inputs": {"ARG0": {"block": _num(7)}},
601                      }}},
602                  },
603              ]
604          },
605          "variables": [],
606      }
607      # math_arithmetic returns float
608      assert _run(workspace) == "14.0"
609  
610  
611  def test_procedure_scope_isolation():
612      """A global variable named the same as a param is unchanged by the call."""
613      # Global `x` = 100. Define `bump(x)` that sets x = 999 (the param, not global).
614      # After the call, global x is still 100.
615      workspace = {
616          "blocks": {
617              "blocks": [
618                  # global x = 100
619                  {
620                      "type": "variables_set",
621                      "fields": {"VAR": {"id": "global_x"}},
622                      "inputs": {"VALUE": {"block": _num(100)}},
623                  },
624                  # def bump(x): x = 999 (this writes to the param frame, not global_x)
625                  {
626                      "type": "procedures_defnoreturn",
627                      "fields": {"NAME": "bump"},
628                      "extraState": {"params": [{"name": "x", "id": "param_x"}]},
629                      "inputs": {"STACK": {"block": {
630                          "type": "variables_set",
631                          "fields": {"VAR": {"id": "param_x"}},
632                          "inputs": {"VALUE": {"block": _num(999)}},
633                      }}},
634                  },
635                  # Call bump(50)
636                  {
637                      "type": "procedures_callnoreturn",
638                      "extraState": {"name": "bump", "params": ["param_x"]},
639                      "inputs": {"ARG0": {"block": _num(50)}},
640                      "next": {"block": {
641                          "type": "restai_set_output",
642                          "inputs": {"VALUE": {"block": {
643                              "type": "variables_get", "fields": {"VAR": {"id": "global_x"}},
644                          }}},
645                      }},
646                  },
647              ]
648          },
649          "variables": [
650              {"id": "global_x", "name": "x"},
651              {"id": "param_x", "name": "x"},  # param has its own id
652          ],
653      }
654      # The global_x was set with math_number (int 100), never touched since.
655      assert _run(workspace) == "100"
656  
657  
658  def test_procedure_ifreturn_early_exit():
659      """A procedure that returns 'big' if x >= 10 else falls through to 'small'."""
660      workspace = {
661          "blocks": {
662              "blocks": [
663                  {
664                      "type": "procedures_defreturn",
665                      "fields": {"NAME": "classify"},
666                      "extraState": {"params": [{"name": "x", "id": "px"}]},
667                      "inputs": {
668                          "STACK": {"block": {
669                              "type": "procedures_ifreturn",
670                              "inputs": {
671                                  "CONDITION": {"block": {
672                                      "type": "logic_compare",
673                                      "fields": {"OP": "GTE"},
674                                      "inputs": {
675                                          "A": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "px"}}}},
676                                          "B": {"block": _num(10)},
677                                      },
678                                  }},
679                                  "VALUE": {"block": _text("big")},
680                              },
681                          }},
682                          "RETURN": {"block": _text("small")},
683                      },
684                  },
685                  {
686                      "type": "restai_set_output",
687                      "inputs": {"VALUE": {"block": {
688                          "type": "procedures_callreturn",
689                          "extraState": {"name": "classify", "params": ["px"]},
690                          "inputs": {"ARG0": {"block": _num(42)}},
691                      }}},
692                  },
693              ]
694          },
695          "variables": [],
696      }
697      assert _run(workspace) == "big"
698  
699  
700  def test_procedure_ifreturn_fallthrough():
701      workspace = {
702          "blocks": {
703              "blocks": [
704                  {
705                      "type": "procedures_defreturn",
706                      "fields": {"NAME": "classify"},
707                      "extraState": {"params": [{"name": "x", "id": "px"}]},
708                      "inputs": {
709                          "STACK": {"block": {
710                              "type": "procedures_ifreturn",
711                              "inputs": {
712                                  "CONDITION": {"block": {
713                                      "type": "logic_compare",
714                                      "fields": {"OP": "GTE"},
715                                      "inputs": {
716                                          "A": {"block": {"type": "variables_get", "fields": {"VAR": {"id": "px"}}}},
717                                          "B": {"block": _num(10)},
718                                      },
719                                  }},
720                                  "VALUE": {"block": _text("big")},
721                              },
722                          }},
723                          "RETURN": {"block": _text("small")},
724                      },
725                  },
726                  {
727                      "type": "restai_set_output",
728                      "inputs": {"VALUE": {"block": {
729                          "type": "procedures_callreturn",
730                          "extraState": {"name": "classify", "params": ["px"]},
731                          "inputs": {"ARG0": {"block": _num(3)}},
732                      }}},
733                  },
734              ]
735          },
736          "variables": [],
737      }
738      assert _run(workspace) == "small"