Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion gitgalaxy/tools/cobol_to_java/cobol_to_java_build_forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,32 @@

from gitgalaxy.tools.cobol_to_java.java_target import JavaTarget

# #3619: springdoc is not in Spring Boot's dependency management, so it carries its own version
# (the 2.x line is the Spring Boot 3 / jakarta one).
SPRINGDOC_VERSION = "2.5.0"

def _dependency(group: str, artifact: str, scope: Optional[str] = None, optional: bool = False) -> str:

def ui_dependencies(t: JavaTarget) -> list[tuple[str, str, Optional[str]]]:
"""(group, artifact, version or None) the BMS screens' UI flavour needs (#3619)."""
if t.ui.flavour == "thymeleaf":
return [("org.springframework.boot", "spring-boot-starter-thymeleaf", None),
("org.springframework.boot", "spring-boot-starter-validation", None)] # fmt: skip
if t.ui.flavour == "openapi-only":
return [("org.springframework.boot", "spring-boot-starter-validation", None),
("org.springdoc", "springdoc-openapi-starter-webmvc-ui", SPRINGDOC_VERSION)] # fmt: skip
return []


def _dependency(
group: str, artifact: str, scope: Optional[str] = None, optional: bool = False, version: Optional[str] = None
) -> str:
lines = [
" <dependency>",
f" <groupId>{group}</groupId>",
f" <artifactId>{artifact}</artifactId>",
]
if version:
lines.append(f" <version>{version}</version>")
if scope:
lines.append(f" <scope>{scope}</scope>")
if optional:
Expand All @@ -49,6 +68,7 @@ def generate_pom_xml(group_id: str, artifact_id: str, target: Optional[JavaTarge
]
if t.features.batch:
deps.append(_dependency("org.springframework.boot", "spring-boot-starter-batch"))
deps += [_dependency(g, a, version=v) for g, a, v in ui_dependencies(t)] # #3619
deps.append(_dependency(drv_group, drv_artifact, scope="runtime"))
if t.lombok:
deps.append(_dependency("org.projectlombok", "lombok", optional=True))
Expand Down Expand Up @@ -123,6 +143,7 @@ def generate_build_gradle(group_id: str, target: JavaTarget) -> str:
]
if t.features.batch:
deps.append(" implementation 'org.springframework.boot:spring-boot-starter-batch'")
deps += [f" implementation '{g}:{a}{':' + v if v else ''}'" for g, a, v in ui_dependencies(t)] # #3619
deps.append(f" runtimeOnly '{drv_group}:{drv_artifact}'")
if t.lombok:
deps += [" compileOnly 'org.projectlombok:lombok'", " annotationProcessor 'org.projectlombok:lombok'"]
Expand Down
548 changes: 548 additions & 0 deletions gitgalaxy/tools/cobol_to_java/cobol_to_java_screen_forge.py

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions gitgalaxy/tools/cobol_to_java/cobol_to_java_skeleton_forges.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from gitgalaxy.tools.cobol_to_java.cobol_to_java_common import ClassNames, TraceLog, merge_extras
from gitgalaxy.tools.cobol_to_java.cobol_to_java_db2_forge import Db2Forge
from gitgalaxy.tools.cobol_to_java.cobol_to_java_repository_forge import RepositoryForge
from gitgalaxy.tools.cobol_to_java.cobol_to_java_screen_forge import ScreenForge
from gitgalaxy.tools.cobol_to_java.cobol_to_java_transaction_forge import CicsForge, CicsProgram, load_skeletons
from gitgalaxy.tools.cobol_to_java.cobol_to_java_uow_forge import UowForge
from gitgalaxy.tools.cobol_to_java.java_target import JavaTarget
Expand All @@ -50,6 +51,7 @@ def __init__(self, skeleton_dir: Path, package: str, target: JavaTarget) -> None
self.repos = RepositoryForge(self.estate, self.skeletons, package, target, self.names, trace=self.trace)
self.uow = UowForge(self.skeletons, package, target, self.names, trace=self.trace)
self.db2 = Db2Forge(self.estate, self.skeletons, package, target, self.names, trace=self.trace) # #3618
self.screens = ScreenForge(self.skeletons, package, target, self.names, trace=self.trace) # #3619

