diff --git a/gitgalaxy/tools/cobol_to_java/cobol_to_java_batch_forge.py b/gitgalaxy/tools/cobol_to_java/cobol_to_java_batch_forge.py index e5243294b6..3c0ba34332 100644 --- a/gitgalaxy/tools/cobol_to_java/cobol_to_java_batch_forge.py +++ b/gitgalaxy/tools/cobol_to_java/cobol_to_java_batch_forge.py @@ -209,7 +209,10 @@ def sources(self) -> dict[tuple[str, ...], dict[str, str]]: if not self.applications: return {} pkg = f"{self.package}.{SUBPACKAGE}" - out = {name: text.replace("{pkg}", pkg) for name, text in _RUNTIME.items()} + out = { + name: text.replace("{pkg}", pkg).replace("{zone}", self.target.culture.zone) + for name, text in _RUNTIME.items() + } if self.target.features.rest_controllers: out["BatchJobController"] = _CONTROLLER.replace("{pkg}", pkg) for j in self.applications: @@ -380,23 +383,41 @@ def audit_line(self) -> str: "MainframeClock": """package {pkg}; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; /** The time a ported program reads -- FUNCTION CURRENT-DATE, ACCEPT FROM DATE / TIME, EXEC CICS ASKTIME * (#3753): `gitgalaxy.clock` (an ISO local date-time, e.g. 2022-07-18T10:30:15.00) when set -- the - * equivalence harness pins it, so a run can be compared with the original's -- else the system clock. */ + * equivalence harness pins it, so a run can be compared with the original's -- else the system clock. + * #3824: the mainframe's zone is `gitgalaxy.zone` (default {zone}, the target's culture.zone), never the JVM's; + * currentDate() carries its UTC offset. A pinned local time that does not exist in the zone (a DST gap, e.g. + * 2022-03-27T02:30 in Europe/Berlin) resolves by ZonedDateTime.ofLocal's rule: it moves forward. */ @Component public class MainframeClock { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSZ"); private final String pinned; + private final ZoneId zone; - public MainframeClock(@Value("${gitgalaxy.clock:}") String pinned) { + public MainframeClock(@Value("${gitgalaxy.clock:}") String pinned, + @Value("${gitgalaxy.zone:{zone}}") String zoneId) { this.pinned = pinned == null ? "" : pinned.trim(); + this.zone = ZoneId.of(zoneId == null || zoneId.trim().isEmpty() ? "{zone}" : zoneId.trim()); } public LocalDateTime now() { - return pinned.isEmpty() ? LocalDateTime.now() : LocalDateTime.parse(pinned); + return zonedNow().toLocalDateTime(); + } + + public ZonedDateTime zonedNow() { + return pinned.isEmpty() ? ZonedDateTime.now(zone) : ZonedDateTime.of(LocalDateTime.parse(pinned), zone); + } + + public String currentDate() { + return zonedNow().format(FORMATTER); } } """, diff --git a/gitgalaxy/tools/cobol_to_java/cobol_to_java_db2_forge.py b/gitgalaxy/tools/cobol_to_java/cobol_to_java_db2_forge.py index 6e7ef6f2e6..b57f5b490b 100644 --- a/gitgalaxy/tools/cobol_to_java/cobol_to_java_db2_forge.py +++ b/gitgalaxy/tools/cobol_to_java/cobol_to_java_db2_forge.py @@ -51,15 +51,15 @@ "SMALLINT": "Integer", "INTEGER": "Integer", "INT": "Integer", "BIGINT": "Long", "DECIMAL": "BigDecimal", "DEC": "BigDecimal", "NUMERIC": "BigDecimal", "REAL": "Double", "FLOAT": "Double", "DOUBLE": "Double", "DOUBLE PRECISION": "Double", "DECFLOAT": "BigDecimal", - "DATE": "LocalDate", "TIME": "LocalTime", "TIMESTAMP": "LocalDateTime", + "DATE": "LocalDate", "TIME": "LocalTime", "TIMESTAMP": "LocalDateTime", "TIMESTAMP WITH TIME ZONE": "OffsetDateTime", "BLOB": "byte[]", "BINARY": "byte[]", "VARBINARY": "byte[]", } # fmt: skip _TIME_IMPORTS = {"LocalDate": "java.time.LocalDate", "LocalTime": "java.time.LocalTime", - "LocalDateTime": "java.time.LocalDateTime"} # fmt: skip + "LocalDateTime": "java.time.LocalDateTime", "OffsetDateTime": "java.time.OffsetDateTime"} # fmt: skip def sql_java_type(sql_type: str) -> str: - """The Java type of a declared DB2 column type (`TIMESTAMP WITH TIME ZONE` -> LocalDateTime).""" + """The Java type of a declared DB2 column type (`TIMESTAMP WITH TIME ZONE` -> OffsetDateTime).""" t = " ".join(sql_type.upper().split()) for name in sorted(_SQL_TYPES, key=len, reverse=True): if t == name or t.startswith(name + " ") or t.startswith(name + "("): diff --git a/gitgalaxy/tools/cobol_to_java/cobol_to_java_port_tickets.py b/gitgalaxy/tools/cobol_to_java/cobol_to_java_port_tickets.py index 75e3988fce..f0080d199e 100644 --- a/gitgalaxy/tools/cobol_to_java/cobol_to_java_port_tickets.py +++ b/gitgalaxy/tools/cobol_to_java/cobol_to_java_port_tickets.py @@ -106,8 +106,9 @@ "as moved, an edited PICTURE formatted as COBOL formats it." ), ( - "Read the time only from the generated batch runtime's MainframeClock (now()), never from the " - "system clock directly: it is how a run is pinned to be compared with the original." + "Read the time only from the generated batch runtime's MainframeClock (now() for local, currentDate() for " + "FUNCTION CURRENT-DATE), never from the system clock directly or ZoneId.systemDefault(): it is how a run is " + "pinned to be compared with the original." ), ( "A fact whose field testing is not 'field-tested' is verified on reference estates but still being " diff --git a/gitgalaxy/tools/cobol_to_java/java_target.py b/gitgalaxy/tools/cobol_to_java/java_target.py index 2120c0aeac..a533df3b98 100644 --- a/gitgalaxy/tools/cobol_to_java/java_target.py +++ b/gitgalaxy/tools/cobol_to_java/java_target.py @@ -165,7 +165,7 @@ class Culture: rounding: str = "cobol" # TODO(#3825): ROUNDED / ROUNDED MODE in generated arithmetic and porting rules overflow: str = "cobol" # TODO(#3825): high-order truncation vs ON SIZE ERROR - zone: str = "UTC" # TODO(#3824): MainframeClock's zone and CURRENT-DATE's UTC offset + zone: str = "UTC" # #3824: MainframeClock's zone and CURRENT-DATE's UTC offset format_locale: str = "ROOT" # generated formatting / case mapping (#3823 made it Locale.ROOT) display_locale: str = "ROOT" # TODO(#3819): screens only -- never used for stored data decimal_point: str = "auto" # TODO(#3827): DECIMAL-POINT IS COMMA in edited PICTUREs and schemas diff --git a/tests/cobol_mainframe/test_db2_repositories.py b/tests/cobol_mainframe/test_db2_repositories.py index e8e17a04da..13d76c2986 100644 --- a/tests/cobol_mainframe/test_db2_repositories.py +++ b/tests/cobol_mainframe/test_db2_repositories.py @@ -85,7 +85,7 @@ def test_to_jdbc_names_parameters_and_drops_the_singleton_into(): @pytest.mark.parametrize( "sql_type, java", [("CHAR", "String"), ("VARCHAR", "String"), ("DECIMAL", "BigDecimal"), ("INTEGER", "Integer"), - ("BIGINT", "Long"), ("DATE", "LocalDate"), ("TIMESTAMP WITH TIME ZONE", "LocalDateTime"), ("BLOB", "byte[]"), + ("BIGINT", "Long"), ("DATE", "LocalDate"), ("TIMESTAMP WITH TIME ZONE", "OffsetDateTime"), ("BLOB", "byte[]"), ("SOMETHING ODD", "String")], ) # fmt: skip def test_sql_types_map_to_java(sql_type, java): diff --git a/tests/cobol_mainframe/test_time_zones.py b/tests/cobol_mainframe/test_time_zones.py new file mode 100644 index 0000000000..5cb12b2a8b --- /dev/null +++ b/tests/cobol_mainframe/test_time_zones.py @@ -0,0 +1,128 @@ +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from gitgalaxy.tools.cobol_to_java.cobol_to_java_batch_forge import BatchForge +from gitgalaxy.tools.cobol_to_java.cobol_to_java_db2_forge import sql_java_type +from gitgalaxy.tools.cobol_to_java.java_target import load_target + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tests" / "tools")) +import equivalence as eq +from equivalence_java import equivalence_test + + +def get_forge(target): + return BatchForge( + {"P": {"program": {"file": "f", "program_ids": ["PROG"]}}}, + { + "sections": { + "job_steps": {"facts": [{"file": "f", "steps": [{"step": "s", "ordinal": 1, "program": "PROG"}]}]} + } + }, + "com.gitgalaxy", + target, + ) + + +def test_mainframe_clock_generation(): + target = load_target(None) + target.culture.zone = "Asia/Kolkata" + forge = get_forge(target) + src = forge.sources()[("base_pkg", "batch")]["MainframeClock"] + assert "gitgalaxy.zone:Asia/Kolkata" in src + assert "ZonedDateTime zonedNow()" in src + assert "String currentDate()" in src + + +def test_mainframe_clock_default_zone(): + target = load_target(None) + forge = get_forge(target) + src = forge.sources()[("base_pkg", "batch")]["MainframeClock"] + assert "gitgalaxy.zone:UTC" in src + + +def test_db2_timestamp_types(): + assert sql_java_type("TIMESTAMP WITH TIME ZONE") == "OffsetDateTime" + assert sql_java_type("TIMESTAMP") == "LocalDateTime" + + +def test_equivalence_harness_zone_plumbing(tmp_path, monkeypatch): + case_with_zone = { + "name": "c", + "program": "P", + "datasets": {}, + "clock": "2022/01/01 12:00:00.00", + "zone": "Europe/Berlin", + } + case_no_zone = {"name": "c", "program": "P", "datasets": {}, "clock": "2022/01/01 12:00:00.00"} + + java_src = equivalence_test(case_with_zone) + assert '"gitgalaxy.zone=Europe/Berlin"' in java_src + + java_src_no = equivalence_test(case_no_zone) + assert '"gitgalaxy.zone=UTC"' in java_src_no + + def fake_run(*args, **kwargs): + work_dir = args[0][4].split(":")[0] + (Path(work_dir) / "RETURN-CODE").write_text("0") + + class Proc: + returncode = 0 + stdout = "" + stderr = "" + + return Proc() + + monkeypatch.setattr(eq.subprocess, "run", fake_run) + + case_with_zone["program_source"] = "P.cbl" + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "P.cbl").write_text(" ") + + work = tmp_path / "work" + eq.run_cobol(case_with_zone, corpus, work) + script = (work / "run.sh").read_text() + assert "TZ='Europe/Berlin'" in script + + work2 = tmp_path / "work2" + case_no_zone["program_source"] = "P.cbl" + eq.run_cobol(case_no_zone, corpus, work2) + script2 = (work2 / "run.sh").read_text() + assert "TZ=" not in script2 + + +@pytest.mark.skipif(not shutil.which("javac"), reason="No JDK") +def test_mainframe_clock_compilation_and_run(tmp_path): + target = load_target(None) + target.culture.zone = "Asia/Kolkata" + forge = get_forge(target) + src = forge.sources()[("base_pkg", "batch")]["MainframeClock"] + + src = src.replace("@Component", "") + src = src.replace('@Value("${gitgalaxy.clock:}")', "") + src = src.replace('@Value("${gitgalaxy.zone:Asia/Kolkata}")', "") + src = src.replace("import org.springframework.beans.factory.annotation.Value;", "") + src = src.replace("import org.springframework.stereotype.Component;", "") + + main = """ + public static void main(String[] args) { + MainframeClock clock = new MainframeClock("2022-07-18T10:30:15.00", "Asia/Kolkata"); + System.out.println(clock.currentDate()); + } + """ + src = src.replace("public class MainframeClock {", "public class MainframeClock {" + main) + + pkg_dir = tmp_path / "com" / "gitgalaxy" / "batch" + pkg_dir.mkdir(parents=True) + java_file = pkg_dir / "MainframeClock.java" + java_file.write_text(src) + + subprocess.run(["javac", str(java_file)], check=True) + res = subprocess.run( + ["java", "-cp", str(tmp_path), "com.gitgalaxy.batch.MainframeClock"], capture_output=True, text=True, check=True + ) + assert res.stdout.strip() == "2022071810301500+0530" diff --git a/tests/tools/equivalence.py b/tests/tools/equivalence.py index bafdd4562f..575e2a8f90 100644 --- a/tests/tools/equivalence.py +++ b/tests/tools/equivalence.py @@ -160,8 +160,9 @@ def run_cobol(case: dict[str, Any], corpus: Path, work: Path) -> dict[str, bytes script.append(f"cobc -x {flags} -o program src/EQDRIVER.cbl src/PROGRAM.cbl") env = " ".join(f"{dd}=/work/{dd}.idx" for dd in case["datasets"]) clock = f"COB_CURRENT_DATE='{case['clock']}' " if case.get("clock") else "" + tz = f"TZ='{case['zone']}' " if case.get("zone") else "" # the step's RETURN-CODE is an output like any other (CBTRN02C sets 4 when it rejects): recorded, not fatal - script.append(f"set +e; {clock}{env} ./program > /work/stdout.txt 2>&1; echo $? > /work/RETURN-CODE; set -e") + script.append(f"set +e; {tz}{clock}{env} ./program > /work/stdout.txt 2>&1; echo $? > /work/RETURN-CODE; set -e") for dd, spec in case["datasets"].items(): if spec.get("compare") and spec.get("organization") == "indexed": script.append(f"{dd}=/work/{dd}.idx OUTFILE=/work/{dd}.out ./ul{dd}") diff --git a/tests/tools/equivalence_java.py b/tests/tools/equivalence_java.py index 93c207dbf8..88b9422735 100644 --- a/tests/tools/equivalence_java.py +++ b/tests/tools/equivalence_java.py @@ -88,7 +88,7 @@ def equivalence_test(case: dict[str, Any]) -> str: /** #3624: the equivalence run of {case["program"]} ({case["name"]}) -- generated by tests/tools/equivalence_java.py. */ @SpringBootTest(properties = {{"spring.jpa.show-sql=false", "gitgalaxy.clock={_clock(case)}", - "gitgalaxy.datasets.directory=${{equivalence.datasets}}"}}) + "gitgalaxy.zone={case.get("zone", "UTC")}", "gitgalaxy.datasets.directory=${{equivalence.datasets}}"}}) class EquivalenceRunTest {{ static final Charset TEXT = StandardCharsets.ISO_8859_1;