27544fecd1
Catégorie « Énigmes » dans le catalogue Blockly canonique : zacus_scene (écran maître title/subtitle/symbol/effect), zacus_puzzleQR et zacus_puzzleSound. L'export blocks_to_runtime3 les attache au step courant comme objets IR scene/puzzle (pas des actions), avec clamping/troncature vers les limites strictes du gateway pour que l'IR émis valide toujours. Copie hub régénérée via sync_blockly_catalog.sh. 14 tests d'export ajoutés (60 verts).
168 lines
7.2 KiB
Python
168 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Blocks studio export: `scene` / `puzzleQR` / `puzzleSound` blocks become
|
|
step-level `scene` / `puzzle` IR objects (not actions).
|
|
|
|
The builder clamps/truncates out-of-range values (with warnings) to the
|
|
strict gateway limits, so the emitted IR always passes
|
|
validate_runtime3_document.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(REPO_ROOT / "tools" / "zacus-gateway"))
|
|
sys.path.insert(0, str(REPO_ROOT / "tools" / "scenario"))
|
|
|
|
from blocks_to_runtime3 import compile_blocks # noqa: E402
|
|
from runtime3_common import validate_runtime3_document # noqa: E402
|
|
|
|
|
|
def _compile(nodes: list[dict]) -> dict:
|
|
doc = {"blocks_studio_version": 2, "nodes": nodes}
|
|
return compile_blocks(yaml.safe_dump(doc), scenario_id="TEST_BLOCKS")
|
|
|
|
|
|
def _chain(*nodes: dict) -> list[dict]:
|
|
"""Assign ids and next pointers to a top-level chain."""
|
|
out = []
|
|
for i, node in enumerate(nodes):
|
|
n = dict(node)
|
|
n["id"] = f"n{i}"
|
|
if i + 1 < len(nodes):
|
|
n["next"] = f"n{i + 1}"
|
|
out.append(n)
|
|
return out
|
|
|
|
|
|
START = {"kind": "sceneStart", "params": {"id": "qr"}}
|
|
SCENE_PARAMS = {"title": "MISSION QR", "subtitle": "scannez dans l'ordre",
|
|
"symbol": "RUN", "effect": "gyro"}
|
|
|
|
|
|
class SceneBlockExport(unittest.TestCase):
|
|
def test_scene_block_becomes_step_scene_object(self):
|
|
ir = _compile(_chain(START, {"kind": "scene", "params": SCENE_PARAMS}))
|
|
step = ir["steps"][0]
|
|
self.assertEqual(step["scene"], SCENE_PARAMS)
|
|
self.assertEqual(step["actions"], [])
|
|
validate_runtime3_document(ir)
|
|
|
|
def test_long_title_truncated_with_warning(self):
|
|
ir = _compile(_chain(START, {"kind": "scene", "params": {"title": "x" * 80}}))
|
|
self.assertEqual(ir["steps"][0]["scene"]["title"], "x" * 47)
|
|
self.assertTrue(any("truncated" in w for w in ir["metadata"]["warnings"]))
|
|
validate_runtime3_document(ir)
|
|
|
|
def test_unknown_effect_falls_back_to_pulse(self):
|
|
ir = _compile(_chain(START, {"kind": "scene",
|
|
"params": {"title": "T", "effect": "rainbow"}}))
|
|
self.assertEqual(ir["steps"][0]["scene"]["effect"], "pulse")
|
|
self.assertTrue(any("rainbow" in w for w in ir["metadata"]["warnings"]))
|
|
validate_runtime3_document(ir)
|
|
|
|
def test_empty_scene_block_ignored(self):
|
|
ir = _compile(_chain(START, {"kind": "scene", "params": {}}))
|
|
self.assertNotIn("scene", ir["steps"][0])
|
|
self.assertTrue(any("empty scene" in w for w in ir["metadata"]["warnings"]))
|
|
|
|
def test_second_scene_block_wins_with_warning(self):
|
|
ir = _compile(_chain(START,
|
|
{"kind": "scene", "params": {"title": "A"}},
|
|
{"kind": "scene", "params": {"title": "B"}}))
|
|
self.assertEqual(ir["steps"][0]["scene"]["title"], "B")
|
|
self.assertTrue(any("last one wins" in w for w in ir["metadata"]["warnings"]))
|
|
|
|
|
|
class PuzzleQRBlockExport(unittest.TestCase):
|
|
def test_codes_parsed_in_order_and_fragment_digits(self):
|
|
ir = _compile(_chain(START, {"kind": "puzzleQR", "params": {
|
|
"id": 3, "codes": "zacus-qr-1, zacus-qr-2", "fragment": "12"}}))
|
|
self.assertEqual(ir["steps"][0]["puzzle"], {
|
|
"id": 3, "type": "qr",
|
|
"codes": ["zacus-qr-1", "zacus-qr-2"], "fragment": [1, 2]})
|
|
validate_runtime3_document(ir)
|
|
|
|
def test_fragment_defaults_to_slot_digit(self):
|
|
ir = _compile(_chain(START, {"kind": "puzzleQR", "params": {
|
|
"id": 5, "codes": "abc", "fragment": ""}}))
|
|
self.assertEqual(ir["steps"][0]["puzzle"]["fragment"], [5])
|
|
validate_runtime3_document(ir)
|
|
|
|
def test_without_codes_is_ignored(self):
|
|
ir = _compile(_chain(START, {"kind": "puzzleQR", "params": {"id": 3, "codes": " "}}))
|
|
self.assertNotIn("puzzle", ir["steps"][0])
|
|
self.assertTrue(any("without codes" in w for w in ir["metadata"]["warnings"]))
|
|
|
|
def test_long_code_truncated_to_firmware_limit(self):
|
|
ir = _compile(_chain(START, {"kind": "puzzleQR", "params": {
|
|
"id": 3, "codes": "y" * 40}}))
|
|
self.assertEqual(ir["steps"][0]["puzzle"]["codes"], ["y" * 31])
|
|
validate_runtime3_document(ir)
|
|
|
|
|
|
class PuzzleSoundBlockExport(unittest.TestCase):
|
|
def test_melody_tolerance_and_fragment(self):
|
|
ir = _compile(_chain(START, {"kind": "puzzleSound", "params": {
|
|
"id": 1, "melody": "60, 62, 64, 65", "tolerance": 2, "fragment": "1"}}))
|
|
self.assertEqual(ir["steps"][0]["puzzle"], {
|
|
"id": 1, "type": "sound", "melody": [60, 62, 64, 65],
|
|
"tolerance": 2, "fragment": [1]})
|
|
validate_runtime3_document(ir)
|
|
|
|
def test_invalid_notes_skipped_with_warning(self):
|
|
ir = _compile(_chain(START, {"kind": "puzzleSound", "params": {
|
|
"id": 1, "melody": "60, la, 300, 62"}}))
|
|
self.assertEqual(ir["steps"][0]["puzzle"]["melody"], [60, 62])
|
|
warnings = ir["metadata"]["warnings"]
|
|
self.assertTrue(any("'la'" in w for w in warnings))
|
|
self.assertTrue(any("300" in w for w in warnings))
|
|
validate_runtime3_document(ir)
|
|
|
|
def test_unplayable_melody_is_ignored(self):
|
|
ir = _compile(_chain(START, {"kind": "puzzleSound", "params": {"melody": "la, si"}}))
|
|
self.assertNotIn("puzzle", ir["steps"][0])
|
|
self.assertTrue(any("playable melody" in w for w in ir["metadata"]["warnings"]))
|
|
|
|
|
|
class StepLevelOnly(unittest.TestCase):
|
|
def test_scene_and_puzzle_inside_if_slot_are_ignored(self):
|
|
nodes = _chain(START, {"kind": "logicIf", "params": {"condition": "score > 0"},
|
|
"slots": {"body": "s0"}})
|
|
nodes.append({"id": "s0", "kind": "scene", "params": {"title": "T"}, "next": "s1"})
|
|
nodes.append({"id": "s1", "kind": "puzzleQR", "params": {"codes": "abc"}})
|
|
ir = _compile(nodes)
|
|
step = ir["steps"][0]
|
|
self.assertNotIn("scene", step)
|
|
self.assertNotIn("puzzle", step)
|
|
self.assertEqual(step["actions"][0]["then"], [])
|
|
self.assertEqual(sum("only makes sense at step level" in w
|
|
for w in ir["metadata"]["warnings"]), 2)
|
|
|
|
def test_full_example_shaped_scenario_validates(self):
|
|
# Same shape as EXAMPLE_LOCAL_PUZZLES.json, authored as blocks.
|
|
ir = _compile(_chain(
|
|
START,
|
|
{"kind": "scene", "params": SCENE_PARAMS},
|
|
{"kind": "puzzleQR", "params": {"id": 3, "codes": "zacus-qr-1, zacus-qr-2",
|
|
"fragment": "5"}},
|
|
{"kind": "sceneStart", "params": {"id": "sound"}},
|
|
{"kind": "scene", "params": {"title": "U-SON", "effect": "glitch"}},
|
|
{"kind": "puzzleSound", "params": {"id": 1, "melody": "60,62,64,65",
|
|
"tolerance": 2, "fragment": "12"}},
|
|
))
|
|
self.assertEqual(len(ir["steps"]), 2)
|
|
self.assertEqual(ir["steps"][0]["puzzle"]["type"], "qr")
|
|
self.assertEqual(ir["steps"][1]["puzzle"]["type"], "sound")
|
|
self.assertEqual(ir["steps"][1]["scene"]["title"], "U-SON")
|
|
validate_runtime3_document(ir)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|