def sources(self) -> dict[tuple[str, ...], dict[str, str]]:
"""(java_dirs key, sub-directory) -> {class name: Java source}, every generated file."""
Expand All @@ -65,6 +67,8 @@ def sources(self) -> dict[tuple[str, ...], dict[str, str]]:
}
for where, files in self.uow.sources().items(): # #3621: exception + web packages
out.setdefault(where, {}).update(files)
for where, files in self.screens.sources().items(): # #3619: view models + screen controllers
out.setdefault(where, {}).update(files)
return out

def write(self, java_dirs: dict[str, Path], header: str) -> dict[str, int]:
Expand All @@ -75,6 +79,10 @@ def write(self, java_dirs: dict[str, Path], header: str) -> dict[str, int]:
for name, code in files.items():
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / f"{name}.java").write_text(header + code, encoding="utf-8")
for rel, text in self.screens.resources().items(): # #3619: templates/screen.html
path = java_dirs["resources"] / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return {"entities": len(self.repos.stores), "dtos": len(self.cics.dtos)}

def summary(self) -> str:
Expand All @@ -99,6 +107,7 @@ def service_extras(self, key: str) -> dict | None:
self.repos.service_extras(key),
self.uow.service_extras(key),
self.db2.service_extras(key),
self.screens.service_extras(key),
)

def write_audit(self, f: TextIO) -> None:
Expand Down Expand Up @@ -127,6 +136,8 @@ def write_audit(self, f: TextIO) -> None:
f" • DB2 tables (#3618) : {d['tables']} repositories ({d['rows']} with DECLAREd row classes), "
f"{d['statements']} statements as written; {d['positioned']} positioned (TODO)\n"
)
if self.screens.screens or self.screens.unresolved:
f.write(self.screens.audit_line())
u = self.uow.counts
f.write(
f" • Units of work (#3621) : {u['services']} @Transactional services, {u['commits']} commit points, "
Expand Down
3 changes: 2 additions & 1 deletion gitgalaxy/tools/cobol_to_java/cobol_to_java_worklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class Category:
"the two layouts' sizes are in the TODO."),
Category("missing-layout", "Unresolved layouts", "fact-gap",
("no COMMAREA layout", "the layout of", "COPY members not found", "was not found in the DATA DIVISION",
"has no known width"),
"has no known width", "no single BMS source defines"),
"Add the missing copybook or record to the repository and re-run; the DTO then gets its real fields."),
Category("vsam-key", "Keys that are not one field", "fact-gap",
("the key (offset", "no key is known", "STARTBR / READNEXT", "alternate index", "start from a key",
Expand Down Expand Up @@ -90,6 +90,7 @@ class Category:
"Wire the called service (or a mock) in place of the placeholder."),
Category("business-logic", "Business logic to port", "port",
("implement from the program's business rules", "Implement extracted business rules", "port paragraph",
"port the logic that fills", "port the logic that reads",
"build the response"),
"Port the cited paragraphs; the skeleton names the COBOL lines and the facts they touch."),
Category("configuration", "Target configuration", "review",
Expand Down
18 changes: 18 additions & 0 deletions gitgalaxy/tools/cobol_to_java/java_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
DTO_STYLES = ("class", "record") # a transient record (DFHCOMMAREA): a class, or a Java record
DATABASES = ("postgresql", "db2", "oracle", "mysql", "h2")
DDL_AUTO = ("none", "validate", "update", "create", "create-drop")
UI_FLAVOURS = ("none", "thymeleaf", "openapi-only") # #3619: what the BMS screens become beyond view models
REMOTE_CALLS = ("http", "local") # a DPL LINK to another region: an HTTP client, or the in-process bean

# Per database: (Maven groupId, artifactId) of the JDBC driver -- versions come from the
Expand Down Expand Up @@ -102,6 +103,11 @@ class Integration:
remote_calls: str = "http" # #3616: a LINK the CSD routes to another region (REMOTESYSTEM / SYSID)


@dataclass
class Ui:
flavour: str = "none" # #3619: view models only | + Thymeleaf pages | + REST endpoints with OpenAPI docs


@dataclass
class JavaTarget:
project: Project = field(default_factory=Project)
Expand All @@ -110,6 +116,7 @@ class JavaTarget:
database: Database = field(default_factory=Database)
features: Features = field(default_factory=Features)
integration: Integration = field(default_factory=Integration)
ui: Ui = field(default_factory=Ui)

@property
def lombok(self) -> bool:
Expand All @@ -129,6 +136,7 @@ def driver(self) -> tuple[str, str, str, str, str]:
"database": Database,
"features": Features,
"integration": Integration,
"ui": Ui,
}


Expand All @@ -148,13 +156,19 @@ def _check(target: JavaTarget) -> None:
("database.engine", d.engine, DATABASES),
("database.ddl_auto", d.ddl_auto, DDL_AUTO),
("integration.remote_calls", target.integration.remote_calls, REMOTE_CALLS),
("ui.flavour", target.ui.flavour, UI_FLAVOURS),
):
if value not in allowed:
raise ConfigError(f"{key} {value!r} is not supported; choose one of {', '.join(map(str, allowed))}")
if target.features.rest_controllers and not target.features.services:
raise ConfigError(
"features.rest_controllers needs features.services: each controller calls its program's service"
)
if target.ui.flavour != "none" and not (target.features.services and target.features.rest_controllers):
raise ConfigError(
f"ui.flavour {target.ui.flavour!r} needs features.services and features.rest_controllers: "
"each screen's controller calls its program's service"
)
if not _BOOT_VERSION.fullmatch(str(s.version)):
raise ConfigError(f"spring_boot.version {s.version!r}: a Spring Boot 3.x.y version (jakarta namespace)")

