Files
le-mystere-professeur-zacus/tests/runtime3/test_blocks_export_puzzle_scene.py
T
Claude Worker claude2 27544fecd1 feat(blocks): scene/puzzle blocks in atelier
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).
2026-06-11 03:34:21 +02:00

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()