diff --git a/gitgalaxy/tools/cobol_to_java/cobol_to_java_build_forge.py b/gitgalaxy/tools/cobol_to_java/cobol_to_java_build_forge.py index 0aae5dbc..b2f4d82f 100644 --- a/gitgalaxy/tools/cobol_to_java/cobol_to_java_build_forge.py +++ b/gitgalaxy/tools/cobol_to_java/cobol_to_java_build_forge.py @@ -72,6 +72,12 @@ def generate_pom_xml(group_id: str, artifact_id: str) -> str: spring-boot-starter-test test + + + com.h2database + h2 + test + diff --git a/tests/test_agent_forge.py b/tests/test_agent_forge.py new file mode 100644 index 00000000..9c07e0f6 --- /dev/null +++ b/tests/test_agent_forge.py @@ -0,0 +1,60 @@ +import pytest +from gitgalaxy.tools.cobol_to_java.cobol_to_java_agent_forge import generate_java_agent_ticket + +def test_llm_hallucination_prevention(): + """ + Verifies that the Agent Forge correctly extracts strict architectural constraints + (external dependencies and honesty flags) from the IR state and injects them + into the JSON ticket. This proves the LLM will not fly blind and hallucinate. + """ + # 1. Setup the Mock Inputs + prog_id = "calc-payroll" + + # Mock a specific COBOL slice + mock_slice = { + "target_var": "WS-NET-PAY", + "business_rules": [ + {"paragraph": "0100-CALC-TAXES", "statement": "COMPUTE WS-TAX = WS-GROSS * 0.20"}, + {"paragraph": "0200-FINALIZE", "statement": "SUBTRACT WS-TAX FROM WS-GROSS GIVING WS-NET-PAY"} + ] + } + + # Mock the Global IR State containing the guardrails + mock_ir_state = { + "analysis": { + "honesty_flags": [ + "[CRITICAL] Do not use Java floats for currency, use BigDecimal.", + "This module assumes EBCDIC encoding." + ], + "lineage": { + "unresolved_calls": ["TAX-RATES-DB", "AUDIT-LOGGER"] + } + } + } + + # 2. Forge the Ticket + ticket = generate_java_agent_ticket(mock_slice, prog_id, mock_ir_state) + + # ===================================================================== + # 3. INVARIANT ASSERTIONS (The Proof) + # ===================================================================== + + # A) Verify the Job ID was formatted correctly + assert ticket["job_id"] == "CALC-PAYROLL_JAVA_SERVICE_TRANSLATION" + assert ticket["target_variable"] == "WS-NET-PAY" + + # B) Verify Business Rules were translated into the context array + context = ticket["context"] + assert len(context["business_rules_to_translate"]) == 2 + assert "// Context: 0100-CALC-TAXES" in context["business_rules_to_translate"][0] + + # C) THE HALLUCINATION GUARDS: Verify Dependencies were passed perfectly + assert len(context["external_dependencies"]) == 2 + assert "TAX-RATES-DB" in context["external_dependencies"] + + # D) THE HONESTY GUARDS: Verify warnings were passed AND cleaned + # The script has a specific line: `a.split(']', 1)[-1].strip() if ']' in a else a` + # We must prove this strip logic works so the LLM doesn't get confused by internal bracket tags + assert len(context["architectural_warnings"]) == 2 + assert context["architectural_warnings"][0] == "Do not use Java floats for currency, use BigDecimal." # The [CRITICAL] tag should be stripped! + assert context["architectural_warnings"][1] == "This module assumes EBCDIC encoding." \ No newline at end of file diff --git a/tests/test_batch_test_harness.py b/tests/test_batch_test_harness.py new file mode 100644 index 00000000..6d142e9d --- /dev/null +++ b/tests/test_batch_test_harness.py @@ -0,0 +1,102 @@ +import pytest +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch, MagicMock + +# Import your orchestrator script +from gitgalaxy.tools.cobol_to_java import batch_test_harness + +@pytest.fixture +def mock_env(tmp_path): + """ + Sets up a fake directory structure in the OS temp folder. + This ensures the harness doesn't exit early when it globs for output folders. + """ + corpus = tmp_path / "legacy_corpus" + corpus.mkdir() + + # Create one target repository + repo1 = corpus / "alpha_repo" + repo1.mkdir() + + # Create the fake output folders the harness globs for in Steps 1 and 2 + (corpus / "alpha_repo_gitgalaxy_clean_v1").mkdir() + (corpus / "alpha_repo_gitgalaxy_java_spring_v1").mkdir() + + return corpus + +@patch("gitgalaxy.tools.cobol_to_java.batch_test_harness.subprocess.run") +def test_happy_path(mock_run, mock_env): + """ + Simulates a flawless run where Refractor, Java Forge, and Maven all succeed. + """ + # 1. Setup the mock to always return a successful execution + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Process completed successfully." + mock_result.stderr = "" + mock_run.return_value = mock_result + + # 2. Patch sys.argv to simulate running the script from the CLI + test_args = ["batch_test_harness.py", str(mock_env)] + with patch.object(sys, 'argv', test_args): + batch_test_harness.main() + + # 3. Assert subprocess was called exactly 3 times for our 1 repo + assert mock_run.call_count == 3 + + # 4. Verify the master log was created and contains the correct sample size + reports = list(mock_env.glob("batch_test_reports/master_batch_run_*.txt")) + assert len(reports) == 1 + content = reports[0].read_text(encoding="utf-8") + assert "Sample Size: 1" in content + +@patch("gitgalaxy.tools.cobol_to_java.batch_test_harness.subprocess.run") +def test_maven_failure_path(mock_run, mock_env): + """ + Simulates Steps 1 & 2 succeeding, but Step 3 (Maven) failing with a compile error. + """ + # 1. Create two distinct mock objects + success_mock = MagicMock(returncode=0, stdout="OK", stderr="") + failure_mock = MagicMock(returncode=1, stdout="[ERROR] COMPILATION ERROR", stderr="Fatal flaw in Java") + + # 2. Use side_effect to return them in sequence (Success, Success, Fail) + mock_run.side_effect = [success_mock, success_mock, failure_mock] + + test_args = ["batch_test_harness.py", str(mock_env)] + with patch.object(sys, 'argv', test_args): + batch_test_harness.main() + + # 3. Verify the specific repo error log was created and caught the Maven stdout + error_logs = list(mock_env.glob("batch_test_reports/alpha_repo_error_*.log")) + assert len(error_logs) == 1 + error_content = error_logs[0].read_text(encoding="utf-8") + + assert "--- MAVEN STDERR/STDOUT ---" in error_content + assert "[ERROR] COMPILATION ERROR" in error_content + +@patch("gitgalaxy.tools.cobol_to_java.batch_test_harness.subprocess.run") +def test_timeout_path(mock_run, mock_env): + """ + Simulates the external script hanging and triggering the 5-minute kill switch. + """ + # 1. Force subprocess.run to raise the specific Timeout exception + mock_run.side_effect = subprocess.TimeoutExpired( + cmd="python cobol_refractor_controller.py", + timeout=300, + output=b"Partial refactor log before the freeze..." + ) + + test_args = ["batch_test_harness.py", str(mock_env)] + with patch.object(sys, 'argv', test_args): + batch_test_harness.main() + + # 2. Verify the harness caught the timeout safely and wrote it to the error log + error_logs = list(mock_env.glob("batch_test_reports/alpha_repo_error_*.log")) + assert len(error_logs) == 1 + error_content = error_logs[0].read_text(encoding="utf-8") + + assert "TIMEOUT: Command exceeded 5 minutes" in error_content + # Ensure it successfully decoded the partial output byte string + assert "Partial refactor log before the freeze..." in error_content \ No newline at end of file diff --git a/tests/test_decoder_forge.py b/tests/test_decoder_forge.py new file mode 100644 index 00000000..e0a9de46 --- /dev/null +++ b/tests/test_decoder_forge.py @@ -0,0 +1,103 @@ +import pytest +from gitgalaxy.tools.cobol_to_java.cobol_to_java_decoder_forge import generate_decoder_util + +# ============================================================================== +# GOLDEN IMAGE (The "Perfect" Expected Output) +# ============================================================================== +GOLDEN_DECODER_UTIL = """package com.gitgalaxy.modernized.util; + +import java.math.BigDecimal; +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class EbcdicDecoderUtil { + + private static final Logger log = LoggerFactory.getLogger(EbcdicDecoderUtil.class); + + // Cp1047 is the standard IBM EBCDIC character set + private static final Charset EBCDIC_CHARSET = Charset.forName("Cp1047"); + + /** + * Decodes a raw EBCDIC byte array into a standard Java UTF-8 String. + */ + public static String decodeEbcdicString(byte[] ebcdicBytes) { + if (ebcdicBytes == null) return null; + try { + return new String(ebcdicBytes, EBCDIC_CHARSET).trim(); + } catch (Exception e) { + log.error("Failed to decode EBCDIC string", e); + return ""; + } + } + + /** + * Unpacks a COBOL COMP-3 (Packed Decimal) byte array into a Java BigDecimal. + * Includes strict hex-boundary validation to prevent runtime crashes from dirty legacy data. + */ + public static BigDecimal unpackComp3(byte[] packedBytes, int scale) { + if (packedBytes == null || packedBytes.length == 0) { + return BigDecimal.ZERO; + } + + try { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < packedBytes.length; i++) { + int b = packedBytes[i] & 0xFF; + + // Extract the high and low nibbles (4 bits each) + int highNibble = b >>> 4; + int lowNibble = b & 0x0F; + + // The high nibble MUST be a number (0-9) + if (highNibble > 9) { + log.warn("Corrupt COMP-3 high nibble '{}' at byte index {}. Defaulting to ZERO.", Integer.toHexString(highNibble), i); + return BigDecimal.ZERO; + } + sb.append(highNibble); + + // The low nibble is a number EXCEPT in the very last byte, where it's the sign + if (i == packedBytes.length - 1) { + boolean isNegative = (lowNibble == 0x0D || lowNibble == 0x0B); + if (isNegative) { + sb.insert(0, "-"); + } else if (lowNibble < 0x0A) { + // The sign nibble should be A-F. If it's a number, the data is likely shifted or corrupt. + log.warn("Suspicious COMP-3 sign nibble '{}' at end of byte array.", Integer.toHexString(lowNibble)); + } + } else { + if (lowNibble > 9) { + log.warn("Corrupt COMP-3 low nibble '{}' at byte index {}. Defaulting to ZERO.", Integer.toHexString(lowNibble), i); + return BigDecimal.ZERO; + } + sb.append(lowNibble); + } + } + + BigDecimal result = new BigDecimal(sb.toString()); + return result.movePointLeft(scale); + + } catch (Exception e) { + log.error("Critical failure unpacking COMP-3 bytes. Defaulting to ZERO.", e); + return BigDecimal.ZERO; + } + } +}""" + +# ============================================================================== +# THE TESTS +# ============================================================================== + +def test_ebcdic_comp3_decoder_golden_image(): + """ + Verifies that the generated EBCDIC and COMP-3 decoding logic perfectly matches + the mathematically proven Golden Image. This prevents fatal regressions in + mainframe bitwise unpacking logic. + """ + # 1. Generate the code + generated_java = generate_decoder_util("com.gitgalaxy.modernized") + + # 2. Compare against the Golden Image + # Using strip() to neutralize OS-level newline differences (CRLF vs LF) + assert generated_java.strip() == GOLDEN_DECODER_UTIL.strip(), \ + "FATAL REGRESSION: The Mainframe Decoder utility drifted from the Golden Image! Check bitwise math and hex boundaries." \ No newline at end of file diff --git a/tests/test_golden_forge.py b/tests/test_golden_forge.py new file mode 100644 index 00000000..09363368 --- /dev/null +++ b/tests/test_golden_forge.py @@ -0,0 +1,113 @@ +import pytest +import json + +# Import your Forge generators +from gitgalaxy.tools.cobol_to_java.cobol_to_java_api_contract_forge import generate_rest_controller +from gitgalaxy.tools.cobol_to_java.cobol_to_java_spring_forge import generate_java_entity + +# ============================================================================== +# INLINE FIXTURES (The "Known Good" Inputs) +# ============================================================================== +MOCK_IR_STATE = { + "metadata": {"file_name": "process-payroll.cbl"}, + "analysis": { + "base_intent": {"files_requested": [], "is_cics": False}, + "lineage": { + "inputs": ["EMPLOYEE-RECORD", "TIMECARD-DATA"], + "outputs": ["PAYROLL-RECEIPT"] + } + } +} + +MOCK_SCHEMA_STATE = { + "title": "EMPLOYEE_TABLE", + "properties": { + "EMP-ID": {"type": "integer", "description": "PIC 9(6)"}, + "EMP-NAME": {"type": "string", "description": "PIC X(50)"}, + "SALARY": {"type": "decimal", "description": "PIC 9(5)V99"} + } +} + +# ============================================================================== +# GOLDEN IMAGES (The "Perfect" Expected Outputs) +# ============================================================================== +GOLDEN_CONTROLLER = """package com.gitgalaxy.modernized.controller; + +import org.springframework.web.bind.annotation.*; +import org.springframework.http.ResponseEntity; +import lombok.RequiredArgsConstructor; +import com.gitgalaxy.modernized.service.ProcessPayrollService; + +@RestController +@RequestMapping("/api/v1/process-payroll") +@RequiredArgsConstructor +public class ProcessPayrollController { + + private final ProcessPayrollService processPayrollService; + + @PostMapping("/execute") + public ResponseEntity executeProcessPayroll( @RequestBody EmployeeRecordDTO employeeRecordData, + @RequestBody TimecardDataDTO timecardDataData ) { // ⚡ TRANSACTIONAL PARADIGM DETECTED + processPayrollService.executeProcessPayroll(/* pass DTOs here */); + + // Expected Outputs: PAYROLL-RECEIPT + return ResponseEntity.ok().build(); } +}""" + +GOLDEN_ENTITY = """package com.gitgalaxy.modernized.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Data +@NoArgsConstructor +@Entity +@Table(name = "EMPLOYEE_TABLE") +public class EmployeeTable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sys_id") + private Long sysId; + + @Column(name = "EMP-ID") + private Integer empId; + + @Column(name = "EMP-NAME", length = 50) + private String empName; + + @Column(name = "SALARY", precision = 7, scale = 2) + private BigDecimal salary; + +}""" + +# ============================================================================== +# THE TESTS +# ============================================================================== + +def test_api_contract_golden_image(): + """ + Feeds a known IR state into the API Contract Forge and verifies the + resulting Java code matches our Golden Image byte-for-byte. + """ + # 1. Generate the code using the mock IR + generated_java = generate_rest_controller(MOCK_IR_STATE, "com.gitgalaxy.modernized") + + # 2. Compare against the Golden Image + # We collapse whitespace to prevent OS line-ending differences (CRLF vs LF) from failing the test + assert " ".join(generated_java.split()) == " ".join(GOLDEN_CONTROLLER.split()), \ + "API Contract generation drifted from the Golden Image! Did someone alter the string formatting?" + +def test_spring_entity_golden_image(): + """ + Feeds a known Schema state into the Spring Entity Forge and verifies the + resulting JPA Entity (with PIC constraints) matches our Golden Image. + """ + # 1. Generate the entity using the mock schema + generated_java = generate_java_entity(MOCK_SCHEMA_STATE, "com.gitgalaxy.modernized") + + # 2. Compare against the Golden Image + assert " ".join(generated_java.split()) == " ".join(GOLDEN_ENTITY.split()), \ + "Spring Entity generation drifted from the Golden Image! Check PIC clause parsing logic." \ No newline at end of file diff --git a/tests/test_service_forge.py b/tests/test_service_forge.py new file mode 100644 index 00000000..d101176b --- /dev/null +++ b/tests/test_service_forge.py @@ -0,0 +1,60 @@ +import pytest +from gitgalaxy.tools.cobol_to_java.cobol_to_java_service_forge import generate_service_skeleton + +# ============================================================================== +# INLINE FIXTURES +# ============================================================================== +MOCK_IR_STATE = { + "metadata": { + "file_name": "payroll-processor.cbl" + }, + "analysis": { + "lineage": { + # Two distinct COBOL-style program names with hyphens + "unresolved_calls": ["CALC-BENEFITS", "UPDATE-LEDGER"] + } + } +} + +# ============================================================================== +# GOLDEN IMAGE +# ============================================================================== +GOLDEN_SERVICE_SKELETON = """package com.gitgalaxy.modernized.service; + +import org.springframework.stereotype.Service; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Service +@RequiredArgsConstructor +public class PayrollProcessorService { + + private static final Logger log = LoggerFactory.getLogger(PayrollProcessorService.class); + + // ⚠️ UNRESOLVED EXTERNAL DEPENDENCIES (FROM DAG) + // TODO: AI AGENT - Implement or mock call to: CalcBenefitsService + // TODO: AI AGENT - Implement or mock call to: UpdateLedgerService + + public void executePayrollProcessor(/* Parameters mapped from Controller */) { + log.info("Executing legacy business logic for payroll-processor"); + // TODO: [AI AGENT] Implement extracted business rules here. + } +}""" + +# ============================================================================== +# THE TESTS +# ============================================================================== + +def test_service_skeleton_dag_resolver(): + """ + Feeds a mock IR state with unresolved COBOL calls into the Service Forge. + Verifies that the Python script correctly translates COBOL hyphens into + Java CamelCase so the downstream AI Agent knows exactly what to auto-wire. + """ + # 1. Generate the code using the mock IR + generated_java = generate_service_skeleton(MOCK_IR_STATE, "com.gitgalaxy.modernized") + + # 2. Compare against the Golden Image + assert generated_java.strip() == GOLDEN_SERVICE_SKELETON.strip(), \ + "Service Forge drifted from the Golden Image! Did the CamelCase/Hyphen parsing break?" \ No newline at end of file