Expand Down Expand Up @@ -247,4 +261,8 @@ def load_target(path: Path | None) -> JavaTarget:
integration:
remote_calls: http # {" | ".join(REMOTE_CALLS)} (a LINK the CSD routes to another region:
# http = a RestTemplate client per region, local = call the bean in-process)

ui:
flavour: none # {" | ".join(UI_FLAVOURS)} (BMS screens: view models only,
# + Thymeleaf pages on the 24x80 layout, or + REST endpoints and OpenAPI docs)
""" # noqa: S608 -- a YAML template: "update | create" are ddl-auto values, not SQL
156 changes: 156 additions & 0 deletions tests/cobol_mainframe/test_bms_screens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""#3619: BMS screens -> view models, service handlers and (per ui.flavour) web views.

A real scan of a small estate: one mapset with a label, an input field (IC), a numeric
input, a dark field and an OCCURS field; a program that RECEIVEs and SENDs the map, and
SENDs a map no BMS source defines. Pinned: the view model (LAYOUT, properties, the
symbolic-map citation), the render / submit handlers, the unresolved map's TODO, each
flavour's controller / template / build dependencies, the manifest and the worklist.
The generated Java is compiled by java_target_matrix (ui-thymeleaf / ui-openapi-plain).
"""

import json
import shutil
import sys
from unittest.mock import patch

import pytest

import gitgalaxy.cobol_refractor_controller as refractor
import gitgalaxy.cobol_to_java_controller as java_controller
from gitgalaxy.tools.cobol_to_cobol.galaxy_ir import scan_to_db
from gitgalaxy.tools.cobol_to_java.java_target import ConfigError, target_from_dict

BMS = (
"\n".join(
[
"SGN00 DFHMSD TYPE=&SYSPARM,LANG=COBOL,MODE=INOUT,STORAGE=AUTO",
"SGN0A DFHMDI SIZE=(24,80),LINE=1,COLUMN=1",
" DFHMDF POS=(1,1),LENGTH=6,ATTRB=(ASKIP,NORM),INITIAL='Tran :',".ljust(71) + "X", # col 72
" COLOR=BLUE",
"USERID DFHMDF POS=(19,43),LENGTH=8,ATTRB=(FSET,IC,NORM,UNPROT)",
"AMOUNT DFHMDF POS=(20,43),LENGTH=5,ATTRB=(NUM,UNPROT)",
"PASSWD DFHMDF POS=(21,43),LENGTH=8,ATTRB=(DRK,FSET,UNPROT)",
"ITEMS DFHMDF POS=(22,1),LENGTH=3,OCCURS=2",
" DFHMSD TYPE=FINAL",
" END",
]
)
+ "\n"
)

