#!/usr/bin/env python3 """ Parity tests for IR <-> bytecode <-> readable decode. This test suite ensures that: 1. Abilities compile to bytecode correctly 2. Semantic forms (IR) are built consistently from abilities 3. Bytecode can be decoded to human-readable form 4. All three representations (IR, bytecode, readable) are consistent These tests catch layout drift early: if bytecode layout changes but semantic form or decoder don't update, tests will fail immediately. Layout versioning is verified: tests ensure version markers are present and consistent across compiled output. """ import json import os import sys from typing import Any, Dict, List, Tuple # Add project root to path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) from engine.models.ability import Ability from engine.models.ability_ir import ( BYTECODE_LAYOUT_NAME, BYTECODE_LAYOUT_VERSION, SEMANTIC_FORM_VERSION, AbilityIR, ) from engine.models.bytecode_readable import ( CONDITION_NAMES, COST_NAMES, OPCODE_NAMES, TRIGGER_NAMES, decode_bytecode, opcode_name, trigger_name, ) class ParityTestResult: """Container for parity test outcomes.""" def __init__(self, test_name: str): self.test_name = test_name self.passed = False self.errors: List[str] = [] self.warnings: List[str] = [] def add_error(self, msg: str): self.errors.append(msg) def add_warning(self, msg: str): self.warnings.append(msg) def set_passed(self): self.passed = True def summary(self) -> str: status = "[PASS]" if self.passed else "[FAIL]" lines = [f"{status} {self.test_name}"] if self.errors: for err in self.errors: lines.append(f" ERROR: {err}") if self.warnings: for warn in self.warnings: lines.append(f" WARNING: {warn}") return "\n".join(lines) def test_ability_compilation(ability: Ability) -> ParityTestResult: """Test that an ability compiles to bytecode without errors.""" result = ParityTestResult(f"Ability compilation: {ability.raw_text[:40]}") try: ability.compile() if not hasattr(ability, "bytecode") or ability.bytecode is None: result.add_error("Ability.compile() did not produce bytecode") return result if not isinstance(ability.bytecode, list): result.add_error( f"Bytecode is {type(ability.bytecode)}, expected list" ) return result if len(ability.bytecode) == 0: result.add_error("Bytecode is empty") return result # Bytecode should be 5-word chunks if len(ability.bytecode) % 5 != 0: result.add_warning( f"Bytecode length {len(ability.bytecode)} is not multiple of 5" ) result.set_passed() except Exception as e: result.add_error(f"Compilation raised exception: {e}") return result def test_semantic_form_building(ability: Ability) -> ParityTestResult: """Test that semantic form (IR) builds successfully from ability.""" result = ParityTestResult(f"Semantic form building: {ability.raw_text[:40]}") try: # Semantic form must be built after compilation if not hasattr(ability, "bytecode") or ability.bytecode is None: result.add_error("Cannot build semantic form without bytecode") return result semantic_form_dict = ability.build_semantic_form() if semantic_form_dict is None: result.add_error("build_semantic_form() returned None") return result if not isinstance(semantic_form_dict, dict): result.add_error( f"Semantic form is {type(semantic_form_dict)}, expected dict" ) return result # Check required fields required_fields = [ "semantic_version", "bytecode_layout_version", "bytecode_layout_name", "trigger", "effects", "conditions", "costs", ] for field in required_fields: if field not in semantic_form_dict: result.add_error(f"Semantic form missing field: {field}") return result # Verify version markers if semantic_form_dict["semantic_version"] != SEMANTIC_FORM_VERSION: result.add_error( f"semantic_version mismatch: " f"{semantic_form_dict['semantic_version']} != {SEMANTIC_FORM_VERSION}" ) return result if semantic_form_dict["bytecode_layout_version"] != BYTECODE_LAYOUT_VERSION: result.add_error( f"bytecode_layout_version mismatch: " f"{semantic_form_dict['bytecode_layout_version']} != {BYTECODE_LAYOUT_VERSION}" ) return result if semantic_form_dict["bytecode_layout_name"] != BYTECODE_LAYOUT_NAME: result.add_error( f"bytecode_layout_name mismatch: " f"{semantic_form_dict['bytecode_layout_name']} != {BYTECODE_LAYOUT_NAME}" ) return result result.set_passed() except Exception as e: result.add_error(f"Semantic form building raised exception: {e}") return result def test_bytecode_decodable( bytecode: List[int], ability_desc: str ) -> ParityTestResult: """Test that bytecode can be decoded to readable form.""" result = ParityTestResult(f"Bytecode decodable: {ability_desc[:40]}") try: readable = decode_bytecode(bytecode) if readable is None or readable == "": result.add_error("Bytecode decoding returned empty/None") return result if "LEGEND" not in readable: result.add_warning("Decoded bytecode missing legend section") result.set_passed() except Exception as e: result.add_error(f"Bytecode decoding raised exception: {e}") return result def test_bytecode_chunk_structure( bytecode: List[int], ability_desc: str ) -> ParityTestResult: """Test that bytecode chunks are valid 5-word structures.""" result = ParityTestResult(f"Bytecode structure valid: {ability_desc[:40]}") try: # All chunks should be 5 words if len(bytecode) % 5 != 0: result.add_error( f"Bytecode length {len(bytecode)} not multiple of 5" ) return result # Break into 5-word chunks for i in range(0, len(bytecode), 5): chunk = bytecode[i : i + 5] if len(chunk) != 5: result.add_error( f"Chunk at offset {i} has length {len(chunk)}, " f"expected 5" ) return result op, val, attr_low, attr_high, slot = chunk # Opcode should be decodable if op not in OPCODE_NAMES and op < 1000: # Some opcodes may legitimately not be in OPCODE_NAMES if dynamic if op >= 1000: # Negated opcode base_op = op - 1000 if base_op not in OPCODE_NAMES: result.add_warning( f"Chunk {i//5}: opcode {op} not found in " f"OPCODE_NAMES" ) result.set_passed() except Exception as e: result.add_error(f"Bytecode structure validation raised exception: {e}") return result def test_naming_consistency() -> ParityTestResult: """Test that naming dicts are consistent and have no conflicts.""" result = ParityTestResult("Naming consistency") try: # Check for duplicate keys (shouldn't happen, but catch edge cases) all_keys = set() for key in OPCODE_NAMES.keys(): if key in all_keys: result.add_error(f"Duplicate key in OPCODE_NAMES: {key}") return result all_keys.add(key) # Check that trigger names are populated if not TRIGGER_NAMES: result.add_error("TRIGGER_NAMES is empty") return result # Check that at least some conditions exist if not CONDITION_NAMES: result.add_error("CONDITION_NAMES is empty") return result # Test opcode_name() function works for key in list(OPCODE_NAMES.keys())[:5]: # Test first 5 name = opcode_name(key) if name is None or name == "": result.add_error(f"opcode_name({key}) returned empty/None") return result # Test trigger_name() function works for key in list(TRIGGER_NAMES.keys())[:5]: # Test first 5 name = trigger_name(key) if name is None or name == "": result.add_error(f"trigger_name({key}) returned empty/None") return result result.set_passed() except Exception as e: result.add_error(f"Naming consistency check raised exception: {e}") return result def test_compiled_json_structure(compiled_json_path: str) -> List[ParityTestResult]: """Test that compiled JSON file has proper version markers.""" results = [] result = ParityTestResult(f"Compiled JSON structure: {compiled_json_path}") try: if not os.path.exists(compiled_json_path): result.add_error(f"Compiled JSON not found: {compiled_json_path}") results.append(result) return results with open(compiled_json_path, "r", encoding="utf-8") as f: data = json.load(f) if "meta" not in data: result.add_error("Compiled JSON missing 'meta' section") results.append(result) return results meta = data["meta"] required_meta_fields = [ "bytecode_layout_version", "bytecode_layout_name", "semantic_form_version", "semantic_form_enabled", ] for field in required_meta_fields: if field not in meta: result.add_error(f"Meta section missing field: {field}") results.append(result) return results # Verify meta values match constants if meta["bytecode_layout_version"] != BYTECODE_LAYOUT_VERSION: result.add_error( f"Meta bytecode_layout_version {meta['bytecode_layout_version']} " f"!= {BYTECODE_LAYOUT_VERSION}" ) results.append(result) return results if meta["semantic_form_version"] != SEMANTIC_FORM_VERSION: result.add_error( f"Meta semantic_form_version {meta['semantic_form_version']} " f"!= {SEMANTIC_FORM_VERSION}" ) results.append(result) return results result.set_passed() results.append(result) # Now check individual card entries card_check_result = ParityTestResult("Compiled JSON card entries have version markers") # Check for cards in member_db, live_db, energy_db card_sources = [ ("member_db", data.get("member_db", {})), ("live_db", data.get("live_db", {})), ("energy_db", data.get("energy_db", {})), ] card_count_checked = 0 for source_name, source_data in card_sources: if card_count_checked >= 10: break for card_no, card_data in source_data.items(): if card_count_checked >= 10: break abilities = card_data.get("abilities", []) for ability in abilities: if "semantic_form" in ability: sf = ability["semantic_form"] if "semantic_version" not in sf: card_check_result.add_error( f"{source_name}[{card_no}] ability missing " f"semantic_version in semantic_form" ) break if "bytecode_layout_version" not in sf: card_check_result.add_error( f"{source_name}[{card_no}] ability missing " f"bytecode_layout_version in semantic_form" ) break card_count_checked += 1 if not card_check_result.errors: card_check_result.set_passed() results.append(card_check_result) except Exception as e: result.add_error(f"JSON structure check raised exception: {e}") results.append(result) return results def run_parity_tests(compiled_json_path: str = None) -> Tuple[int, int]: """ Run all parity tests and report results. Returns: (passed_count, failed_count) """ results = [] print("\n" + "=" * 70) print("PARITY TESTS: IR <-> Bytecode <-> Readable Decode") print("=" * 70) # Test 1: Naming consistency print("\n[1] Testing naming consistency...") results.append(test_naming_consistency()) # Test 2: Load a few sample abilities to test print("[2] Testing with sample abilities...") try: # This is a simplified test - in production you'd load from compiled data # For now, we just test the framework sample_abilities = [ # Example ability (simplified for testing) { "trigger": "ON_PLAY", "costs": [], "conditions": [], "effects": [], "instructions": [], "raw_text": "Test ability", } ] # Note: This is a placeholder. In real use, you'd load actual abilities # from the compiled JSON or from parsing print(" (Skipping real ability tests - use compiled JSON to validate)") except Exception as e: print(f" ERROR loading sample abilities: {e}") # Test 3: Compiled JSON structure if compiled_json_path is None: # Try default locations possible_paths = [ "data/cards_compiled.json", "engine/data/cards_compiled.json", ] for path in possible_paths: if os.path.exists(path): compiled_json_path = path break if compiled_json_path and os.path.exists(compiled_json_path): print(f"[3] Testing compiled JSON structure ({compiled_json_path})...") results.extend(test_compiled_json_structure(compiled_json_path)) else: print("[3] Skipping compiled JSON tests (file not found)") # Print results print("\n" + "=" * 70) print("RESULTS") print("=" * 70) passed = 0 failed = 0 for result in results: print(result.summary()) if result.passed: passed += 1 else: failed += 1 print("\n" + "=" * 70) print(f"Summary: {passed} passed, {failed} failed") print("=" * 70 + "\n") return passed, failed if __name__ == "__main__": passed, failed = run_parity_tests() sys.exit(0 if failed == 0 else 1)