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