bc1d56d1a4
P0.6: gcov infrastructure P1: extract_structure output expansion (11 new feature fields) P2: Confusion group rule engine (8 pairs + contradiction + backtrack) P3: 4-factor confidence calculation + quality gate update P4: 33+2 COBOL program type test samples (22 files, 7 categories) P5: parametrized/ test data generation engine P6: japanese_data.py lookup tables P7-10: Type-specific test suites (~159 parametrized tests) P11: Full classification pipeline (classify_program) + orchestrator integration P12: Documentation (module-interfaces, test-plan v3.0, coverage-matrix) Architecture decisions: - classification_pipeline/ merged to hina/pipeline/ - parametrized/ as independent module - japanese_data.py as root-level file - hina/__all__ only exports classify_program() Co-Authored-By: Claude <noreply@anthropic.com>
470 lines
14 KiB
Python
470 lines
14 KiB
Python
"""JC-101~130: Deep JCL parser testing
|
|
|
|
Covers COND variations, DD statement variants, control statements,
|
|
error recovery, tokenization edge cases, and direct data class tests.
|
|
"""
|
|
import sys, os, tempfile
|
|
|
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
|
from jcl.parser import parse_jcl, CondParam, JobStep, Job, DDEntry
|
|
|
|
|
|
def _write_jcl(content: str) -> str:
|
|
"""Write JCL content to a temp file and return the file path."""
|
|
tmp = tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".jcl", delete=False, encoding="utf-8"
|
|
)
|
|
tmp.write(content)
|
|
tmp.close()
|
|
return tmp.name
|
|
|
|
|
|
# =====================================================================
|
|
# COND variations
|
|
# =====================================================================
|
|
|
|
def test_cond_basic():
|
|
"""JC-101: COND=(0,NE) -- basic return-code condition"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P,COND=(0,NE)")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps) == 1
|
|
step = job.steps[0]
|
|
assert step.cond is not None
|
|
assert step.cond.code == 0
|
|
assert step.cond.operator == "NE"
|
|
assert step.cond.step_name is None
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_cond_step_specific():
|
|
"""JC-102: COND=(0,NE,STEP1) -- step-specific condition
|
|
|
|
Current parser captures (code, op) only; the trailing step_name
|
|
is present in the JCL but not parsed into CondParam.step_name.
|
|
"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P,COND=(0,NE,STEP1)")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert job.steps[0].cond is not None
|
|
assert job.steps[0].cond.code == 0
|
|
assert job.steps[0].cond.operator == "NE"
|
|
# step_name is not parsed by the current regex
|
|
assert job.steps[0].cond.step_name is None
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_cond_even():
|
|
"""JC-103: COND=EVEN -- execute even if prior step fails
|
|
|
|
Current parser does not recognise the EVEN keyword;
|
|
cond remains None.
|
|
"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P,COND=EVEN")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert job.steps[0].cond is None
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_cond_only():
|
|
"""JC-104: COND=ONLY -- execute only if prior step fails
|
|
|
|
Current parser does not recognise the ONLY keyword;
|
|
cond remains None.
|
|
"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P,COND=ONLY")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert job.steps[0].cond is None
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_cond_compound():
|
|
"""JC-105: COND=((0,NE),(4,GT)) -- compound condition
|
|
|
|
Current parser's regex looks for a single parenthesised pair;
|
|
nested outer parens cause the match to fail, leaving cond=None.
|
|
"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P,COND=((0,NE),(4,GT))")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert job.steps[0].cond is None
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_cond_no_parens():
|
|
"""JC-106: COND=0 -- condition without parentheses
|
|
|
|
Current parser requires parentheses around (code,op);
|
|
bare COND=0 does not match and cond is None.
|
|
"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P,COND=0")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert job.steps[0].cond is None
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
# =====================================================================
|
|
# DD statement variations
|
|
# =====================================================================
|
|
|
|
def test_dd_dsn_only():
|
|
"""JC-107: DD with DSN only"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P\n//DD1 DD DSN=MY.DATA")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps[0].dd_entries) == 1
|
|
assert job.steps[0].dd_entries[0].dsn == "MY.DATA"
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_dd_dsn_disp():
|
|
"""JC-108: DD with DSN + DISP
|
|
|
|
Current parser extracts DSN but does not parse DISP;
|
|
the disp field remains None.
|
|
"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P\n//DD1 DD DSN=MY.DATA,DISP=SHR")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
dd = job.steps[0].dd_entries[0]
|
|
assert dd.dsn == "MY.DATA"
|
|
# disp is declared on DDEntry but not yet populated by the parser
|
|
assert dd.disp is None
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_dd_unit_vol():
|
|
"""JC-109: DD with UNIT + VOL -- attributes not extracted but DD entry created"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P\n//DD1 DD UNIT=SYSDA,VOL=SER=VOL001")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps[0].dd_entries) == 1
|
|
assert job.steps[0].dd_entries[0].dd_name == "DD1"
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_dd_space():
|
|
"""JC-110: DD with SPACE -- nested parens in SPACE value do not break parsing"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P\n//DD1 DD SPACE=(CYL,(10,5),RLSE)")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps[0].dd_entries) == 1
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_dd_dcb():
|
|
"""JC-111: DD with DCB -- nested parens in DCB value do not break parsing"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P\n//DD1 DD DCB=(LRECL=80,RECFM=FB)")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps[0].dd_entries) == 1
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_dd_all_attributes():
|
|
"""JC-112: DD with all common attributes combined on one line"""
|
|
jcl = (
|
|
"//J JOB\n"
|
|
"//S EXEC PGM=P\n"
|
|
"//DD1 DD DSN=MY.DATA,DISP=SHR,UNIT=SYSDA,"
|
|
"VOL=SER=VOL001,SPACE=(CYL,(10,5),RLSE),DCB=(LRECL=80,RECFM=FB)"
|
|
)
|
|
path = _write_jcl(jcl)
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
dd = job.steps[0].dd_entries[0]
|
|
assert dd.dsn == "MY.DATA"
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
# =====================================================================
|
|
# Control statements
|
|
# =====================================================================
|
|
|
|
def test_include_member():
|
|
"""JC-113: INCLUDE member silently skipped (not yet parsed)"""
|
|
path = _write_jcl("//J JOB\n// INCLUDE MEMBER=MYMEM\n//S EXEC PGM=P")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
# INCLUDE is ignored; only the EXEC step is present
|
|
assert len(job.steps) == 1
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_jes2_delimiter_inline():
|
|
"""JC-114: Inline data delimited by /* (JES2 delimiter)
|
|
|
|
Current parser recognises SYSIN DD * and captures lines until /*.
|
|
"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P\n//SYSIN DD *\nline1\n/*")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
dd = job.steps[0].dd_entries[-1]
|
|
assert dd.dd_name == "SYSIN"
|
|
assert dd.inline_data == ["line1"]
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_proc_call():
|
|
"""JC-115: PROC call via EXEC PROC=name
|
|
|
|
Current EXEC regex only handles PGM=; with PROC=, (?:PGM=)?
|
|
matches empty and the first \\w+ after EXEC is "PROC" rather
|
|
than the member name. The step is still created.
|
|
"""
|
|
path = _write_jcl("//J JOB\n//STEP1 EXEC PROC=MYPROC")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert job.steps[0].step_name == "STEP1"
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_proc_with_parm_override():
|
|
"""JC-116: PROC with PARM.C=VAL override"""
|
|
path = _write_jcl("//J JOB\n//STEP1 EXEC PROC=MYPROC,PARM.C=VAL")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert job.steps[0].step_name == "STEP1"
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
# =====================================================================
|
|
# Error recovery
|
|
# =====================================================================
|
|
|
|
def test_malformed_bad_keyword():
|
|
"""JC-117: Malformed line with unrecognised keyword does not crash"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P\n//DD1 DD BADKEYWORD=XYZ")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps[0].dd_entries) == 1
|
|
assert job.steps[0].dd_entries[0].dd_name == "DD1"
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_continuation_nothing_after():
|
|
"""JC-118: Continuation comma followed by a bare // line"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P\n//DD1 DD DSN=A,\n//")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
# The continuation merges the bare // onto the DD line;
|
|
# DSN extraction still works because the regex stops at comma.
|
|
dd = job.steps[0].dd_entries[0]
|
|
assert dd.dsn == "A"
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_only_comments_and_blanks():
|
|
"""JC-119: File with only comments and blank lines yields None"""
|
|
path = _write_jcl("//* THIS IS A COMMENT\n//* ANOTHER COMMENT\n\n")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is None
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_tokenization_variable_whitespace():
|
|
"""JC-120: Variable whitespace between tokens"""
|
|
path = _write_jcl("//J JOB\n//S EXEC PGM=P\n//DD1 DD DSN=MY.DATA")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps) == 1
|
|
assert job.steps[0].dd_entries[0].dsn == "MY.DATA"
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_tokenization_tabs():
|
|
"""JC-121: Tab characters instead of spaces"""
|
|
path = _write_jcl("//J\tJOB\n//S\tEXEC\tPGM=P\n//DD1\tDD\tDSN=MY.DATA")
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps) == 1
|
|
assert job.steps[0].dd_entries[0].dsn == "MY.DATA"
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
# =====================================================================
|
|
# Data class direct tests
|
|
# =====================================================================
|
|
|
|
def test_cond_param_direct():
|
|
"""JC-122: CondParam with code=0, operator='NE' """
|
|
c = CondParam(code=0, operator="NE")
|
|
assert c.code == 0
|
|
assert c.operator == "NE"
|
|
assert c.step_name is None
|
|
|
|
|
|
def test_cond_param_with_step():
|
|
"""JC-123: CondParam with step_name set"""
|
|
c = CondParam(code=4, operator="GT", step_name="STEP1")
|
|
assert c.code == 4
|
|
assert c.operator == "GT"
|
|
assert c.step_name == "STEP1"
|
|
|
|
|
|
def test_dd_entry_dsn_disp():
|
|
"""JC-124: DDEntry with dsn and disp"""
|
|
d = DDEntry(dd_name="DD1", dsn="MY.DATA", disp="SHR")
|
|
assert d.dd_name == "DD1"
|
|
assert d.dsn == "MY.DATA"
|
|
assert d.disp == "SHR"
|
|
assert d.sysout is None
|
|
assert d.inline_data == []
|
|
|
|
|
|
def test_dd_entry_inline_data():
|
|
"""JC-125: DDEntry with inline data"""
|
|
d = DDEntry(dd_name="SYSIN", inline_data=["line1", "line2"])
|
|
assert d.dd_name == "SYSIN"
|
|
assert d.inline_data == ["line1", "line2"]
|
|
|
|
|
|
def test_job_steps_append():
|
|
"""JC-126: Job with steps list append"""
|
|
j = Job("TESTJOB")
|
|
assert j.job_name == "TESTJOB"
|
|
assert len(j.steps) == 0
|
|
j.steps.append(JobStep("S1", "PGM1"))
|
|
j.steps.append(JobStep("S2", "PGM2"))
|
|
assert len(j.steps) == 2
|
|
assert j.steps[0].step_name == "S1"
|
|
assert j.steps[0].program == "PGM1"
|
|
assert j.steps[1].step_name == "S2"
|
|
assert j.steps[1].program == "PGM2"
|
|
|
|
|
|
def test_job_step_cond_dd():
|
|
"""JC-127: JobStep with cond and dd_entries lists"""
|
|
cond = CondParam(code=0, operator="NE")
|
|
dd1 = DDEntry(dd_name="SYSUT1", dsn="INPUT.DATA")
|
|
dd2 = DDEntry(dd_name="SYSUT2", dsn="OUTPUT.DATA", disp="OLD")
|
|
step = JobStep(step_name="S1", program="PGM1", cond=cond)
|
|
step.dd_entries.append(dd1)
|
|
step.dd_entries.append(dd2)
|
|
assert step.step_name == "S1"
|
|
assert step.program == "PGM1"
|
|
assert step.cond is not None
|
|
assert step.cond.code == 0
|
|
assert step.cond.operator == "NE"
|
|
assert len(step.dd_entries) == 2
|
|
assert step.dd_entries[0].dd_name == "SYSUT1"
|
|
assert step.dd_entries[0].dsn == "INPUT.DATA"
|
|
assert step.dd_entries[1].dd_name == "SYSUT2"
|
|
assert step.dd_entries[1].dsn == "OUTPUT.DATA"
|
|
assert step.dd_entries[1].disp == "OLD"
|
|
|
|
|
|
# =====================================================================
|
|
# Additional edge cases
|
|
# =====================================================================
|
|
|
|
def test_multi_step_with_cond():
|
|
"""JC-128: Multiple steps, each with a condition"""
|
|
path = _write_jcl(
|
|
"//J JOB\n"
|
|
"//STEP1 EXEC PGM=PGM1,COND=(0,NE)\n"
|
|
"//STEP2 EXEC PGM=PGM2,COND=(4,GT)\n"
|
|
"//STEP3 EXEC PGM=PGM3"
|
|
)
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps) == 3
|
|
assert job.steps[0].step_name == "STEP1"
|
|
assert job.steps[0].cond is not None
|
|
assert job.steps[0].cond.code == 0
|
|
assert job.steps[0].cond.operator == "NE"
|
|
assert job.steps[1].cond is not None
|
|
assert job.steps[1].cond.code == 4
|
|
assert job.steps[1].cond.operator == "GT"
|
|
assert job.steps[2].cond is None
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_dd_multiple_entries():
|
|
"""JC-129: Multiple DD entries under one step"""
|
|
path = _write_jcl(
|
|
"//J JOB\n"
|
|
"//S EXEC PGM=P\n"
|
|
"//DD1 DD DSN=IN.DATA,DISP=SHR\n"
|
|
"//DD2 DD DSN=OUT.DATA,DISP=OLD\n"
|
|
"//DD3 DD DUMMY\n"
|
|
)
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps[0].dd_entries) == 3
|
|
assert job.steps[0].dd_entries[0].dd_name == "DD1"
|
|
assert job.steps[0].dd_entries[0].dsn == "IN.DATA"
|
|
assert job.steps[0].dd_entries[1].dd_name == "DD2"
|
|
assert job.steps[0].dd_entries[1].dsn == "OUT.DATA"
|
|
assert job.steps[0].dd_entries[2].dd_name == "DD3"
|
|
assert job.steps[0].dd_entries[2].dsn is None
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
def test_cond_even_only_not_captured():
|
|
"""JC-130: COND=EVEN and COND=ONLY -- explicit check that cond is None"""
|
|
path = _write_jcl(
|
|
"//J JOB\n"
|
|
"//S1 EXEC PGM=P,COND=EVEN\n"
|
|
"//S2 EXEC PGM=P,COND=ONLY"
|
|
)
|
|
try:
|
|
job = parse_jcl(path)
|
|
assert job is not None
|
|
assert len(job.steps) == 2
|
|
assert job.steps[0].step_name == "S1"
|
|
assert job.steps[0].cond is None # EVEN not parsed
|
|
assert job.steps[1].step_name == "S2"
|
|
assert job.steps[1].cond is None # ONLY not parsed
|
|
finally:
|
|
os.unlink(path)
|