PROGRAM = """\
IDENTIFICATION DIVISION.
PROGRAM-ID. SGNON.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 WS-X PIC X.
PROCEDURE DIVISION.
000-MAIN.
EXEC CICS RECEIVE MAP('SGN0A') MAPSET('SGN00') INTO(SGN0AI)
END-EXEC.
EXEC CICS SEND MAP('SGN0A') MAPSET('SGN00') FROM(SGN0AO) ERASE
END-EXEC.
EXEC CICS SEND MAP('GHOST') MAPSET('NOSUCH') END-EXEC.
EXEC CICS SEND MAP('SGN00') MAPONLY END-EXEC.
EXEC CICS RETURN END-EXEC.
"""

CSD = " DEFINE TRANSACTION(SGN0) GROUP(APP)\n PROGRAM(SGNON)\n"


@pytest.fixture(scope="module")
def scanned(tmp_path_factory):
base = tmp_path_factory.mktemp("bms_screens")
repo = base / "estate"
for rel, text in {"bms/SGN00.bms": BMS, "cbl/SGNON.cbl": PROGRAM, "csd/APP.csd": CSD}.items():
(repo / rel).parent.mkdir(parents=True, exist_ok=True)
(repo / rel).write_text(text, encoding="utf-8")
return repo, scan_to_db(repo, base / "scan")


def _generate(scanned, tmp_path, flavour=None):
repo, db = scanned
work = tmp_path / "estate"
shutil.copytree(repo, work)
with patch.object(sys, "argv", ["refract", str(work), "--galaxy-db", str(db)]):
refractor.main()
(clean,) = tmp_path.glob("estate_gitgalaxy_clean_*")
argv = ["cobol-to-java", str(clean), "--header", str(tmp_path / "none.txt")]
if flavour:
cfg = tmp_path / "ui.json"
cfg.write_text(json.dumps({"ui": {"flavour": flavour}}), encoding="utf-8")
argv += ["--config", str(cfg)]
with patch.object(sys, "argv", argv):
java_controller.main()
(java,) = tmp_path.glob("estate_gitgalaxy_java_spring_*")
return java


SRC = "src/main/java/com/gitgalaxy/modernized"


def test_the_view_model_and_the_handlers(scanned, tmp_path):
java = _generate(scanned, tmp_path)
vm = (java / SRC / "dto/screen/Sgn0aScreen.java").read_text(encoding="utf-8")
assert "BMS map SGN0A of mapset SGN00 (bms/SGN00.bms)" in vm
assert 'new ScreenField(null, 1, 1, 6, false, false, false, false, false, "Tran :", "BLUE", 1)' in vm # label
assert 'new ScreenField("USERID", 19, 43, 8, true, false, false, false, true, null, null, 1)' in vm # IC input
assert 'new ScreenField("AMOUNT", 20, 43, 5, true, true, false, false, false, null, null, 1)' in vm # NUM
assert 'new ScreenField("PASSWD", 21, 43, 8, true, false, false, true, false, null, null, 1)' in vm # DRK
assert "private List<String> items;" in vm and "private String userid;" in vm # OCCURS=2 -> a list
assert "Symbolic map USERIDI, USERIDO." in vm
assert "@Size" not in vm # ui.flavour none: no validator on the classpath, no annotations
svc = (java / SRC / "service/SgnonService.java").read_text(encoding="utf-8")
assert "public Sgn0aScreen renderSgn0a(Sgn0aScreen screen) {" in svc
assert "public ScreenModel submitSgn0a(Sgn0aScreen input, String aid) {" in svc
assert " return renderSgn0a(input);" in svc # the program also SENDs the map it RECEIVEs
assert "RECEIVE MAP(SGN0A) MAPSET(SGN00) INTO(SGN0AI) at cbl/SGNON.cbl:8" in svc
assert "SEND MAP GHOST (mapset NOSUCH) at cbl/SGNON.cbl:12: no single BMS source defines it" in svc
# the mapset's name sent as a map (CBSA BNK1CCS's CLEAR branch): the maps the mapset does define
assert (
"SEND MAP SGN00 (mapset SGN00) at cbl/SGNON.cbl:13: no single BMS source defines it (candidates: none "
"in the repository); mapset SGN00 defines SGN0A" in svc
)
assert not (java / SRC / "controller/screen").exists() and not (java / "src/main/resources/templates").exists()
assert " • BMS screens (#3619) : 1 view models (4 fields) for 1 programs" in (
java / "java_migration_audit.txt").read_text(encoding="utf-8") # fmt: skip

