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"