manifest = json.loads((java / "traceability.json").read_text(encoding="utf-8"))
kinds = {a["kind"] for a in manifest["artifacts"]}
assert {"screen-view-model", "screen-field", "screen-send", "screen-receive"} <= kinds
field = next(a for a in manifest["artifacts"] if a["symbol"] == "Sgn0aScreen#userid")
assert field["facts"][0]["source"] == "bms/SGN00.bms:5" and field["facts"][0]["ledger_field"] == "BMS screen fields"
worklist = json.loads((java / "migration_worklist.json").read_text(encoding="utf-8"))
cats = {i["category"] for i in worklist["items"] if "Sgnon" in i["file"]}
assert {"business-logic", "missing-layout"} <= cats # the handlers' TODOs; the GHOST map


def test_thymeleaf_adds_pages_validation_and_its_starters(scanned, tmp_path):
java = _generate(scanned, tmp_path, "thymeleaf")
vm = (java / SRC / "dto/screen/Sgn0aScreen.java").read_text(encoding="utf-8")
assert " @Size(max = 8)\n private String userid;" in vm
assert ' @Size(max = 5)\n @Pattern(regexp = "[0-9 ]*")\n private String amount;' in vm
ctl = (java / SRC / "controller/screen/SgnonScreenController.java").read_text(encoding="utf-8")
assert '@RequestMapping("/screens/sgnon")' in ctl and "@Controller" in ctl
assert "ScreenModel next = sgnonService.submitSgn0a(Sgn0aScreen.fromValues(form), aid);" in ctl
page = (java / "src/main/resources/templates/screen.html").read_text(encoding="utf-8")
assert 'th:each="c : ${cells}"' in page and 'value="PF3"' in page
pom = (java / "pom.xml").read_text(encoding="utf-8")
assert "spring-boot-starter-thymeleaf" in pom and "spring-boot-starter-validation" in pom


def test_openapi_only_adds_rest_endpoints_and_springdoc(scanned, tmp_path):
java = _generate(scanned, tmp_path, "openapi-only")
ctl = (java / SRC / "controller/screen/SgnonScreenController.java").read_text(encoding="utf-8")
assert '@RequestMapping("/api/v1/sgnon/screens")' in ctl and "@RestController" in ctl
assert "public ScreenModel submitSgn0a(@Valid @RequestBody Sgn0aScreen input," in ctl
assert not (java / "src/main/resources/templates").exists()
pom = (java / "pom.xml").read_text(encoding="utf-8")
assert "springdoc-openapi-starter-webmvc-ui" in pom and "<version>2.5.0</version>" in pom


def test_a_ui_flavour_needs_the_services_and_controllers():
with pytest.raises(ConfigError, match="ui.flavour 'thymeleaf' needs features.services"):
target_from_dict({"ui": {"flavour": "thymeleaf"}, "features": {"rest_controllers": False}})
with pytest.raises(ConfigError, match="choose one of none, thymeleaf, openapi-only"):
target_from_dict({"ui": {"flavour": "react"}})
2 changes: 2 additions & 0 deletions tests/tools/java_target_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
}, # fmt: skip
"gradle": {"java": {"build_tool": "gradle"}},
"gradle-plain-21": {"java": {"build_tool": "gradle", "data_classes": "plain", "version": 21}},
"ui-thymeleaf": {"ui": {"flavour": "thymeleaf"}}, # #3619: BMS screens as web pages
"ui-openapi-plain": {"ui": {"flavour": "openapi-only"}, "java": {"data_classes": "plain"}}, # + REST, plain
}


Expand Down
4 changes: 3 additions & 1 deletion tests/tools/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ def command(ctx: GateContext) -> list[str]:
Gate("refraction-snapshot", lambda ctx: [ctx.python_exe, "tests/tools/refraction_snapshot.py", "check"]),
Gate(
"java-carddemo",
_matrix("aws-mainframe-modernization-carddemo", ["default", "plain-records", "gradle-plain-21"]),
_matrix(
"aws-mainframe-modernization-carddemo", ["default", "plain-records", "gradle-plain-21", "ui-thymeleaf"]
),
requires_java=True,
), # fmt: skip
Gate("java-cics-genapp", _matrix("cics-genapp", ["default"]), requires_java=True),
Expand Down
Loading