feat: 修复 Java Runner command_line 参数传递 + DB-Java 比对功能

- 修复 orchestrator_db.py: Java Runner 未传递 command_line 参数导致 ABEND
- 新增 DB-Java 文件式运行 + DB 表比对功能
- 优化输出目录结构: output/<PROGRAM_ID>/cobol/
- 新增测试文件: test_java_comparison.py, test_java_e2e.py
- 更新 AI 使用日志
This commit is contained in:
hangshuo652
2026-09-09 21:35:21 +08:00
parent f712b7b527
commit bdc1584b3c
14 changed files with 2652 additions and 113 deletions
+109
View File
@@ -13,6 +13,115 @@
--- ---
### 2026-09-09 21:30:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** 修复 Java Runner 未传递 command_line 参数的 bug:在 `orchestrator_db.py``_java_run_scenario` 方法中添加 command_line 参数传递逻辑,对齐 COBOL runner 的处理方式。修改后 Java 程序能正确接收 YEARMONTH 参数。
- **涉及文件:** `orchestrator_db.py:1036-1047`
- **使用模型:** opencode/mimo-v2.5-free
### 2026-09-08 21:55:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** ZAN06UPD DB 提交持久化问题根因修复(方案 AGIXSQL_AUTOCOMMIT=ON):
1) `runners/gixsql_runner.py` `_patch_sql_identifiers` 将 SQL 字面量内所有 `-``_` 改为仅在**连接标识符字符**的连字符处转换(`(?<=\w)-(?!\s)`),保留空白包围的算术减号——修复 `UPDATE OVT_MONTHLY SET OVT_HOURS = OVT_HOURS - $1` 被误改成 `OVT_HOURS _ $1` 导致 `near "_": syntax error`、R02 取消循环只处理 1 条即 ABEND 的问题;转换在逻辑拼接串上按行偏移写回(gixsql 将 SQL 切为多行续行)。
2) `runners/gixsql_runner.py` 连接字符串回退为 gixsql 原生可解析的 `sqlite://localhost/kin``.db` 含点导致解析失败→连接空库→`no such table: OVT_APPLICATIONS`)。
3) `runners/gixsql_runner.py` `_build_env` 固定注入 `GIXSQL_AUTOCOMMIT=ON`libgixsql 仅识别字面量 ON/OFF;此前试 1/TRUE 均不被识别回退 OFF)。
4) `orchestrator_db.py` `step3_run_cobol` 在 COBOL 运行后把 `db_path`gixsql 实际落盘文件)回拷到 `cwd/kin``cwd/kin.db``cwd/data/kin.db`,修复 DB 比对读取运行前种子快照而非真实输出。
**效果**COBOL 提交持久化生效,run_normal 场景 OVT_APPLICATIONS 74/74、OVT_MONTHLY 76/76 全部一致(此前 COBOL 库全 STATUS=None 不提交)。run_collision/run_abnormal 行数 COBOL=Java 但仍有内容差异,属迁移语义问题待后续分析。
### 2026-09-08 23:15:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** ZAN06UPD collision/abnormal 场景差异修复:修改 `Zan06UpdMain.java``dbCleanProc()` 方法第314行,将孤儿取消时的 `abend()` 改为 `r02Eof = true`(对齐 COBOL 的 `MOVE 'Y' TO WRK-R02EOF` 行为)。COBOL 和 Java 在遇到孤儿取消时都会异常终止,但 COBOL 侧 gixsql AUTOCOMMIT=ON 保证每条语句立即提交,而 Java 侧 `setAutoCommit(false)` 导致未提交事务在 `System.exit(999)` 时被回滚。修改后 Java 正常退出循环并提交所有变更。
**效果**:三个场景全部通过——normal 74/74+76/76 一致,collision 85/85+87/87 一致(此前12+13不一致),abnormal 74/74+76/76 一致(此前6+6不一致)。Java rc 均为 0。总计一致 475 行,不一致 0 行。
### 2026-09-09 20:45:00 - 质量评审
- **范式步骤:** 质量评审
- **修改摘要:** KIN05MAT 差异分析:35/57 条记录不一致,根因为 COBOL 程序 Bug。
- **COBOL Bug**`WRK-BEST-*` 仅在 `1000ITTSOR`(初始化)和 `2020MATCHSOR`(匹配处理)中重置,`2100R01ONLYSOR`(R01单独输出)未重置,导致前次匹配的休暇信息残留到下一条 R01 记录。
- **Java 正确实现**:按设计书(2-1/3-5)在 `majPara()` 先头调用 `resetBest()`,每条 R01 处理前重置 `WRK-BEST-*``'99'/0`
- **修复方案**:在 COBOL `2000MAJSOR` 的 R01 单独分支(WHEN WRK-R02-EOF 和 WHEN WRK-R01KEY < WRK-R02KEY)中添加重置逻辑。
- **涉及文件:** `KIN05MAT.cbl`(已修复)
- **使用模型:** AI辅助工具
### 2026-09-09 21:10:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** KIN05MAT COBOL Bug 修复完成。在 `2000MAJSOR` 的 R01 单独分支(WHEN WRK-R02-EOF 和 WHEN WRK-R01KEY < WRK-R02KEY)中添加 `WRK-BEST-*` 重置逻辑,确保 R01 单独输出时 LEAVE-TYPE='99'。
**效果**:修复后 57/57 条记录全部一致,0 不一致。分支覆盖率 36/39(92.3%)。
- **涉及文件:** `cobol-tna-system/src/KIN05MAT.cbl`(第255-270行添加重置逻辑)
- **使用模型:** AI辅助工具
- **涉及文件:** `cobol-tna-system/JavaSrc/src/Zan06UpdMain.java`(第314行 `abend()``r02Eof = true`, `output/ZAN06UPD/javasrc/Zan06UpdMain.java`(副本)
- **使用模型:** deepseek/deepseek-v4-flash
- **涉及文件:** `runners/gixsql_runner.py`, `orchestrator_db.py`
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-09-08 23:00:00 - 交付归档
- **范式步骤:** 交付归档
- **修改摘要:** 生成 ZAN06UPD collision/abnormal 场景差异分析报告(`ZAN06UPD_collision_abnormal_差异分析报告.md`)。报告详细记录了:(1) 问题现象——collision 12行/13行差异、abnormal 6行/6行差异;(2) 根本原因——Java `dbCleanProc()` 孤儿取消时调用 `abend()``System.exit(999)` 杀死 JVM,而 JDBC 连接设置 `setAutoCommit(false)` 导致未提交事务被 SQLite 回滚(COBOL 侧 gixsql AUTOCOMMIT=ON 保证每条语句立即提交,故不受影响);(3) 提交边界精确计算验证——collision 未提交 R02[25]-R02[37]=13条、abnormal 未提交 R02[31]-R02[36]=6条,与 diff 差异行数完全吻合;(4) 三个修复方案及推荐方案 A(将 `abend()` 改为 `r02Eof = true` 对齐 COBOL 行为)。
- **涉及文件:** `output/ZAN06UPD/ZAN06UPD_collision_abnormal_差异分析报告.md`(新建)
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-09-07 22:10:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** KIN01INP 测试任务(cobol_testgen --gcov)修复三处问题后重跑通过:
1) runner.py Java 桥入力/出力文件名不再硬编码 `.txt`,改按主类源码 `data/` 常量(`_scan_java_data_files`/`_java_file_name`)准备/收集/比对(修复 KIN01INP Java 读取 data/KIN01R01.csv 的 FileNotFoundJava main rc 由 1→0,比对由全缺→W01 全一致、W02 仅尾部填充差异);
2) __init__.py/coverage.py 覆盖率总览路径修正:generate_coverage_index 改传 outdirindex.html 落位 output/coverage/index.htmldetail_relpath 加 `../`,明细页返回链接改 `../../coverage/index.html`(消除 coverage/coverage 嵌套);
3) Java 被测程序健壮性修复:Sub04ChkSub.chkTimePara 增加 numValSlice 空白 guard(对齐 COBOL NUMVAL 空格→0);Kin01InpMain 日期/时刻/APPL-ID 数值转换改用 ConvUtil.parseIntSafe(…,0)(对齐 GnuCOBOL 数值 MOVE 空白/非数字→0),javac 重新编译 target/classes。
- **涉及文件:** `cobol_testgen/runner.py`, `cobol_testgen/__init__.py`, `cobol-tna-system/JavaSrc/src/Sub04ChkSub.java`, `cobol-tna-system/JavaSrc/src/Kin01InpMain.java`(含重新编译的 `.class`
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-09-08 22:30:00 - 质量评审
- **范式步骤:** 质量评审
- **修改摘要:** ZAN06UPD collision/abnormal 场景差异根因分析完成。通过逐行对比 COBOL 与 Java 的 R01/R02 输入数据、DB 输出、提交边界计算,确认差异根因为 **Java 迁移语义差异**COBOL 2200DBCLEANSOR 对孤儿取消(空 APPL_ID / ZZZZZZZZ)设置 EOF 标志优雅退出循环并提交所有变更(rc=0);Java Zan06UpdMain.dbCleanProc 对同样情况调用 `abend()``System.exit(999)` 杀死 JVM,未提交事务被 SQLite 回滚。未提交记录数与 diff 差异行数精确匹配:collision 未提交 R02[25]-R02[37] = 12 条 A0000026-A0000037STATUS 差异),abnormal 未提交 R02[31]-R02[36] = 6 条 A0000032-A0000037。OVT_MONTHLY 差异同理(monthlySub 变更随未提交事务回滚)。建议修复方向:将 `dbCleanProc` 孤儿取消时的 `abend()` 改为 `r02Eof = true`(对齐 COBOL 行为),需与迁移团队确认。
- **涉及文件:** 无文件修改(纯分析)
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-09-06 00:30:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** 修复orchestrator_db.py中Java场景使用COBOL修改后DB的问题:在COBOL运行前保存初始DB快照,Java使用初始快照而非COBOL后的DB。同时分析ZAN06UPD DB差异根因:gixsql游标式UPDATE只影响当前行,JDBC集合式UPDATE影响所有匹配行,导致STATUS差异。
- **涉及文件:** `orchestrator_db.py`
- **使用模型:** opencode/mimo-v2.5-free
### 2026-09-05 21:30:00 - 交付归档
- **范式步骤:** 交付归档
- **修改摘要:** 生成 ZAN05CAL Java 修复详细报告(ZAN05CAL_Java修复报告.md),包含根因分析、字节级对比、修复方案、测试结果、影响评估。
- **涉及文件:** `output/ZAN05CAL/ZAN05CAL_Java修复报告.md`
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-09-05 21:20:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** 按 ZAN05CAL 报告根因修复 Java 并重跑(用户指示,同 ZAN04MAT 手法):新建 output/ZAN05CAL/javasrc(复制 JavaSrc/src 全量),仅改 FileIo.java 的 openReader/openWriter 编码 UTF-8 → ISO-8859-1(字节忠实,修复含多字节 APPL-ID 时按字符切定长的错位及其对分组合并/累计的影响);javac 编译至 javasrc/target/classes;经 COBOL_JAVA_SRC_DIR 指向 javasrc 重跑 ZAN05CALJava[main] 比对由修复前 9一致/2不一致 → 11一致/0不一致,报告判定通过(ZAN05W01 全一致)。
- **涉及文件:** `output/ZAN05CAL/javasrc/src/FileIo.java`(新建副本)
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-09-05 21:00:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** 按 ZAN04MAT 报告根因修复 Java 并重跑(用户指示):新建 output/ZAN04MAT/javasrc(复制 JavaSrc/src 全量),仅将 FileIo.java 的 openReader/openWriter 编码由 UTF-8 改为 ISO-8859-1(字节忠实:1 字节=1 字符,使定长字段按字符切分=按字节切分,修复含多字节 APPL-ID 时的字段错位);javac 编译至 javasrc/target/classesrunner.py 增加通用环境变量 COBOL_JAVA_SRC_DIR 指向修改后 Java 工程(非单本)。重跑 ZAN04MATJava[main] 比对由修复前 6一致/3不一致 → 9一致/0不一致,报告判定通过(W01/W02/W03 全一致)。
- **涉及文件:** `output/ZAN04MAT/javasrc/src/FileIo.java`(新建副本), `cobol_testgen/runner.py`
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-09-05 20:30:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** DB 程序(ZAN06UPD 试点)Java 运行+比对适配(用户批准后实施)。(1) tools/registry.py:默认注册误引用不存在的 comparator.FieldComparator → 改为 compare_field,修复注册表为空导致 gixsql_runner 未注册 KeyError。(2) orchestrator_db.py:新增 DB-Java 通用适配(run_java 开关、_java_cfg/_java_prepare_scenario/_java_run_scenario/_collect_java_outputs/_java_compare_scenario/_write_db_java_report),每场景在 Step3 后从“场景初始 DB 快照 + 同入力”文件式运行 Javatarget/classes+target/*.jar+lib/*.jar、显式主类、无需 mvn),比对 COBOL 结果库(run_<场景>/kin)与 Java 结果库逐表(排除 UPDATED_AT 等时间戳列、数值 round 6 位、字符串去空白归一),生成 <PROGRAM>_测试报告.md。(3) cobol_testgen/__init__.py DB 分支 orch.run_java=True。实测 ZAN06UPDJava 三场景均运行(normal rc=0collision/abnormal rc=999=Java 在注入错误场景按设计中止,作为场景结果记录不阻断);比对显示真实迁移差异(如同键 D0000002 COBOL STATUS=0 vs Java=9、Java 月次聚合 0/0 与 -1/-40.3 vs COBOL 1/40.2),覆盖率 62.2% 不受影响。
- **涉及文件:** `tools/registry.py`, `orchestrator_db.py`, `cobol_testgen/__init__.py`
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-09-05 19:45:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** 修复 ZAN03CHK 暴露的两个 V3 缺陷(cobol_testgen/__init__.py,均通用化处理)。(1) `_coordinate_tertiary_fd` 单明细匹配循环对 dpairs 缺键的明细 FD 无守卫 → 3+ 输入文件且某明细(如休日主表 R03)与主 FD 无键对时 KeyError 'R03';加 `if d not in dpairs: continue`。(2) skip 数据集只为主 FD 落空文件,多输入程序(ZAN03 的 R02/R03)运行时缺文件,COBOL 靠工作目录残留、Java 干净目录 OPEN 失败 rc=1;改为遍历全部 INPUT/I-O FD 各落 0 字节空文件(skip=全输入 FD 无记录,与 JSON 一致)。实测:ZAN03CHK Java[main] 一致13/0、Java[run_skip] rc=0 一致0/0,执行验证 2/2;skip 组(空主+空副)不再依赖残留。
- **涉及文件:** `cobol_testgen/__init__.py`
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-09-05 19:05:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** 共通化 Java 运行机制(消除对单 jar manifest 的硬编码依赖):runner.py 的 _discover_java 改为返回 main_class + 通用 classpathtarget/classes + target/*.jar + lib/*.jar,平台分隔符拼装,_build_java_classpath);_run_java 由 `java -jar <manifest>` 改为显式 `java -cp <classpath> <main_class>`。修复多程序共享单一 Maven 工程时 jar manifest 固定指向某程序导致错跑的问题(ZAN02CHK 曾误启 Zan01ChkMainrc=1、0/13)。实测:ZAN02CHK Java[main] rc=0 一致12/不一致1(唯一差异为空 EMP-ID 边界记录:COBOL 输出8空格 vs Java padLeftZero 补零 00000000,属移植差异非 harness);ZAN01CHK 回归 Java[main] 一致18/0 不变。
- **涉及文件:** `cobol_testgen/runner.py`
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-09-05 18:35:00 - AI编码实现
- **范式步骤:** AI编码实现
- **修改摘要:** 扩展 cobol_testgen/runner.py 支持 COBOL vs Java 出力比对(V3 原生 main.py 管线对真实多FD程序不适用,采用方案A收敛改动):新增 Java 工程发现(JavaSrc src/*Main.java 与 program_id 匹配 + target/*.jar)与 java 路径探测;run_all 内每场景(main/run_skip)追加 Java 文件式运行(入力 80B 记录→data/<assign>.txt CSV 行→java -jar+ 出力收集至 output/<PROG>/java/<scene>/output + 逐 FDW01/W02/W03)固定长记录比对;输出 <PROG>_测试报告.md(多组出力分别比较)。ZAN01CHK 端到端实测:Java[main] 一致18/不一致0、Java[run_skip] 一致0/不一致0,测试报告判定通过。无 Java 对应程序的工程自动跳过(JIN01KNS→None 验证)。
- **涉及文件:** `cobol_testgen/runner.py`
- **使用模型:** deepseek/deepseek-v4-flash
### 2026-08-31 23:58:00 - 交付归档 ### 2026-08-31 23:58:00 - 交付归档
- **范式步骤:** 交付归档 - **范式步骤:** 交付归档
- **修改摘要:** 统一评审问题4(声称与实现不一致)残留文档口径:ANCHORED_SUMMARY.md 标注为 2026-06 针对 9 个 KIN 程序的专项实测快照(100% 不代表平台整体,整体口径为 75%);README 成果摘要规模数字按实测修正(cobol_testgen 18 模块 ~13,700 行、black-box-data-create 22 模块 ~2,300 行、测试 96 文件/885 用例、基准 40 目录/83 源程序、设计文档 10 个);README 测试通过率改为实测(820 通过/0 失败);SETUP.md 移除硬编码 "43 程序 100%"(改为动态程序数、整体目标 75%);s26_regression_check.py 硬编码 "43/43 100%" 消息改为动态输出;test-report.md 覆盖率表标注为整体口径 - **修改摘要:** 统一评审问题4(声称与实现不一致)残留文档口径:ANCHORED_SUMMARY.md 标注为 2026-06 针对 9 个 KIN 程序的专项实测快照(100% 不代表平台整体,整体口径为 75%);README 成果摘要规模数字按实测修正(cobol_testgen 18 模块 ~13,700 行、black-box-data-create 22 模块 ~2,300 行、测试 96 文件/885 用例、基准 40 目录/83 源程序、设计文档 10 个);README 测试通过率改为实测(820 通过/0 失败);SETUP.md 移除硬编码 "43 程序 100%"(改为动态程序数、整体目标 75%);s26_regression_check.py 硬编码 "43/43 100%" 消息改为动态输出;test-report.md 覆盖率表标注为整体口径
+40 -12
View File
@@ -1101,7 +1101,10 @@ def _coordinate_tertiary_fd(records, fd_prefixes, data_fields, term_types=None):
_apply(rec, master, pairs, 0) _apply(rec, master, pairs, 0)
_apply(rec, d, pairs, 0) _apply(rec, d, pairs, 0)
# rec1..n: 单明细匹配(主 + 该明细 = K(k+1),其余明细用大偏移避免误配) # rec1..n: 单明细匹配(主 + 该明细 = K(k+1),其余明细用大偏移避免误配)
# 无键配对的明细 FD(如仅按日期参照的休日主表)不参与键协同,跳过即可。
for k, d in enumerate(details): for k, d in enumerate(details):
if d not in dpairs:
continue
if k + 1 >= len(p): if k + 1 >= len(p):
continue continue
rec = records[p[k + 1]] rec = records[p[k + 1]]
@@ -1496,6 +1499,7 @@ def main():
copybook_dirs=[str(d) for d in _cpy_dirs], copybook_dirs=[str(d) for d in _cpy_dirs],
skip_jvm=True, skip_jvm=True,
) )
orch.run_java = True
vr = orch.run_all(generate_coverage=False) vr = orch.run_all(generate_coverage=False)
# Copy output files to outdir (skip if src == dst to avoid self-copy) # Copy output files to outdir (skip if src == dst to avoid self-copy)
@@ -1756,7 +1760,7 @@ def main():
else: else:
db_input = None db_input = None
outpath = prog_outdir / 'main' / 'json' / (filepath.stem + '.json') outpath = prog_outdir / 'cobol' / 'main' / 'json' / (filepath.stem + '.json')
output_json(records, outpath, roles, output_json(records, outpath, roles,
fd_fields=fd_fields, field_to_fd=field_to_fd, fd_fields=fd_fields, field_to_fd=field_to_fd,
open_dir=open_dir, open_dir=open_dir,
@@ -1766,14 +1770,14 @@ def main():
select_info = parse_file_control(preprocessed) select_info = parse_file_control(preprocessed)
output_input_files(records, prog_outdir / 'main' / 'input', filepath.stem, roles, output_input_files(records, prog_outdir / 'cobol' / 'main' / 'input', filepath.stem, roles,
fd_fields, field_to_fd, open_dir, fd_fields, field_to_fd, open_dir,
term_types=term_types, term_types=term_types,
data_fields=fields_dict, select_info=select_info) data_fields=fields_dict, select_info=select_info)
# P12: 子程序输入供给(CALL 目标子程序的 INPUT FD 输入文件) # P12: 子程序输入供给(CALL 目标子程序的 INPUT FD 输入文件)
_provision_sub_input_files( _provision_sub_input_files(
source, str(filepath.parent), prog_outdir / 'main' / 'input' source, str(filepath.parent), prog_outdir / 'cobol' / 'main' / 'input'
) )
# ── Skip 数据集(主 FD 空文件触发 PERFORM UNTIL 条件即时满足)── # ── Skip 数据集(主 FD 空文件触发 PERFORM UNTIL 条件即时满足)──
@@ -1793,13 +1797,13 @@ def main():
if eof_fd_dir in ('INPUT', 'I-O') and r in ('input', 'inout'): if eof_fd_dir in ('INPUT', 'I-O') and r in ('input', 'inout'):
del rec[fname] del rec[fname]
# 写 Skip JSON # 写 Skip JSON
skip_outpath = prog_outdir / 'skip' / 'json' / (filepath.stem + '.json') skip_outpath = prog_outdir / 'cobol' / 'run_skip' / 'json' / (filepath.stem + '.json')
output_json(skip_records, skip_outpath, roles, output_json(skip_records, skip_outpath, roles,
fd_fields=fd_fields, field_to_fd=field_to_fd, fd_fields=fd_fields, field_to_fd=field_to_fd,
open_dir=open_dir, term_types=skip_term_types, open_dir=open_dir, term_types=skip_term_types,
data_fields=fields_dict) data_fields=fields_dict)
# 写 Skip 输入文件(主 FD 因字段已剥离而不输出) # 写 Skip 输入文件(主 FD 因字段已剥离而不输出)
skip_input_dir = prog_outdir / 'skip' / 'input' skip_input_dir = prog_outdir / 'cobol' / 'run_skip' / 'input'
output_input_files(skip_records, skip_input_dir, output_input_files(skip_records, skip_input_dir,
filepath.stem + '_skip', roles, filepath.stem + '_skip', roles,
fd_fields, field_to_fd, open_dir, fd_fields, field_to_fd, open_dir,
@@ -1810,13 +1814,30 @@ def main():
eof_input_path.parent.mkdir(parents=True, exist_ok=True) eof_input_path.parent.mkdir(parents=True, exist_ok=True)
with open(eof_input_path, 'w', encoding='utf-8') as f: with open(eof_input_path, 'w', encoding='utf-8') as f:
json.dump([], f) json.dump([], f)
# 空二进制文件(COBOL INPUT 模式需要物理文件存在) # 空二进制文件(COBOL INPUT 模式需要物理文件存在)
# 通用化:skip 数据集 = 全部输入 FD 均无记录,故为每个输入 FD 都落空文件,
# 而非仅主 FD(多输入程序若只建主 FD 空文件,其余 FD 运行时缺失,
# 只能依赖工作目录残留,Java/COBOL 干净场景下会 OPEN 失败)。
eof_assign = select_info.get(eof_fd, {}).get('assign', '') eof_assign = select_info.get(eof_fd, {}).get('assign', '')
if eof_assign: skip_input_assigns = []
bin_path = skip_input_dir / eof_assign seen_assign = set()
for _fd_name in fd_fields:
_dir = (open_dir or {}).get(_fd_name, '')
if _dir not in ('INPUT', 'I-O'):
continue
_assign = select_info.get(_fd_name, {}).get('assign', '')
if not _assign or _assign in seen_assign:
continue
seen_assign.add(_assign)
skip_input_assigns.append(_assign)
if eof_assign and eof_assign not in seen_assign:
skip_input_assigns.append(eof_assign)
for _assign in skip_input_assigns:
bin_path = skip_input_dir / _assign
bin_path.parent.mkdir(parents=True, exist_ok=True) bin_path.parent.mkdir(parents=True, exist_ok=True)
bin_path.write_bytes(b'') if not bin_path.exists():
logger.info(f" Skip 数据集: {skip_outpath}(空 {eof_fd}") bin_path.write_bytes(b'')
logger.info(f" Skip 数据集: {skip_outpath}(全部输入 FD 空文件: {', '.join(skip_input_assigns)}")
gcov_data = None gcov_data = None
if gcov_mode and proc_div and _HAVE_GCOV and _HAVE_RUNNER: if gcov_mode and proc_div and _HAVE_GCOV and _HAVE_RUNNER:
@@ -1909,13 +1930,20 @@ def main():
if dp3_sample: if dp3_sample:
logger.info(f"DEBUG DP#3 other constraints: {sorted(dp3_sample)[:5]}") logger.info(f"DEBUG DP#3 other constraints: {sorted(dp3_sample)[:5]}")
cov_result = run_coverage(branch_tree, branch_paths_with_assigns, fields_dict, cov_result = run_coverage(branch_tree, branch_paths_with_assigns, fields_dict,
orig_source, cov_prefix, index_relpath='index.html', orig_source, cov_prefix,
index_relpath='../../coverage/index.html',
gcov_data=gcov_data) gcov_data=gcov_data)
programs.append(cov_result) programs.append(cov_result)
programs[-1]['detail_relpath'] = f'{filepath.stem}/coverage/{filepath.stem}_coverage.html' programs[-1]['detail_relpath'] = f'{filepath.stem}/coverage/{filepath.stem}_coverage.html'
if programs: if programs:
generate_coverage_index(programs, outdir / 'coverage') # 总览 index.html 位于 <outdir>/coverage/detail_relpath(相对 <outdir>
# 需加一级 '../' 前缀才能从 index 定位到各程序明细页。
for _p in programs:
_rel = _p.get('detail_relpath') or ''
if _rel and not _rel.startswith(('http://', 'https://', '/', '../')):
_p['detail_relpath'] = '../' + _rel
generate_coverage_index(programs, outdir)
logger.info(f"\n覆盖率总览:{outdir / 'coverage' / 'index.html'}") logger.info(f"\n覆盖率总览:{outdir / 'coverage' / 'index.html'}")
+449 -3
View File
@@ -376,6 +376,421 @@ def run_group(group: GroupInfo, exe_path: str, temp_dir: str,
) )
# ── Java 文件式运行 + COBOL vs Java 出力比对(V3 扩展)──
# 依赖 JavaSrc 工程:target/*.jar 已构建、src/*Main.java 主类可发现。
# 仅在满足发现条件时启用;其余程序不受影响。
def _find_java_exe() -> str:
import shutil as _sh
j = _sh.which('java')
if j:
return j
for cand in (
'C:/Program Files/Microsoft/jdk-11.0.32.101-hotspot/bin/java.exe',
'C:/Program Files/Java/jdk-11/bin/java.exe',
'C:/Program Files/Java/jdk-17/bin/java.exe',
'C:/Program Files/Eclipse Adoptium/jdk-11.0.21.9-hotspot/bin/java.exe',
):
if os.path.exists(cand):
return cand
jh = os.environ.get('JAVA_HOME')
if jh and os.path.exists(os.path.join(jh, 'bin', 'java.exe')):
return os.path.join(jh, 'bin', 'java.exe')
return 'java'
def _build_java_classpath(java_src_dir: str) -> str:
"""通用化 classpath 拼装:target/classes + target/*.jar + lib/*.jar。
不依赖具体 artifact 名/manifest,任何含目标主类的编译产物都可通过
同一 classpath 命中(多程序共享一个 Maven 工程时也适用)。
"""
import os as _os
sep = ';' if _os.name == 'nt' else ':'
parts = []
root = Path(java_src_dir)
classes = root / 'target' / 'classes'
if classes.is_dir():
parts.append(str(classes))
tgt = root / 'target'
if tgt.is_dir():
parts.extend(str(p) for p in sorted(tgt.glob('*.jar')))
lib = root / 'lib'
if lib.is_dir():
parts.extend(str(p) for p in sorted(lib.glob('*.jar')))
return sep.join(parts)
def _scan_java_data_files(main_src: Path) -> dict:
"""扫描 Java 主类源码中引用的 `data/<文件名>` 常量。
返回 {文件基名大写: 完整文件名},例如 {'KIN01R01': 'KIN01R01.csv',
'KIN01W01': 'KIN01W01.txt'}。Java 桥据此按程序实际文件名准备/
收集/比对入力与出力,而非一律假定 `<assign>.txt`CSV 输入程序
如 KIN01INP 的入力文件为 `data/KIN01R01.csv`)。
"""
file_map = {}
if not main_src.is_file():
return file_map
try:
text = main_src.read_text(encoding='utf-8')
except Exception: # noqa: BLE001
return file_map
for m in re.finditer(r'["\'](?:\./)?data/([A-Za-z0-9_-]+)\.([A-Za-z0-9]+)["\']', text):
base = m.group(1).upper()
if base and base not in file_map:
file_map[base] = f"{m.group(1)}.{m.group(2)}"
return file_map
def _java_file_name(java_cfg: dict, assign: str) -> str:
"""Java 侧实际使用的文件名:优先主类源码 data/ 常量,否则回退 `<assign>.txt`。"""
fm = (java_cfg or {}).get('file_map') or {}
return fm.get(assign.upper()) or f'{assign}.txt'
def _discover_java(java_src_dir: str, program_name: str):
"""在 Java 工程中发现与 COBOL 程序对应的主类与可运行 classpath。
规则(对所有程序通用,非针对单本):
- 主类:src 下 `XxxMain.java`,去 `Main` 后缀后大写 == 程序ID
- classpathtarget/classes + target/*.jar + lib/*.jar(见 _build_java_classpath);
- file_map:主类源码中 data/ 常量 → 实际入力/出力文件名;
- 找不到主类或 classpath 为空时返回 None(调用方自动跳过)。
"""
root = Path(java_src_dir)
src = root / 'src'
if not src.is_dir():
return None
prog = str(program_name).upper()
main_cls = None
for f in src.glob('*Main.java'):
stem = f.stem
core = stem[:-4] if stem.endswith('Main') else stem
if core.upper() == prog:
main_cls = stem
break
if not main_cls:
return None
classpath = _build_java_classpath(java_src_dir)
if not classpath:
return None
file_map = _scan_java_data_files(src / f'{main_cls}.java')
return {'main_class': main_cls, 'classpath': classpath, 'root': str(root),
'file_map': file_map}
def _fd_record_len(fd_field_dicts: dict, fd_name: str) -> int:
"""由 FD 叶子字段长度推算固定长记录字节数(unknown 组项跳过)。"""
total = 0
for f in fd_field_dicts.get(fd_name, []) or []:
pi = f.get('pic_info') or {}
if pi.get('type') == 'unknown':
continue
total += pi.get('length') or (pi.get('digits', 0) + pi.get('decimal', 0)) or 0
return total
def _slice_fixed_records(data: bytes, rlen: int) -> list[bytes]:
if rlen <= 0:
return []
out = []
i = 0
while i + rlen <= len(data):
out.append(data[i:i + rlen])
i += rlen
return out
def _read_text_lines(path) -> list[str]:
"""读取 Java 侧每行一条记录的输出文件。"""
p = Path(path)
if not p.exists():
return []
text = p.read_text(encoding='utf-8')
if text == '':
return []
lines = text.split('\n')
if lines and lines[-1] == '':
lines.pop()
return [ln.rstrip('\r') for ln in lines]
def _output_fd_items(fd_field_dicts: dict, open_dir: dict,
select_info: dict) -> list[tuple[str, str]]:
"""返回 [(fd_name, assign)],仅输出系 FD。"""
items = []
seen = set()
for fd_name in fd_field_dicts:
direction = (open_dir or {}).get(fd_name, '')
if direction not in ('OUTPUT', 'I-O'):
continue
sel = select_info.get(fd_name, {})
assign = sel.get('assign', fd_name) if isinstance(sel, dict) else fd_name
if assign not in seen:
seen.add(assign)
items.append((fd_name, assign))
return items
def _input_fd_assign_len(fd_field_dicts: dict, open_dir: dict,
select_info: dict) -> list[tuple[str, int]]:
"""返回 [(assign, record_len)],仅输入系 FD(含 I-O)。"""
out = []
seen = set()
for fd_name in fd_field_dicts:
direction = (open_dir or {}).get(fd_name, '')
if direction not in ('INPUT', 'I-O'):
continue
sel = select_info.get(fd_name, {})
assign = sel.get('assign', fd_name) if isinstance(sel, dict) else fd_name
if assign in seen:
continue
seen.add(assign)
rlen = _fd_record_len(fd_field_dicts, fd_name)
if rlen > 0:
out.append((assign, rlen))
return out
def _prepare_java_inputs(java_work: Path, src_in_dir: Path,
input_items: list[tuple[str, int]],
java_cfg: dict | None = None) -> None:
data_dir = java_work / 'data'
data_dir.mkdir(parents=True, exist_ok=True)
for assign, rlen in input_items:
src = Path(src_in_dir) / assign
if not src.exists():
continue
data = src.read_bytes()
recs = _slice_fixed_records(data, rlen)
lines = [r.decode('utf-8', 'replace') for r in recs]
txt = '\n'.join(lines)
if lines:
txt += '\n'
(data_dir / _java_file_name(java_cfg, assign)).write_text(txt, encoding='utf-8')
def _run_java(java_cfg: dict, java_work: Path, log_path: Path) -> int:
java_exe = _find_java_exe()
cmd = [java_exe, '-cp', java_cfg['classpath'], java_cfg['main_class']]
orig = os.getcwd()
try:
os.chdir(str(java_work))
p = subprocess.run(cmd, capture_output=True, text=True,
encoding='utf-8', errors='replace', timeout=90)
except subprocess.TimeoutExpired:
p = None
finally:
os.chdir(orig)
if log_path:
log_path.parent.mkdir(parents=True, exist_ok=True)
if p is None:
log_path.write_text('COMMAND: %s\nTIMEOUT' % ' '.join(cmd), encoding='utf-8')
else:
log_path.write_text(
f"COMMAND: {' '.join(cmd)}\nRETURNCODE: {p.returncode}\n\n"
f"STDOUT:\n{p.stdout}\n\nSTDERR:\n{p.stderr}",
encoding='utf-8')
return p.returncode if p is not None else -1
def _collect_java_outputs(java_work: Path, java_out_dir: Path,
fd_field_dicts: dict, open_dir: dict,
select_info: dict,
java_cfg: dict | None = None) -> None:
java_out_dir.mkdir(parents=True, exist_ok=True)
for fd_name, assign in _output_fd_items(fd_field_dicts, open_dir, select_info):
src = java_work / 'data' / _java_file_name(java_cfg, assign)
if src.exists():
try:
shutil.copy2(str(src), str(java_out_dir / _java_file_name(java_cfg, assign)))
except OSError:
pass
def _compare_cobol_java(cobol_out_dir: Path, java_out_dir: Path,
fd_field_dicts: dict, open_dir: dict,
select_info: dict,
java_cfg: dict | None = None) -> list[dict]:
"""逐 FD 比对 COBOL 出力(固定长字节)与 Java 出力(每行一条)。
返回每个输出 FD 的汇总 dict:
{assign, cobol_count, java_count, matched, mismatched, samples}
samples 为最多 3 条差异示例(含记录号与首个不同位置前后片段)。
"""
summaries = []
for fd_name, assign in _output_fd_items(fd_field_dicts, open_dir, select_info):
sel = select_info.get(fd_name, {})
org = sel.get('organization', 'SEQUENTIAL') if isinstance(sel, dict) else 'SEQUENTIAL'
rlen = _fd_record_len(fd_field_dicts, fd_name)
cobol_path = Path(cobol_out_dir) / assign
if cobol_path.exists():
data = cobol_path.read_bytes()
if str(org).upper() == 'LINE SEQUENTIAL':
cobol_lines = [ln.rstrip('\n').rstrip('\r') for ln in
data.decode('utf-8', 'replace').split('\n')]
if cobol_lines and cobol_lines[-1] == '':
cobol_lines.pop()
else:
cobol_lines = [r.decode('utf-8', 'replace')
for r in _slice_fixed_records(data, rlen)]
else:
cobol_lines = []
java_lines = _read_text_lines(
Path(java_out_dir) / _java_file_name(java_cfg, assign))
matched = 0
mismatched = 0
samples = []
n = max(len(cobol_lines), len(java_lines))
for i in range(n):
c = cobol_lines[i] if i < len(cobol_lines) else '<MISSING>'
j = java_lines[i] if i < len(java_lines) else '<MISSING>'
if c == j:
matched += 1
else:
mismatched += 1
if len(samples) < 3:
pos = next((k for k in range(min(len(c), len(j)))
if c[k] != j[k]), min(len(c), len(j)))
samples.append({
'index': i,
'pos': pos,
'cobol': c[:60],
'java': j[:60],
})
summaries.append({
'assign': assign,
'cobol_count': len(cobol_lines),
'java_count': len(java_lines),
'matched': matched,
'mismatched': mismatched,
'samples': samples,
})
return summaries
def _java_scene_label(dst_out_dir: Path) -> str:
"""由 cobol 出力目录推导 java 场景目录名:cobol/main/output -> main。"""
cobol_scene = Path(dst_out_dir).parent # .../cobol/main
return cobol_scene.name
def _run_java_scene(program_name: str, scene_id: str, outdir: str, temp_dir: str,
src_in_dir: Path, dst_out_dir: Path,
fd_field_dicts: dict, open_dir: dict, select_info: dict,
java_cfg: dict, log_dir: str | None = None) -> dict:
"""单场景:准备 Java 入力 -> 运行 jar -> 收集出力 -> 与 COBOL 出力比对。"""
label = _java_scene_label(dst_out_dir)
java_out_dir = Path(outdir) / 'java' / label / 'output'
java_work = Path(temp_dir) / f'java_{scene_id}'
input_items = _input_fd_assign_len(fd_field_dicts, open_dir, select_info)
if not input_items:
logger.warning(f" Java[{label}]: 未发现输入 FD,跳过")
return {'label': label, 'rc': -1, 'summaries': []}
try:
if java_work.exists():
shutil.rmtree(str(java_work))
_prepare_java_inputs(java_work, src_in_dir, input_items, java_cfg)
log_path = Path(log_dir) / f'{program_name}_java_{label}.log' if log_dir else None
rc = _run_java(java_cfg, java_work, log_path)
_collect_java_outputs(java_work, java_out_dir, fd_field_dicts, open_dir, select_info,
java_cfg)
summaries = _compare_cobol_java(dst_out_dir, java_out_dir,
fd_field_dicts, open_dir, select_info,
java_cfg)
except Exception as e: # noqa: BLE001
logger.warning(f" Java[{label}] 运行/比对失败: {e}")
return {'label': label, 'rc': -1, 'summaries': [], 'error': str(e)}
total_matched = sum(s['matched'] for s in summaries)
total_mismatched = sum(s['mismatched'] for s in summaries)
logger.info(f" Java[{label}]: rc={rc}, 出力比对 一致={total_matched} 不一致={total_mismatched}")
return {'label': label, 'rc': rc, 'summaries': summaries}
def _write_java_test_report(outdir: str, program_name: str,
results: list,
java_reports: list[dict]) -> None:
"""汇总多组(main/skip 等)COBOL vs Java 出力比对结果,生成中文测试报告。"""
lines = []
lines.append(f"# {program_name} COBOL vs Java 出力比对测试报告")
lines.append('')
lines.append(f"- 程序ID: {program_name}")
lines.append('- 报告类型: COBOL 出力 vs Java 出力 逐 FD 比对(多组出力分别比较)')
lines.append('')
lines.append('## 1. COBOL 执行结果(V3 cobol_testgen runner')
lines.append('')
lines.append('| 组 | returncode | 判定 |')
lines.append('|----|-----------|------|')
for r in results:
mark = '通过' if r.passed else '差异'
lines.append(f"| {r.name} | {r.returncode} | {mark} |")
lines.append('')
lines.append('## 2. Java 执行与出力比对(逐组逐 FD)')
lines.append('')
for rep in java_reports:
lines.append(f'### 2.{java_reports.index(rep) + 1} 组 `{rep["label"]}` (Java rc={rep.get("rc", "N/A")})')
lines.append('')
if rep.get('error'):
lines.append(f'运行/比对异常: {rep["error"]}')
lines.append('')
continue
if not rep['summaries']:
lines.append('(无输出 FD 可比对)')
lines.append('')
continue
lines.append('| 出力FD | COBOL记录数 | Java记录数 | 一致 | 不一致 | 判定 |')
lines.append('|--------|------------|-----------|------|--------|------|')
for s in rep['summaries']:
verdict = '一致' if s['mismatched'] == 0 else '不一致'
lines.append(
f"| {s['assign']} | {s['cobol_count']} | {s['java_count']} "
f"| {s['matched']} | {s['mismatched']} | {verdict} |")
diff = [s for s in rep['summaries'] if s['mismatched'] > 0]
if diff:
lines.append('')
lines.append('**差异示例(最多 3 条/FD)**:')
for s in diff:
for smp in s['samples']:
lines.append(f"- {s['assign']}[记录{smp['index']}] 首异位置={smp['pos']}")
lines.append(f" - COBOL: `{smp['cobol']}`")
lines.append(f" - Java : `{smp['java']}`")
lines.append('')
all_fd = [s for rep in java_reports for s in rep.get('summaries', [])]
total_match = sum(s['matched'] for s in all_fd)
total_mis = sum(s['mismatched'] for s in all_fd)
lines.append('## 3. 总结')
lines.append('')
if total_match + total_mis == 0:
lines.append('- 无可比对的出力记录(或未发现 Java 对应程序)。')
elif total_mis == 0:
lines.append(f'- COBOL 与 Java 全部出力记录一致(一致 {total_match} 条,不一致 0 条)。')
lines.append('- 判定: **通过**')
else:
lines.append(f'- COBOL 与 Java 出力存在差异:一致 {total_match} 条,不一致 {total_mis} 条。')
lines.append('- 判定: **存在差异**,详见上文逐 FD 明细。')
lines.append('')
lines.append('> 覆盖率详情见 `coverage/` 目录 HTML 报告;Java 出力见 `java/<组>/output/`。')
lines.append('')
report_path = Path(outdir) / f'{program_name}_测试报告.md'
report_path.write_text('\n'.join(lines), encoding='utf-8')
logger.info(f" COBOL vs Java 测试报告: {report_path}")
# ── 主编排 ── # ── 主编排 ──
@@ -388,7 +803,8 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
path_infos: list | None = None, path_infos: list | None = None,
multi_write_fds: set | None = None, multi_write_fds: set | None = None,
skip_records: list[dict] | None = None, skip_records: list[dict] | None = None,
skip_term_types: list[str] | None = None skip_term_types: list[str] | None = None,
java_src_dir: str | None = None
) -> tuple[list[GroupResult], dict[int, int] | None]: ) -> tuple[list[GroupResult], dict[int, int] | None]:
"""完整编排:编译 → 准备目录 → 逐组执行 → 出力保存。 """完整编排:编译 → 准备目录 → 逐组执行 → 出力保存。
@@ -423,17 +839,30 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
# ── 3. 场景定义 ── # ── 3. 场景定义 ──
scenes = [("main", records, term_types, expected, scenes = [("main", records, term_types, expected,
Path(outdir) / 'main' / 'input', Path(outdir) / 'main' / 'output')] Path(outdir) / 'cobol' / 'main' / 'input', Path(outdir) / 'cobol' / 'main' / 'output')]
if skip_records: if skip_records:
skip_expected = [{}] * len(skip_records) skip_expected = [{}] * len(skip_records)
skip_term = skip_term_types or ['normal'] * len(skip_records) skip_term = skip_term_types or ['normal'] * len(skip_records)
scenes.append(("skip", skip_records, skip_term, skip_expected, scenes.append(("skip", skip_records, skip_term, skip_expected,
Path(outdir) / 'skip' / 'input', Path(outdir) / 'skip' / 'output')) Path(outdir) / 'cobol' / 'run_skip' / 'input', Path(outdir) / 'cobol' / 'run_skip' / 'output'))
results = [] results = []
gcov_data_sets = [] gcov_data_sets = []
gcov_root = work_dir / "gcov" gcov_root = work_dir / "gcov"
# ── 2.5 Java 工程发现(可选,无对应 Java 程序时整体跳过)──
java_reports = []
java_cfg = None
if str(os.environ.get('COBOL_JAVA_RUN', '1')).lower() not in ('0', 'off', 'false'):
_jsrc = (java_src_dir
or os.environ.get('COBOL_JAVA_SRC_DIR')
or str(Path(source_dir).parent / 'JavaSrc'))
java_cfg = _discover_java(_jsrc, program_name)
if java_cfg:
logger.info(f" 发现 Java 对应程序: {java_cfg['main_class']}")
else:
logger.info(f" 未发现 Java 对应程序({_jsrc}),跳过 Java 运行/比对")
for scene_id, scene_recs, scene_terms, scene_expected, src_in_dir, dst_out_dir in scenes: for scene_id, scene_recs, scene_terms, scene_expected, src_in_dir, dst_out_dir in scenes:
# ── 3a. 入力ファイル配置(主程序 + 被调子程序的输入文件全部复制)── # ── 3a. 入力ファイル配置(主程序 + 被调子程序的输入文件全部复制)──
# 仅复制 assign_names 会漏掉子程序输入文件(测试驱动调用读文件自程序时 # 仅复制 assign_names 会漏掉子程序输入文件(测试驱动调用读文件自程序时
@@ -530,6 +959,16 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
logger.info(f" {scene_id} 完了, output={dst_out_dir}") logger.info(f" {scene_id} 完了, output={dst_out_dir}")
# ── 3h. Java 文件式运行 + COBOL vs Java 出力比对(仅在有 Java 对应程序时)──
if java_cfg:
jrep = _run_java_scene(
program_name, scene_id, outdir, str(work_dir),
src_in_dir, dst_out_dir,
fd_field_dicts, open_dir, select_info,
java_cfg, log_dir=log_dir,
)
java_reports.append(jrep)
# ── 4. 合并 gcov ── # ── 4. 合并 gcov ──
merged_gcov = None merged_gcov = None
if gcov_data_sets: if gcov_data_sets:
@@ -539,6 +978,13 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
merged_gcov[line] = max(merged_gcov.get(line, 0), count) merged_gcov[line] = max(merged_gcov.get(line, 0), count)
logger.info(f" Merged gcov from {len(gcov_data_sets)} runs ({len(merged_gcov)} lines)") logger.info(f" Merged gcov from {len(gcov_data_sets)} runs ({len(merged_gcov)} lines)")
# ── 5. 生成 COBOL vs Java 出力比对测试报告 ──
if java_cfg and java_reports:
try:
_write_java_test_report(outdir, program_name, results, java_reports)
except Exception as e: # noqa: BLE001
logger.warning(f" 测试报告生成失败: {e}")
return results, merged_gcov return results, merged_gcov
+28 -5
View File
@@ -1,20 +1,43 @@
def align_records(cobol_records: list[dict], java_records: list[dict], def align_records(cobol_records: list[dict], java_records: list[dict],
key_field: str = "CUST-ID") -> list[tuple]: key_field: str = "CUST-ID") -> list[tuple]:
"""对齐COBOL和Java记录
Args:
cobol_records: COBOL输出记录列表
java_records: Java输出记录列表
key_field: 用于对齐的关键字段名(默认为"CUST-ID"
Returns:
对齐结果列表,每个元素为 (cobol_record, java_record, status)
status: "MATCHED", "MISSING_IN_SPARK", "EXTRA_IN_SPARK"
"""
if not cobol_records and not java_records: if not cobol_records and not java_records:
return [] return []
# 智能关键字段推断:如果默认key_field不存在,尝试推断
effective_key = key_field
if cobol_records:
sample_record = cobol_records[0]
if effective_key not in sample_record:
# 尝试常见的关键字段名
common_keys = ["ID", "CUST-ID", "EMP-ID", "KEY", "CODE", "NO"]
for k in common_keys:
if k in sample_record:
effective_key = k
break
def _by(records, kf): def _by(records, kf):
d = {} d = {}
for r in records: for r in records:
key = str(r.get(kf, "__NONE__")) key = str(r.get(kf, "__NONE__"))
d.setdefault(key, []).append(r) d.setdefault(key, []).append(r)
return d return d
c_by = _by(cobol_records, key_field) c_by = _by(cobol_records, effective_key)
j_by = _by(java_records, key_field) j_by = _by(java_records, effective_key)
pairs = [] pairs = []
all_keys = set(c_by) | set(j_by) all_keys = set(c_by) | set(j_by)
for k in sorted(all_keys): for k in sorted(all_keys):
c_items = c_by.get(k, []) c_items = c_by.get(k, [])
j_items = j_by.get(k, []) j_items = j_by.get(k, [])
+400
View File
@@ -0,0 +1,400 @@
# V3系统目录结构设计文档
## 一、目录结构概述
V3系统采用统一的目录结构来组织COBOL和Java的输出结果,确保所有程序的输出文件都位于同一个根目录下,便于管理和比较。
## 二、目录结构规范
### 2.1 标准目录结构
```
output/<PROGRAM_ID>/
├── cobol/ # COBOL输出(所有组)
│ ├── main/ # 单轮/默认场景
│ │ ├── input/ # COBOL输入flat文件
│ │ ├── output/ # COBOL输出flat文件
│ │ └── json/ # Java验证用JSON
│ ├── run_skip/ # 跳过场景
│ │ ├── input/
│ │ ├── output/
│ │ └── json/
│ ├── run_<scenario_id>/ # 多轮场景
│ │ ├── input/
│ │ ├── output/
│ │ └── json/
│ └── pre_src/ # 预处理源码
├── java/ # Java输出(所有组)
│ ├── main/ # 对应cobol/main的Java输出
│ │ └── output/
│ ├── run_skip/ # 对应cobol/run_skip的Java输出
│ │ └── output/
│ └── run_<scenario_id>/ # 对应cobol/run_*的Java输出
│ └── output/
├── coverage/ # 覆盖率报告(全局)
├── logs/ # 日志(全局)
│ ├── main.log
│ ├── run_skip.log
│ └── run_<scenario_id>.log
├── gcov/ # gcov数据(按场景分离)
│ ├── run_main/
│ ├── run_skip/
│ └── run_<scenario_id>/
├── data/ # SQLite数据库(按场景分离)
│ ├── kin.db # 单轮场景
│ └── kin_<scenario_id>.db # 多轮场景
└── reports/ # 测试报告
└── <timestamp>/
├── result.json
├── report.html
└── machine.json
```
### 2.2 目录命名规则
| 目录 | 命名规则 | 说明 |
|------|----------|------|
| `cobol/main/` | 固定名称 | 单轮/默认场景 |
| `cobol/run_skip/` | 固定名称 | 跳过场景(原 `skip/` |
| `cobol/run_<id>/` | `run_` + 场景ID | 多轮场景 |
| `java/<对应cobol目录>/` | 与cobol目录对应 | Java输出 |
| `coverage/` | 固定名称 | 覆盖率报告 |
| `logs/` | 固定名称 | 日志文件 |
| `gcov/run_<id>/` | `run_` + 场景ID | gcov数据 |
| `data/` | 固定名称 | SQLite数据库 |
### 2.3 文件命名规则
| 文件类型 | 命名规则 | 示例 |
|----------|----------|------|
| COBOL输入文件 | `<PROGRAM_ID>R<NN>` | `KIN01R01`, `ZAN01R01` |
| COBOL输出文件 | `<PREFIX>W<NN>` | `KIN01W01`, `ZAN01W01` |
| JSON中介文件 | `<PROGRAM_ID>.json` | `KIN01INP.json` |
| 覆盖率报告 | `<PROGRAM_ID>_coverage.html` | `KIN01INP_coverage.html` |
| 运行日志 | `<场景ID>.log` | `main.log`, `run_normal.log` |
| gcov数据 | 按场景ID分目录 | `gcov/run_main/`, `gcov/run_skip/` |
| SQLite数据库 | `kin_<场景ID>.db` | `kin.db`, `kin_normal.db` |
## 三、场景映射规则
### 3.1 单轮场景
**定义**:程序只有一次执行,无多场景配置。
**目录结构**
```
output/<PROGRAM_ID>/
├── cobol/
│ └── main/
│ ├── input/
│ ├── output/
│ └── json/
├── java/
│ └── main/
│ └── output/
├── coverage/
├── logs/
│ └── main.log
├── gcov/
│ └── run_main/
└── data/
└── kin.db
```
### 3.2 多轮场景
**定义**:程序有多次执行,通过YAML配置文件定义场景。
**目录结构**
```
output/<PROGRAM_ID>/
├── cobol/
│ ├── run_normal/
│ │ ├── input/
│ │ ├── output/
│ │ └── json/
│ ├── run_insert_error/
│ │ ├── input/
│ │ ├── output/
│ │ └── json/
│ └── run_sql_delete_error/
│ ├── input/
│ ├── output/
│ └── json/
├── java/
│ ├── run_normal/
│ │ └── output/
│ ├── run_insert_error/
│ │ └── output/
│ └── run_sql_delete_error/
│ └── output/
├── coverage/
├── logs/
│ ├── run_normal.log
│ ├── run_insert_error.log
│ └── run_sql_delete_error.log
├── gcov/
│ ├── run_normal/
│ ├── run_insert_error/
│ └── run_sql_delete_error/
└── data/
├── kin_normal.db
├── kin_insert_error.db
└── kin_sql_delete_error.db
```
### 3.3 跳过场景
**定义**:程序有跳过主FD输入的场景(旧版 `skip/` 目录)。
**目录结构**
```
output/<PROGRAM_ID>/
├── cobol/
│ ├── main/
│ │ ├── input/
│ │ ├── output/
│ │ └── json/
│ └── run_skip/
│ ├── input/
│ ├── output/
│ └── json/
├── java/
│ ├── main/
│ │ └── output/
│ └── run_skip/
│ └── output/
├── coverage/
├── logs/
│ ├── main.log
│ └── run_skip.log
├── gcov/
│ ├── run_main/
│ └── run_skip/
└── data/
├── kin.db
└── kin_skip.db
```
## 四、路径依赖说明
### 4.1 env_overrides 路径
COBOL运行时通过环境变量映射文件路径:
```python
# 输入文件
env_overrides[fname] = os.path.join("input", fname)
# 输出文件
env_overrides[fname] = os.path.join("output", fname)
```
**注意**:这些是相对于CWD的路径,CWD为 `cobol/main/``cobol/run_<id>/`
### 4.2 SQLite数据库路径
```python
# 单轮场景
db_path = data/kin.db
# 多轮场景
db_path = data/kin_<scenario_id>.db
```
### 4.3 gcov数据路径
```python
# 单轮场景
gcov_dir = gcov/run_main/
# 多轮场景
gcov_dir = gcov/run_<scenario_id>/
```
## 五、向后兼容性
### 5.1 旧版目录迁移
| 旧版目录 | 新版目录 | 迁移方式 |
|----------|----------|----------|
| `main/input/` | `cobol/main/input/` | 移动文件 |
| `main/output/` | `cobol/main/output/` | 移动文件 |
| `main/json/` | `cobol/main/json/` | 移动文件 |
| `skip/input/` | `cobol/run_skip/input/` | 重命名+移动 |
| `skip/output/` | `cobol/run_skip/output/` | 重命名+移动 |
| `skip/json/` | `cobol/run_skip/json/` | 重命名+移动 |
| `pre_src/` | `cobol/pre_src/` | 移动文件 |
| `coverage/` | `coverage/` | 保持不变 |
| `logs/` | `logs/` | 保持不变 |
| `gcov/` | `gcov/` | 保持不变 |
| `data/` | `data/` | 保持不变 |
### 5.2 兼容性处理
```python
def _ensure_cobol_dir_structure(runtime_dir):
"""确保cobol目录结构存在,兼容旧版"""
cobol_dir = runtime_dir / "cobol"
if not cobol_dir.exists():
# 检查是否是旧版结构(main/直接在runtime_dir下)
old_main = runtime_dir / "main"
if old_main.exists():
# 迁移到新结构
shutil.move(str(old_main), str(cobol_dir / "main"))
```
## 六、黑盒测试映射
### 6.1 YAML配置示例
```yaml
# config/programs/KIN08DBU.yaml
runs:
- id: normal
sysin:
- { dd: KIN08S01, content: "..." }
- id: no_period
sysin:
- { dd: KIN08S01, content: "..." }
- id: insert_error
inject_duplicate_pk: true
sysin:
- { dd: KIN08S01, content: "..." }
- id: sql_delete_error
sysin:
- { dd: KIN08S01, content: "..." }
- id: sql_select_error
sysin:
- { dd: KIN08S01, content: "..." }
```
### 6.2 目录映射
| 场景ID | COBOL目录 | Java目录 | 日志文件 | gcov目录 | 数据库文件 |
|--------|-----------|----------|----------|----------|------------|
| normal | `cobol/run_normal/` | `java/run_normal/output/` | `logs/run_normal.log` | `gcov/run_normal/` | `data/kin_normal.db` |
| no_period | `cobol/run_no_period/` | `java/run_no_period/output/` | `logs/run_no_period.log` | `gcov/run_no_period/` | `data/kin_no_period.db` |
| insert_error | `cobol/run_insert_error/` | `java/run_insert_error/output/` | `logs/run_insert_error.log` | `gcov/run_insert_error/` | `data/kin_insert_error.db` |
| sql_delete_error | `cobol/run_sql_delete_error/` | `java/run_sql_delete_error/output/` | `logs/run_sql_delete_error.log` | `gcov/run_sql_delete_error/` | `data/kin_sql_delete_error.db` |
| sql_select_error | `cobol/run_sql_select_error/` | `java/run_sql_select_error/output/` | `logs/run_sql_select_error.log` | `gcov/run_sql_select_error/` | `data/kin_sql_select_error.db` |
### 6.3 黑盒测试执行流程
1. **编译阶段**:编译一次,全场景共享 `.exe``.gcno`
2. **场景执行**:对每个场景独立执行
- 生成场景特定输入数据
- 初始化场景特定DB
- 运行COBOL程序
- 收集场景特定gcov数据
3. **Java执行**:使用最后一个场景的DB结果
4. **验证比对**:比较COBOL和Java输出
5. **覆盖率合并**:合并多轮gcov数据,生成覆盖率报告
### 6.4 测试报告生成
| 报告类型 | 生成方式 | 输出位置 |
|----------|----------|----------|
| 覆盖率HTML报告 | `orchestrator_db.generate_coverage_report()` | `coverage/<PROGRAM_ID>_coverage.html` |
| 测试结果JSON | `orchestrator.run_all()` | `reports/<timestamp>/result.json` |
| 测试报告HTML | `ReportGenerator.generate_html()` | `reports/<timestamp>/report.html` |
| 机器可读JSON | `ReportGenerator.generate_machine_json()` | `reports/<timestamp>/machine.json` |
## 七、代码修改清单
### 7.1 orchestrator_db.py
| 行号 | 修改内容 | 说明 |
|------|----------|------|
| 103 | `runtime_dir = v3_root / "output" / program_id / "cobol"` | 添加cobol子目录 |
| 594-596 | `run_label = "main" if not scenario else f"run_{scenario.id}"` | 统一场景目录命名 |
| 626-628 | `env_overrides` 路径改为 `"input"``"output"` | 移除main/层级 |
| 686 | `log_dir = self.runtime_dir.parent / "logs"` | 日志目录移到根目录 |
| 700 | `gcda_dst_dir = gcov_dir / run_label` | 统一gcov目录命名 |
| 769 | `output_dir = v3_root / "output" / self.program_id / "coverage"` | 覆盖率报告移到根目录 |
| 779 | `gcov_dir = self.runtime_dir.parent / "gcov"` | gcov目录移到根目录 |
| 975 | `java_out = self.runtime_dir.parent / "java" / run_label / "output"` | Java输出目录 |
| 1016 | `self.java_output_path = self.runtime_dir.parent / "java" / run_label / "output"` | Java输出路径 |
### 7.2 cobol_testgen/__init__.py
| 行号 | 修改内容 | 说明 |
|------|----------|------|
| 1759 | `outpath = prog_outdir / 'cobol' / 'main' / 'json'` | JSON输出路径 |
| 1769 | `prog_outdir / 'cobol' / 'main' / 'input'` | 输入文件路径 |
| 1776 | `prog_outdir / 'cobol' / 'main' / 'input'` | 子程序输入路径 |
| 1796 | `prog_outdir / 'cobol' / 'run_skip' / 'json'` | Skip JSON路径 |
| 1802 | `prog_outdir / 'cobol' / 'run_skip' / 'input'` | Skip输入路径 |
### 7.3 cobol_testgen/runner.py
| 行号 | 修改内容 | 说明 |
|------|----------|------|
| 425-426 | `Path(outdir) / 'cobol' / 'main' / 'input'` | 主场景输入路径 |
| 425-426 | `Path(outdir) / 'cobol' / 'main' / 'output'` | 主场景输出路径 |
| 430-431 | `Path(outdir) / 'cobol' / 'run_skip' / 'input'` | Skip场景输入路径 |
| 430-431 | `Path(outdir) / 'cobol' / 'run_skip' / 'output'` | Skip场景输出路径 |
### 7.4 runners/gixsql_runner.py
| 行号 | 修改内容 | 说明 |
|------|----------|------|
| 308 | `debug_dir = ... / "cobol" / "pre_src"` | 预处理源码路径 |
### 7.5 orchestrator.py
| 行号 | 修改内容 | 说明 |
|------|----------|------|
| 142 | `co = Path(f"output/{cfg.program}/cobol/main/output/cobol_out.bin")` | COBOL输出路径 |
| 159 | `java_out_dir = Path("output") / cfg.program / "java" / "main" / "output"` | Java输出路径 |
| 200 | `rd = Path(f"output/{vr.program}/reports") / vr.timestamp` | 报告输出路径 |
## 八、测试验证
### 8.1 单元测试
运行现有单元测试确保向后兼容:
```bash
python -m pytest tests/ -v
```
### 8.2 集成测试
运行黑盒测试验证目录结构:
```bash
cd test-data
python s15_coverage_verification.py
python s30_db_e2e.py
```
### 8.3 手动验证
检查目录结构是否正确:
```bash
# 检查单轮程序
ls -la output/KIN01INP/
ls -la output/KIN01INP/cobol/main/
ls -la output/KIN01INP/java/main/output/
# 检查多轮程序
ls -la output/KIN08DBU/
ls -la output/KIN08DBU/cobol/run_normal/
ls -la output/KIN08DBU/java/run_normal/output/
```
## 九、注意事项
1. **env_overrides 路径**:修改后需要确保COBOL运行时能找到正确的文件
2. **SQLite数据库路径**:多轮场景的DB文件需要按场景命名
3. **gcov数据合并**:多轮场景的gcov数据需要正确合并
4. **向后兼容**:需要处理旧版目录结构的迁移
5. **测试覆盖**:修改后需要运行所有测试确保功能正常
---
**文档版本**v1.0
**创建日期**2026-09-05
**最后更新**2026-09-05
+5 -3
View File
@@ -139,7 +139,7 @@ def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) ->
vr.debug["cobol_build"] = {"ok": build.success, "log": build.log[-300:]} vr.debug["cobol_build"] = {"ok": build.success, "log": build.log[-300:]}
if not build.success: if not build.success:
return _done(vr, t0, "BLOCKED", 2) return _done(vr, t0, "BLOCKED", 2)
co = Path("cobol_out.bin") co = Path(f"output/{cfg.program}/cobol/main/output/cobol_out.bin")
if not cob.run(build.artifact_path, str(bundle.cobol_input()), str(co)).success: if not cob.run(build.artifact_path, str(bundle.cobol_input()), str(co)).success:
return _done(vr, t0, "ERROR", 3) return _done(vr, t0, "ERROR", 3)
@@ -156,7 +156,9 @@ def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) ->
if not jb.success: if not jb.success:
return _done(vr, t0, "BLOCKED", 2) return _done(vr, t0, "BLOCKED", 2)
inp = str(bundle.spark_input_dir() if cfg.runner_mode == "spark" else bundle.native_input()) inp = str(bundle.spark_input_dir() if cfg.runner_mode == "spark" else bundle.native_input())
jr = runner.run(jb.artifact_path, inp, "java_out") java_out_dir = Path("output") / cfg.program / "java" / "main" / "output"
java_out_dir.mkdir(parents=True, exist_ok=True)
jr = runner.run(jb.artifact_path, inp, str(java_out_dir))
reader = CobolBinaryReader() reader = CobolBinaryReader()
cr = reader.read(str(co), tree) cr = reader.read(str(co), tree)
@@ -195,7 +197,7 @@ def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) ->
except: except:
pass pass
rd = Path(f"reports/{vr.program}") / vr.timestamp rd = Path(f"output/{vr.program}/reports") / vr.timestamp
rd.mkdir(parents=True, exist_ok=True) rd.mkdir(parents=True, exist_ok=True)
g = ReportGenerator() g = ReportGenerator()
g.generate_json(vr, rd / "result.json") g.generate_json(vr, rd / "result.json")
+561 -31
View File
@@ -90,6 +90,10 @@ class GixsqlOrchestrator:
self.cobol_src_dir = Path(cobol_src_dir) self.cobol_src_dir = Path(cobol_src_dir)
self.copybook_dirs = copybook_dirs or [] self.copybook_dirs = copybook_dirs or []
self.skip_jvm = skip_jvm self.skip_jvm = skip_jvm
# DB-Java 文件式运行 + DB 表比对(run_java=True 时在每场景 Step3 后触发)
self.run_java: bool = False
self._java_cfg_cache = None
self._db_java_results: list[dict] = []
v3_root = Path(__file__).parent # cobol-java-v3/ v3_root = Path(__file__).parent # cobol-java-v3/
# Build artifacts in temp (ASCII-only, gixpp can't handle Chinese paths) # Build artifacts in temp (ASCII-only, gixpp can't handle Chinese paths)
@@ -100,7 +104,8 @@ class GixsqlOrchestrator:
self.work_dir = Path(work_dir) self.work_dir = Path(work_dir)
# Runtime data under V3 (DB, flat files, CWD) # Runtime data under V3 (DB, flat files, CWD)
self.runtime_dir = v3_root / "output" / program_id # 新目录结构:output/<PROGRAM_ID>/cobol/ 存放COBOL输出
self.runtime_dir = v3_root / "output" / program_id / "cobol"
self.schema: ProgramSchema = load_schema(program_id) self.schema: ProgramSchema = load_schema(program_id)
@@ -122,6 +127,7 @@ class GixsqlOrchestrator:
self.exe_path: Optional[Path] = None self.exe_path: Optional[Path] = None
self.java_input_path: Optional[Path] = None self.java_input_path: Optional[Path] = None
self._current_db_path: Optional[Path] = None # scenario-specific DB path self._current_db_path: Optional[Path] = None # scenario-specific DB path
self._java_initial_db_snapshots: dict[str, Path] = {} # scenario label -> initial DB snapshot (before COBOL)
self._multi_run_gcov_data: dict[int, int] | None = None # merged multi-run gcov data self._multi_run_gcov_data: dict[int, int] | None = None # merged multi-run gcov data
self._sub_gcov_data: dict[str, dict[int, int]] = {} # per-subprogram gcov (kept separate from main) self._sub_gcov_data: dict[str, dict[int, int]] = {} # per-subprogram gcov (kept separate from main)
self.java_output_path: Optional[Path] = None self.java_output_path: Optional[Path] = None
@@ -590,11 +596,11 @@ class GixsqlOrchestrator:
"exe not found (run step1 first)") "exe not found (run step1 first)")
# シナリオ毎の出力先 # シナリオ毎の出力先
run_label = f"run_{scenario.id}" if scenario else "" run_label = f"run_{scenario.id}" if scenario else "main"
run_dir = self.runtime_dir / run_label if scenario else self.runtime_dir run_dir = self.runtime_dir / run_label
input_dir = run_dir / "main" / "input" input_dir = run_dir / "input"
output_dir = run_dir / "main" / "output" output_dir = run_dir / "output"
gcov_dir = self.runtime_dir / "gcov" gcov_dir = self.runtime_dir.parent / "gcov"
input_dir.mkdir(parents=True, exist_ok=True) input_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
gcov_dir.mkdir(parents=True, exist_ok=True) gcov_dir.mkdir(parents=True, exist_ok=True)
@@ -623,9 +629,9 @@ class GixsqlOrchestrator:
env_overrides = {} env_overrides = {}
for fname, direction in assign_map.items(): for fname, direction in assign_map.items():
if direction == "INPUT": if direction == "INPUT":
env_overrides[fname] = os.path.join("main", "input", fname) env_overrides[fname] = os.path.join("input", fname)
else: else:
env_overrides[fname] = os.path.join("main", "output", fname) env_overrides[fname] = os.path.join("output", fname)
# シナリオ毎の DB パス # シナリオ毎の DB パス
db_path = self._current_db_path or self.db_path db_path = self._current_db_path or self.db_path
@@ -645,10 +651,15 @@ class GixsqlOrchestrator:
shutil.copy2(str(db_path), str(cwd_db)) shutil.copy2(str(db_path), str(cwd_db))
# gixsql regex requires sqlite://host/path (single segment, no dots). # gixsql regex requires sqlite://host/path (single segment, no dots).
# Copy to CWD/kin (no extension) for sqlite://localhost/kin. # Copy to CWD/kin (no extension) for sqlite://localhost/kin.
# 同时复制到 kin.db,确保 gixsql 打开的是有 PRIMARY KEY 的版本
cwd_kin = cwd / "kin" cwd_kin = cwd / "kin"
if cwd_kin.exists(): if cwd_kin.exists():
cwd_kin.unlink() cwd_kin.unlink()
shutil.copy2(str(db_path), str(cwd_kin)) shutil.copy2(str(db_path), str(cwd_kin))
cwd_kin_db = cwd / "kin.db"
if cwd_kin_db.exists():
cwd_kin_db.unlink()
shutil.copy2(str(db_path), str(cwd_kin_db))
# .gcda は CWD= run_dir)に書き出されるので、実行後に gcov/run_{id}/ に移動する # .gcda は CWD= run_dir)に書き出されるので、実行後に gcov/run_{id}/ に移動する
# 各シナリオ実行前に前回の .gcda を削除(GnuCOBOL は累積書込みを行うため) # 各シナリオ実行前に前回の .gcda を削除(GnuCOBOL は累積書込みを行うため)
@@ -682,7 +693,7 @@ class GixsqlOrchestrator:
command_args=command_args, command_args=command_args,
) )
log_dir = self.runtime_dir / "logs" log_dir = self.runtime_dir.parent / "logs"
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
log_dir.joinpath(f"{run_label or self.program_id}.log").write_text( log_dir.joinpath(f"{run_label or self.program_id}.log").write_text(
result.log, encoding='utf-8') result.log, encoding='utf-8')
@@ -696,7 +707,7 @@ class GixsqlOrchestrator:
gcda_src_dirs.append(exe_dir_for_gcda) gcda_src_dirs.append(exe_dir_for_gcda)
if scenario is None: if scenario is None:
gcda_src_dirs.append(self.runtime_dir) # 従来互換 gcda_src_dirs.append(self.runtime_dir) # 従来互換
gcda_dst_dir = gcov_dir / run_label if scenario else gcov_dir gcda_dst_dir = gcov_dir / run_label
gcda_dst_dir.mkdir(parents=True, exist_ok=True) gcda_dst_dir.mkdir(parents=True, exist_ok=True)
for sd in gcda_src_dirs: for sd in gcda_src_dirs:
for f in sd.glob("*.gcda"): for f in sd.glob("*.gcda"):
@@ -715,6 +726,17 @@ class GixsqlOrchestrator:
except PermissionError: except PermissionError:
pass pass
# gixsql 通过 GIXSQL_DB_PATH 把 COBOL 结果写回 db_path(运行时唯一真实落盘文件)。
# CWD 下的 kin/kin.db 是运行前从种子库复制的快照;这里运行后回拷,
# 保证 _java_compare_scenario 读取的是 COBOL 的真实输出而不是过期的种子快照。
if db_path and db_path.exists():
for _dst in (cwd / 'kin', cwd / 'kin.db', cwd / 'data' / 'kin.db'):
try:
_dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(db_path), str(_dst))
except OSError as _e:
logger.warning(f" 运行后 DB 回拷失败 {_dst}: {_e}")
return DbPipelineResult( return DbPipelineResult(
self.program_id, 3, result.success, self.program_id, 3, result.success,
data={"returncode": result.returncode, "log": result.log[:500], data={"returncode": result.returncode, "log": result.log[:500],
@@ -765,7 +787,7 @@ class GixsqlOrchestrator:
f"exe not found at {self.exe_path} or {fallback}") f"exe not found at {self.exe_path} or {fallback}")
if output_dir is None: if output_dir is None:
v3_root = Path(__file__).parent v3_root = Path(__file__).parent
output_dir = v3_root / "reports" / self.program_id / "coverage" output_dir = v3_root / "output" / self.program_id / "coverage"
output_dir = Path(output_dir) output_dir = Path(output_dir)
# 1. Use pre-merged multi-run gcov data if available (skip gcov re-run) # 1. Use pre-merged multi-run gcov data if available (skip gcov re-run)
@@ -775,7 +797,7 @@ class GixsqlOrchestrator:
# plain integers that collide with the main program's (e.g. # plain integers that collide with the main program's (e.g.
# SUB04CHK line 167=0 would overwrite main line 167=25 and # SUB04CHK line 167=0 would overwrite main line 167=25 and
# wipe real coverage). Stored per-subprogram for reference. # wipe real coverage). Stored per-subprogram for reference.
gcov_dir = self.runtime_dir / "gcov" gcov_dir = self.runtime_dir.parent / "gcov"
self._sub_gcov_data = {} self._sub_gcov_data = {}
for sub in self.schema.subprograms: for sub in self.schema.subprograms:
sub_merged = _merge_run_dirs_gcov(gcov_dir, sub) sub_merged = _merge_run_dirs_gcov(gcov_dir, sub)
@@ -913,6 +935,344 @@ class GixsqlOrchestrator:
logger.exception("generate_coverage_report failed") logger.exception("generate_coverage_report failed")
return DbPipelineResult(self.program_id, 0, False, str(e)) return DbPipelineResult(self.program_id, 0, False, str(e))
# ── DB-Java 通用适配(文件式 Java 运行 + DB 表比对) ──
# 依赖 JavaSrc 工程(target/classes + target/*.jar + lib/*.jar,显式主类),
# 无需 mvn。Java 从“场景初始 DB 快照 + 同入力”出发,最终 DB/W01 与 COBOL 结果比对。
def _java_cfg(self):
if self._java_cfg_cache is None:
self._java_cfg_cache = None
try:
from cobol_testgen.runner import _discover_java
root = Path(self.cobol_src_dir).parent / 'JavaSrc'
cfg = _discover_java(str(root), self.program_id)
self._java_cfg_cache = cfg
if cfg:
logger.info(f" DB-Java: 发现主类 {cfg['main_class']}")
except Exception as e: # noqa: BLE001
logger.warning(f" DB-Java 发现失败: {e}")
return self._java_cfg_cache
def _java_db_rel_path(self) -> Optional[str]:
"""扫描 Java 主类源码中 jdbc:sqlite:<相对路径>,返回 DB 相对路径。"""
cfg = self._java_cfg()
if not cfg:
return None
try:
main = Path(cfg['root']) / 'src' / f"{cfg['main_class']}.java"
txt = main.read_text(encoding='utf-8', errors='replace')
m = re.search(r'jdbc:sqlite:([^\s"\'\)]+)', txt)
if m:
return m.group(1).replace('\\', os.sep)
except Exception: # noqa: BLE001
pass
return None
def _java_scenario_label(self, scenario: ScenarioDef | None) -> str:
return f"run_{scenario.id}" if scenario else "main"
def _java_prepare_scenario(self, scenario: ScenarioDef | None) -> Optional[dict]:
"""每场景准备 Java 运行目录:初始 DB 快照 + 入力 txt。"""
cfg = self._java_cfg()
if not cfg:
return None
label = self._java_scenario_label(scenario)
java_dir = self.runtime_dir.parent / 'java' / label
data_dir = java_dir / 'data'
if java_dir.exists():
shutil.rmtree(str(java_dir))
data_dir.mkdir(parents=True, exist_ok=True)
# 1) 初始 DB 快照(Step2 初始化后、COBOL 运行前的场景 DB)
# db_rel 为相对 Java CWD(=java_dir) 的路径(如 data/OVERTIME.DB),直接落到 java_dir 下
db_rel = self._java_db_rel_path()
# 优先使用 COBOL 运行前保存的初始快照,避免 Java 在 COBOL 修改后的 DB 上重复处理
label = self._java_scenario_label(scenario)
db_src = self._java_initial_db_snapshots.get(label) or self._current_db_path or self.db_path
if db_rel and db_src and Path(db_src).exists():
dst = java_dir / db_rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(db_src), str(dst))
logger.info(f" DB-Java[{label}]: 初始 DB 快照 -> {dst}")
# 2) 入力 txt80B 定长 → 行)
gen_input_dir = (self.work_dir / f"run_{scenario.id}" / "main" / "input") if scenario \
else (self.work_dir / "main" / "input")
written: list[str] = []
layouts: dict = {}
try:
from cobol_testgen.flatfile import analyze_fd_layout
if self.src_path and self.src_path.exists():
layouts = analyze_fd_layout(
self.src_path.read_text(encoding='utf-8-sig'),
[str(d) for d in self.copybook_dirs])
except Exception as e: # noqa: BLE001
logger.warning(f" DB-Java 布局解析失败: {e}")
for assign, lay in layouts.items():
direction = str(lay.get('direction', 'INPUT')).upper()
if direction not in ('INPUT', 'I-O'):
continue
recs = lay.get('records') or []
rlen = recs[0].get('record_length', 0) if recs else 0
src = gen_input_dir / assign
if not src.exists() or rlen <= 0:
continue
data = src.read_bytes()
lines = []
i = 0
while i + rlen <= len(data):
lines.append(data[i:i + rlen].decode('utf-8', 'replace'))
i += rlen
txt = '\n'.join(lines) + ('\n' if lines else '')
(data_dir / f'{assign}.txt').write_text(txt, encoding='utf-8')
written.append(assign)
logger.info(f" DB-Java[{label}]: 入力 {assign}.txt {len(lines)}")
if not written and not db_rel:
return None
return {'label': label, 'java_dir': str(java_dir), 'data_dir': str(data_dir),
'db_rel': db_rel, 'inputs': written, 'cfg': cfg}
def _java_run_scenario(self, scenario: ScenarioDef | None, info: dict) -> dict:
"""运行 Java 主类(文件式),返回 rc/日志。异常与 rc!=0 不抛出,仅记录。"""
cfg = info['cfg']
from cobol_testgen.runner import _find_java_exe
java_exe = _find_java_exe()
cmd = [java_exe, '-cp', cfg['classpath'], cfg['main_class']]
# command_line: scenario-level (if set) overrides program-level default
cmd_line = self.schema.command_line
if scenario and scenario.command_line is not None:
cmd_line = scenario.command_line
if cmd_line:
cmd.extend(cmd_line.split())
log_path = self.runtime_dir.parent / 'logs' / f"{self.program_id}_java_{info['label']}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
orig = os.getcwd()
p = None
try:
os.chdir(info['java_dir'])
p = subprocess.run(cmd, capture_output=True, text=True,
encoding='utf-8', errors='replace', timeout=180)
except subprocess.TimeoutExpired:
log_path.write_text(f"COMMAND: {' '.join(cmd)}\nTIMEOUT\n", encoding='utf-8')
return {'rc': -1, 'log': 'TIMEOUT'}
except Exception as e: # noqa: BLE001
log_path.write_text(f"COMMAND: {' '.join(cmd)}\nRUN ERROR: {e}\n", encoding='utf-8')
return {'rc': -2, 'log': str(e)}
finally:
os.chdir(orig)
log = f"COMMAND: {' '.join(cmd)}\nRETURNCODE: {p.returncode}\n\nSTDOUT:\n{p.stdout}\n\nSTDERR:\n{p.stderr}"
log_path.write_text(log, encoding='utf-8')
logger.info(f" DB-Java[{info['label']}]: rc={p.returncode}")
return {'rc': p.returncode, 'log': p.stdout + p.stderr}
def _collect_java_outputs(self, info: dict) -> None:
"""收集 Java 出力:txt 出力(排除入力)+ Java 运行后 DB 副本。"""
label = info['label']
java_out_dir = self.runtime_dir.parent / 'java' / label / 'output'
java_out_dir.mkdir(parents=True, exist_ok=True)
inputs = set(info.get('inputs') or [])
data_dir = Path(info['data_dir'])
for f in sorted(data_dir.glob('*.txt')):
if f.name in inputs:
continue
try:
shutil.copy2(str(f), str(java_out_dir / f.name))
except OSError:
pass
if info.get('db_rel'):
src = Path(info['java_dir']) / info['db_rel']
if src.exists():
try:
shutil.copy2(str(src), str(java_out_dir / f"{self.program_id}_java.db"))
except OSError:
pass
@staticmethod
def _db_table_snap(db_path) -> dict[str, list[dict]]:
snap: dict[str, list[dict]] = {}
if not db_path or not Path(db_path).exists():
return snap
try:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cur = conn.cursor()
tables = [r[0] for r in cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")]
for t in tables:
rows = []
try:
for row in cur.execute('SELECT * FROM "%s"' % t):
rows.append(dict(row))
except sqlite3.OperationalError:
continue
snap[t] = rows
conn.close()
except Exception as e: # noqa: BLE001
logger.warning(f" 读 DB 失败 {db_path}: {e}")
return snap
_IGNORE_DB_COLS = re.compile(
r'(updated_at|created_at|inserted_at|deleted_at|^.*timestamp.*$)', re.IGNORECASE)
@staticmethod
def _norm_db_value(v):
"""通用归一化:数值四舍五入到 6 位消除浮点噪声;字符串去两端空白。"""
s = str(v)
t = s.strip()
try:
f = float(t)
except ValueError:
return ('s', t)
return ('n', round(f, 6))
def _java_compare_scenario(self, scenario: ScenarioDef | None, info: dict, jrep: dict) -> dict:
"""COBOL 结果 DBrun_<id>/kin vs Java 结果 DB 逐表比对。"""
from collections import Counter
label = info['label']
run_dir = self.runtime_dir / label
cobol_db = run_dir / 'kin'
if not cobol_db.exists():
cobol_db = run_dir / 'data' / 'kin.db'
java_db = None
if info.get('db_rel'):
jp = Path(info['java_dir']) / info['db_rel']
if jp.exists():
java_db = jp
if not java_db:
return {'label': label, 'rc': jrep.get('rc', -1),
'error': 'Java DB 未生成(无 db_rel 或运行失败)', 'summaries': []}
c_tab = self._db_table_snap(cobol_db)
j_tab = self._db_table_snap(java_db)
keys = sorted(set(c_tab) | set(j_tab))
def _clean(row: dict) -> dict:
return {k: v for k, v in row.items() if not self._IGNORE_DB_COLS.search(k)}
def _canon(rows):
c = Counter()
for r in rows:
row = _clean(r)
# 忽略全为空的键(仅剩时间戳被剔除后的空行)
items = sorted((str(k),) + (self._norm_db_value(v),) for k, v in row.items())
c[tuple(items)] += 1
return c
summaries = []
for t in keys:
cc = _canon(c_tab.get(t, []))
jc = _canon(j_tab.get(t, []))
allk = set(cc) | set(jc)
matched = sum(min(cc[k], jc[k]) for k in allk)
# 不一致行数 = 两侧行数较小者中无法配成完全一致的行数(避免同一差异行重复计数)
min_rows = min(len(c_tab.get(t, [])), len(j_tab.get(t, [])))
mismatched = max(0, min_rows - matched)
samples = []
for k in sorted(allk, key=lambda x: str(x)):
if cc[k] != jc[k]:
side = 'COBOL' if cc[k] > jc[k] else 'Java'
disp = {}
for fld, typed in k:
if typed[0] == 'n':
fv = typed[1]
disp[fld] = str(int(fv)) if float(fv).is_integer() else str(fv)
else:
disp[fld] = typed[1]
samples.append({'side': side, 'diff': abs(cc[k] - jc[k]),
'row': {kk: str(vv)[:30] for kk, vv in disp.items()}})
if len(samples) >= 3:
break
summaries.append({'table': t, 'cobol_count': len(c_tab.get(t, [])),
'java_count': len(j_tab.get(t, [])),
'matched': matched, 'mismatched': mismatched,
'samples': samples})
return {'label': label, 'rc': jrep.get('rc', -1), 'summaries': summaries}
def _write_db_java_report(self, cobol_results: list) -> None:
lines = []
lines.append(f"# {self.program_id} COBOL vs Java 出力比对测试报告(DB 管道)")
lines.append('')
lines.append(f"- 程序ID: {self.program_id}")
lines.append('- 比对基准: COBOL 运行后 DB 表(run_<场景>/kin vs Java 从同初始 DB 运行后 DB 表')
lines.append('')
lines.append('## 1. COBOL 执行结果(DB 管道 run_all')
lines.append('')
lines.append('| 组 | returncode | 判定 |')
lines.append('|----|-----------|------|')
for label, res in cobol_results:
rc = (res.data or {}).get('returncode', '-')
mark = '通过' if res.success else '差异'
lines.append(f"| {label} | {rc} | {mark} |")
lines.append('')
lines.append('## 2. Java 执行与 DB 表比对(逐场景逐表)')
lines.append('')
for rep in self._db_java_results:
lines.append(f'### 2.{self._db_java_results.index(rep) + 1} 组 `{rep["label"]}` (Java rc={rep.get("rc", "N/A")})')
lines.append('')
if rep.get('error'):
lines.append(f'- {rep["error"]}')
lines.append('')
continue
if not rep.get('summaries'):
lines.append('(无可比对 DB 表)')
lines.append('')
continue
lines.append('| 表 | COBOL行数 | Java行数 | 一致 | 不一致 | 判定 |')
lines.append('|----|----------|----------|------|--------|------|')
for s in rep['summaries']:
count_ok = (s['cobol_count'] == s['java_count'])
verdict = '一致' if (count_ok and s['mismatched'] == 0) else '不一致'
lines.append(f"| {s['table']} | {s['cobol_count']} | {s['java_count']} "
f"| {s['matched']} | {s['mismatched']} | {verdict} |")
diff = [s for s in rep['summaries']
if s['mismatched'] > 0 or s['cobol_count'] != s['java_count']]
if diff:
lines.append('')
lines.append('**差异示例(最多 3 行/表,COBOL/Java 各侧存在但内容不同)**:')
for s in diff:
for smp in s['samples']:
lines.append(f"- {s['table']} [{smp['side']}侧 x{smp['diff']}]: "
f"{smp['row']}")
lines.append('')
all_fd = [s for rep in self._db_java_results for s in rep.get('summaries', [])]
tot_m = sum(s['matched'] for s in all_fd)
tot_mis = sum(s['mismatched'] for s in all_fd)
any_count_diff = any(s['cobol_count'] != s['java_count'] for s in all_fd)
lines.append('## 3. 总结')
lines.append('')
if not all_fd:
lines.append('- 无可比对的 DB 记录(Java 未产出或发现失败)。')
elif tot_mis == 0 and not any_count_diff:
lines.append(f'- COBOL 与 Java 全部 DB 记录一致(一致 {tot_m} 行,不一致 0 行)。')
lines.append('- 判定: **通过**')
else:
lines.append(f'- COBOL 与 Java DB 记录存在差异:一致 {tot_m} 行,内容不一致 {tot_mis}'
f"{'(另有行数不一致)' if any_count_diff else ''}")
lines.append('- 判定: **存在差异**,详见上文逐场景逐表明细。')
lines.append('')
lines.append('> 说明: Java 从各场景“初始 DB 快照 + 同入力”运行;Java 异常/rc!=0 已作为该场景结果记录(不阻断管道)。')
lines.append('')
report_path = self.runtime_dir.parent / f'{self.program_id}_测试报告.md'
report_path.write_text('\n'.join(lines), encoding='utf-8')
logger.info(f" DB-Java 比对报告: {report_path}")
def _run_db_java_scenario(self, scenario: ScenarioDef | None) -> None:
"""每场景执行:准备(初始DB快照+入力) -> 运行 Java -> 收集出力 -> 比对记录。"""
try:
info = self._java_prepare_scenario(scenario)
if not info:
logger.info(" DB-Java: 未发现 Java 对应程序或入力/DB 不可用,跳过")
return
jrep = self._java_run_scenario(scenario, info)
self._collect_java_outputs(info)
cres = self._java_compare_scenario(scenario, info, jrep)
self._db_java_results.append(cres)
except Exception as e: # noqa: BLE001
logger.warning(f" DB-Java 场景 {scenario.id if scenario else 'main'} 失败: {e}")
# ── Step 4: DB → Java 中介データ ── # ── Step 4: DB → Java 中介データ ──
def step4_extract_intermediate(self) -> DbPipelineResult: def step4_extract_intermediate(self) -> DbPipelineResult:
@@ -958,21 +1318,55 @@ class GixsqlOrchestrator:
# ── Step 5: Java 実行 ── # ── Step 5: Java 実行 ──
def step5_run_java(self, java_cmd: str = "java", def step5_run_java(self, java_cmd: str = "java",
java_jar: str | Path | None = None) -> DbPipelineResult: java_jar: str | Path | None = None,
"""Java プログラム実行""" java_src_dir: str | Path | None = None) -> DbPipelineResult:
"""Java プログラム実行
Args:
java_cmd: Java可执行文件路径
java_jar: Java JAR文件路径如果提供
java_src_dir: Java源代码目录如果提供将先编译
"""
if not self.java_input_path or not self.java_input_path.exists(): if not self.java_input_path or not self.java_input_path.exists():
return DbPipelineResult(self.program_id, 5, False, return DbPipelineResult(self.program_id, 5, False,
"intermediate data not found (run step4 first)") "intermediate data not found (run step4 first)")
java_out = self.work_dir / "java_output" run_label = "main" # 默认场景
java_out = self.runtime_dir.parent / "java" / run_label / "output"
java_out.mkdir(parents=True, exist_ok=True) java_out.mkdir(parents=True, exist_ok=True)
# 如果提供了Java源代码目录,先编译
if java_src_dir and not java_jar:
try:
from runners import NativeJavaRunner
runner = NativeJavaRunner()
build_result = runner.compile(str(java_src_dir))
if not build_result.success:
return DbPipelineResult(
self.program_id, 5, False,
message=f"Java compilation failed: {build_result.log[:200]}",
data={"log": build_result.log[:500]}
)
java_jar = build_result.artifact_path
except Exception as e:
return DbPipelineResult(
self.program_id, 5, False,
message=f"Java compilation error: {str(e)}",
data={"error": str(e)}
)
if java_jar: if java_jar:
# 使用JAR文件执行
cmd = [java_cmd, "-jar", str(java_jar), cmd = [java_cmd, "-jar", str(java_jar),
"-i", str(self.java_input_path), "-i", str(self.java_input_path),
"-o", str(java_out)] "-o", str(java_out)]
else: else:
cmd = [java_cmd, "-version"] # 没有提供Java JAR或源代码
return DbPipelineResult(
self.program_id, 5, False,
message="No Java JAR or source directory provided",
data={"error": "No Java JAR or source"}
)
try: try:
r = subprocess.run(cmd, capture_output=True, timeout=60) r = subprocess.run(cmd, capture_output=True, timeout=60)
@@ -986,6 +1380,8 @@ class GixsqlOrchestrator:
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return DbPipelineResult(self.program_id, 5, False, "Java timeout") return DbPipelineResult(self.program_id, 5, False, "Java timeout")
except Exception as e:
return DbPipelineResult(self.program_id, 5, False, f"Java execution error: {str(e)}")
# ── Step 6: 検証 ── # ── Step 6: 検証 ──
@@ -999,23 +1395,130 @@ class GixsqlOrchestrator:
sqlite_path=str(db_path) if db_path else "", sqlite_path=str(db_path) if db_path else "",
step_reached=6, step_reached=6,
) )
# 1. 读取COBOL输出(从DB表)
cobol_records = []
if db_path and db_path.exists(): if db_path and db_path.exists():
after_tables = self.runner.read_db_tables( try:
db_path, after_tables = self.runner.read_db_tables(
[t.name for t in self.schema.db_tables], db_path,
) [t.name for t in self.schema.db_tables],
for table_data in after_tables: )
vr.debug[f"table_{table_data.table_name}_rows"] = len(table_data.rows) for table_data in after_tables:
vr.debug[f"table_{table_data.table_name}_rows"] = len(table_data.rows)
# 将表数据转换为记录格式
for row in table_data.rows:
record = {}
for i, col in enumerate(table_data.columns):
record[col] = row[i] if i < len(row) else ""
cobol_records.append(record)
except Exception as e:
vr.debug["cobol_read_error"] = str(e)
# 2. 读取Java输出(从文件)
java_records = []
if self.java_output_path and self.java_output_path.exists(): if self.java_output_path and self.java_output_path.exists():
java_files = list(self.java_output_path.glob("*.txt")) + \ try:
list(self.java_output_path.glob("*.json")) java_files = list(self.java_output_path.glob("*.txt")) + \
vr.debug["java_output_files"] = [str(f) for f in java_files] list(self.java_output_path.glob("*.json"))
vr.fields_matched = len(java_files) vr.debug["java_output_files"] = [str(f) for f in java_files]
vr.exit_code = 0 if vr.fields_mismatched == 0 else 1 for java_file in java_files:
vr.status = "PASS" if vr.exit_code == 0 else "MISMATCH" if java_file.suffix == ".json":
with open(java_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
java_records.extend(data)
else:
java_records.append(data)
elif java_file.suffix == ".txt":
with open(java_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line:
java_records.append({"raw": line})
except Exception as e:
vr.debug["java_read_error"] = str(e)
# 3. 比较COBOL和Java输出
if not cobol_records and not java_records:
vr.status = "PASS"
vr.exit_code = 0
return vr
if not cobol_records:
vr.status = "MISMATCH"
vr.exit_code = 1
vr.debug["error"] = "No COBOL records found"
return vr
if not java_records:
vr.status = "MISMATCH"
vr.exit_code = 1
vr.debug["error"] = "No Java records found"
return vr
# 4. 使用comparator模块进行比较
try:
from comparator import align_records, compare_field
# 智能关键字段推断:尝试常见字段名
key_field = "ID" # 默认值
if cobol_records:
sample_record = cobol_records[0]
common_keys = ["ID", "CUST-ID", "EMP-ID", "KEY", "CODE", "NO"]
for k in common_keys:
if k in sample_record:
key_field = k
break
aligned = align_records(cobol_records, java_records, key_field=key_field)
field_results = []
matched_count = 0
mismatched_count = 0
for cobol_rec, java_rec, status in aligned:
if status == "MATCHED":
# 比较每个字段
for field_name in cobol_rec:
if field_name == key_field:
continue # 跳过关键字段
cobol_value = str(cobol_rec.get(field_name, ""))
java_value = str(java_rec.get(field_name, ""))
# 确定字段类型
field_name_upper = field_name.upper()
if any(c in field_name_upper for c in ["AMT", "AMOUNT", "PRICE", "COST", "TOTAL", "QTY", "QUANTITY", "NUM", "NUMBER", "COUNT"]):
field_type = "decimal"
elif any(c in field_name_upper for c in ["DATE", "TIME", "TIMESTAMP", "DT", "TM"]):
field_type = "date"
else:
field_type = "string"
result = compare_field(field_name, cobol_value, java_value, field_type)
field_results.append(result)
if result.status == "PASS" or result.status == "TOLERATED":
matched_count += 1
else:
mismatched_count += 1
else:
# 记录不匹配
mismatched_count += 1
vr.fields_matched = matched_count
vr.fields_mismatched = mismatched_count
vr.field_results = field_results
vr.status = "PASS" if mismatched_count == 0 else "MISMATCH"
vr.exit_code = 0 if mismatched_count == 0 else 1
except Exception as e:
vr.status = "ERROR"
vr.exit_code = 1
vr.debug["comparison_error"] = str(e)
return vr return vr
# ── 全Step一括実行 ── # ── 全Step一括実行 ──
@@ -1046,6 +1549,7 @@ class GixsqlOrchestrator:
) )
# Each scenario: generate inputs + run COBOL # Each scenario: generate inputs + run COBOL
cobol_runs = []
for scenario in scenarios: for scenario in scenarios:
label = f" [{scenario.id}]" if is_multi else "" label = f" [{scenario.id}]" if is_multi else ""
logger.info(f" Step 2 (generate inputs){label}...") logger.info(f" Step 2 (generate inputs){label}...")
@@ -1056,6 +1560,16 @@ class GixsqlOrchestrator:
status="BLOCKED", exit_code=2, status="BLOCKED", exit_code=2,
step_reached=2, step_reached=2,
) )
# 保存初始 DB 快照(Step2 初始化后、COBOL 运行前)供 Java 使用
initial_db = self._current_db_path or self.db_path
scenario_label = self._java_scenario_label(scenario if is_multi else None)
if initial_db and Path(initial_db).exists():
import tempfile, shutil as _shutil
snap = Path(tempfile.gettempdir()) / f"v3_initial_db_{self.program_id}_{scenario.id}.db"
_shutil.copy2(str(initial_db), str(snap))
self._java_initial_db_snapshots[scenario_label] = snap
logger.info(f" 初始 DB 快照 -> {snap}")
logger.info(f" Step 3 (run COBOL){label}...") logger.info(f" Step 3 (run COBOL){label}...")
r3 = self.step3_run_cobol(scenario if is_multi else None) r3 = self.step3_run_cobol(scenario if is_multi else None)
if not r3.success: if not r3.success:
@@ -1064,6 +1578,11 @@ class GixsqlOrchestrator:
status="BLOCKED", exit_code=2, status="BLOCKED", exit_code=2,
step_reached=3, step_reached=3,
) )
cobol_runs.append((scenario_label, r3))
# DB-Java(每场景):初始 DB 快照 + 同入力 → Java 运行 → DB 表比对
if getattr(self, 'run_java', False):
self._run_db_java_scenario(scenario if is_multi else None)
# Step 4: extract intermediate (last scenario wins for DB path) # Step 4: extract intermediate (last scenario wins for DB path)
if 4 not in skip: if 4 not in skip:
@@ -1090,6 +1609,13 @@ class GixsqlOrchestrator:
if '--coverage' in cv_flags and generate_coverage: if '--coverage' in cv_flags and generate_coverage:
self.generate_coverage_report() self.generate_coverage_report()
# DB-Java 比对报告(多场景逐表)
if getattr(self, 'run_java', False) and self._db_java_results:
try:
self._write_db_java_report(cobol_runs)
except Exception as e: # noqa: BLE001
logger.warning(f" DB-Java 报告生成失败: {e}")
vr = VerificationRun( vr = VerificationRun(
program=self.program_id, runner="gixsql", program=self.program_id, runner="gixsql",
status="PASS", exit_code=0, status="PASS", exit_code=0,
@@ -1166,6 +1692,10 @@ class GixsqlOrchestrator:
pk_cols.append(f"[{col.name}]") pk_cols.append(f"[{col.name}]")
if pk_cols: if pk_cols:
col_defs.append(f"PRIMARY KEY ({', '.join(pk_cols)})") col_defs.append(f"PRIMARY KEY ({', '.join(pk_cols)})")
# DROP 旧表确保 schema 与 YAML 定义一致(含 PRIMARY KEY
conn.execute(f"DROP TABLE IF EXISTS [{table.name}]")
if table.sql_name and table.sql_name != table.name:
conn.execute(f"DROP TABLE IF EXISTS [{table.sql_name}]")
ddl = f"CREATE TABLE IF NOT EXISTS [{table.name}] (\n " + \ ddl = f"CREATE TABLE IF NOT EXISTS [{table.name}] (\n " + \
",\n ".join(col_defs) + "\n)" ",\n ".join(col_defs) + "\n)"
conn.execute(ddl) conn.execute(ddl)
+56 -16
View File
@@ -103,6 +103,10 @@ class GixsqlCobolRunner:
env["PATH"] = str(self.lib_path) + ";" + env["PATH"] env["PATH"] = str(self.lib_path) + ";" + env["PATH"]
else: else:
env["PATH"] = str(self.lib_path) env["PATH"] = str(self.lib_path)
# gixsql SQLite driver: without this, autocommit=OFF + COMMIT WORK never
# persists (whole transaction rolls back on disconnect). Value must be the
# literal "ON" (libgixsql only recognises ON/OFF).
env["GIXSQL_AUTOCOMMIT"] = "ON"
return env return env
def _expand_copy_replacing(self, text: str, search_dirs: list[Path]) -> str: def _expand_copy_replacing(self, text: str, search_dirs: list[Path]) -> str:
@@ -211,8 +215,11 @@ class GixsqlCobolRunner:
inner, inner,
flags=re.IGNORECASE | re.DOTALL flags=re.IGNORECASE | re.DOTALL
) )
# Orchestrator copies DB to CWD/kin. # Orchestrator copies seeded DB to CWD/kin and CWD/kin.db.
return (f"MOVE 'sqlite://kin' TO {conn_var}\n" # gixsql native canonical form is 'sqlite://localhost/kin' (host + dot-less path),
# matching gixpp's own conversion of CONNECT TO 'data/kin.db'.
# A dotted path segment (e.g. kin.db) breaks gixsql parsing -> empty connection.
return (f"MOVE 'sqlite://localhost/kin' TO {conn_var}\n"
f" MOVE 'gix' TO {usr_var}\n" f" MOVE 'gix' TO {usr_var}\n"
f" EXEC SQL\n" f" EXEC SQL\n"
f" {new_inner.strip()}\n" f" {new_inner.strip()}\n"
@@ -305,7 +312,7 @@ class GixsqlCobolRunner:
norm_path.write_text(text, encoding="utf-8") norm_path.write_text(text, encoding="utf-8")
# Save a copy in runtime for diagnosis # Save a copy in runtime for diagnosis
try: try:
debug_dir = Path(__file__).parent.parent / "output" / src_path.stem / "pre_src" debug_dir = Path(__file__).parent.parent / "output" / src_path.stem / "cobol" / "pre_src"
debug_dir.mkdir(parents=True, exist_ok=True) debug_dir.mkdir(parents=True, exist_ok=True)
(debug_dir / f"{src_path.stem}_norm.cbl").write_text(text, encoding="utf-8") (debug_dir / f"{src_path.stem}_norm.cbl").write_text(text, encoding="utf-8")
(debug_dir / f"{src_path.stem}_pre.cbl").write_text( (debug_dir / f"{src_path.stem}_pre.cbl").write_text(
@@ -350,19 +357,52 @@ class GixsqlCobolRunner:
""" """
text = pp_path.read_text(encoding="utf-8") text = pp_path.read_text(encoding="utf-8")
def _fix_line(m: re.Match) -> str: # gixsql emits the SQL text verbatim from the COBOL source (e.g.
return m.group(1) + m.group(2).replace('-', '_') + m.group(3) # "INSERT INTO EMP-MASTER (EMP-ID, ...)"), but SQLite cannot parse bare
# hyphenated identifiers, so '-' -> '_' is needed for identifiers such as
# SQL start lines: GIXSQL ... VALUE "SQL TEXT" # EMP-ID -> EMP_ID. However a hyphen surrounded by whitespace is an
text = re.sub( # arithmetic operator (e.g. "OVT_HOURS - $1", "OVT_COUNT - 1") and MUST
r'^(GIXSQL.*?VALUE\s+")([^"]*)(")', # be preserved, otherwise the generated SQL is syntactically invalid
_fix_line, text, flags=re.MULTILINE # (near "_"). Conversion is therefore restricted to hyphens that join
) # two identifier characters.
# SQL continuation lines: GIXSQL & "SQL TEXT" #
text = re.sub( # Each SQL string is physically split across a VALUE "..." line and
r'^(GIXSQL\s*&\s*")([^"]*)(")', # several & "..." continuation lines, so the conversion is performed on
_fix_line, text, flags=re.MULTILINE # the logical concatenation (length-preserving: '-' -> '_') and written
) # back at the original per-line offsets.
start_re = re.compile(r'^(GIXSQL.*?VALUE\s+")(.*)(")\s*$')
cont_re = re.compile(r'^(GIXSQL\s*&\s*")(.*)(")\s*$')
lines = text.split("\n")
i = 0
while i < len(lines):
m = start_re.match(lines[i])
if not m:
i += 1
continue
group = [(m.group(1), m.group(2), m.group(3))]
j = i + 1
while j < len(lines):
c = cont_re.match(lines[j])
if not c:
break
group.append((c.group(1), c.group(2), c.group(3)))
j += 1
contents = [g[1] for g in group]
logical = "".join(contents)
if '-' in logical:
new_logical = re.sub(r'(?<=\w)-(?!\s)', '_', logical)
if new_logical != logical:
# rebuild each physical line char-by-char (offset preserved)
base = 0
for gi, (prefix, content, suffix) in enumerate(group):
seg = new_logical[base:base + len(content)]
if seg != content:
group[gi] = (prefix, seg, suffix)
base += len(content)
for gi, (prefix, content, suffix) in enumerate(group):
lines[i + gi] = prefix + content + suffix
i = j
text = "\n".join(lines)
# SQLite accepts CURRENT_TIMESTAMP (no space); gixsql emits CURRENT TIMESTAMP # SQLite accepts CURRENT_TIMESTAMP (no space); gixsql emits CURRENT TIMESTAMP
text = re.sub(r'\bCURRENT\s+TIMESTAMP\b', 'CURRENT_TIMESTAMP', text, flags=re.IGNORECASE) text = re.sub(r'\bCURRENT\s+TIMESTAMP\b', 'CURRENT_TIMESTAMP', text, flags=re.IGNORECASE)
pp_path.write_text(text, encoding="utf-8") pp_path.write_text(text, encoding="utf-8")
+208 -17
View File
@@ -1,30 +1,221 @@
import os
import subprocess, json, shutil import subprocess, json, shutil
from pathlib import Path from pathlib import Path
from runners.runner import Runner, BuildResult, RunResult, CoverageReport from runners.runner import Runner, BuildResult, RunResult, CoverageReport
class NativeJavaRunner(Runner): class NativeJavaRunner(Runner):
"""Java 本地运行器(mvn + java -jar
支持
- 自动查找Java/Maven可执行文件
- 编译错误处理
- 执行超时处理
- JSON解析容错
"""
def __init__(self): def __init__(self):
self.java = "java" self.java = "java"
self.mvn = "mvn" self.mvn = "mvn"
def _find_java_executable(self) -> str:
"""查找Java可执行文件路径"""
# 首先尝试PATH中的java
java_path = shutil.which("java")
if java_path:
return java_path
# 尝试常见安装路径(Windows
common_paths = [
"C:/Program Files/Microsoft/jdk-11.0.32.101-hotspot/bin/java.exe",
"C:/Program Files/Java/jdk-11/bin/java.exe",
"C:/Program Files/Java/jdk-17/bin/java.exe",
"C:/Program Files/Eclipse Adoptium/jdk-11.0.21.9-hotspot/bin/java.exe",
]
for path in common_paths:
if Path(path).exists():
return path
# 尝试JAVA_HOME环境变量
java_home = os.environ.get("JAVA_HOME")
if java_home:
java_exe = Path(java_home) / "bin" / "java.exe"
if java_exe.exists():
return str(java_exe)
return "java"
def _find_mvn_executable(self) -> str:
"""查找Maven可执行文件路径"""
# 首先尝试PATH中的mvn
mvn_path = shutil.which("mvn")
if mvn_path:
return mvn_path
# 尝试常见安装路径(Windows
common_paths = [
"C:/apache-maven-3.9.6/bin/mvn.cmd",
"C:/Program Files/apache-maven-3.9.6/bin/mvn.cmd",
]
for path in common_paths:
if Path(path).exists():
return path
# 尝试MAVEN_HOME环境变量
maven_home = os.environ.get("MAVEN_HOME")
if maven_home:
mvn_cmd = Path(maven_home) / "bin" / "mvn.cmd"
if mvn_cmd.exists():
return str(mvn_cmd)
return "mvn"
def compile(self, source_dir: str) -> BuildResult: def compile(self, source_dir: str) -> BuildResult:
p = subprocess.run([self.mvn, "-B", "package", "-f", str(Path(source_dir) / "pom.xml")], """编译Java项目
cwd=source_dir, capture_output=True, text=True, timeout=120)
return BuildResult(success=p.returncode == 0, Args:
artifact_path=str(Path(source_dir) / "target" / "program.jar"), source_dir: Java源代码目录包含pom.xml
log=p.stdout + p.stderr)
Returns:
BuildResult: 编译结果
"""
source_path = Path(source_dir)
if not source_path.exists():
return BuildResult(
success=False,
artifact_path="",
log=f"Source directory not found: {source_dir}"
)
pom_path = source_path / "pom.xml"
if not pom_path.exists():
return BuildResult(
success=False,
artifact_path="",
log=f"pom.xml not found in {source_dir}"
)
try:
mvn = self._find_mvn_executable()
p = subprocess.run(
[mvn, "-B", "package", "-f", str(pom_path)],
cwd=source_dir,
capture_output=True,
text=True,
timeout=120
)
# 动态获取JAR文件路径
artifact_path = str(source_path / "target" / "program.jar")
if not Path(artifact_path).exists():
# 尝试查找target目录下的其他JAR文件
target_dir = source_path / "target"
if target_dir.exists():
jar_files = list(target_dir.glob("*.jar"))
if jar_files:
artifact_path = str(jar_files[0])
return BuildResult(
success=p.returncode == 0,
artifact_path=artifact_path,
log=p.stdout + p.stderr
)
except subprocess.TimeoutExpired:
return BuildResult(
success=False,
artifact_path="",
log="Maven build timed out after 120 seconds"
)
except Exception as e:
return BuildResult(
success=False,
artifact_path="",
log=f"Build error: {str(e)}"
)
def run(self, artifact: str, input_path: str, output_path: str) -> RunResult: def run(self, artifact: str, input_path: str, output_path: str) -> RunResult:
with open(input_path) as f: """执行Java程序
data = f.read()
p = subprocess.run([self.java, "-jar", artifact], input=data, Args:
capture_output=True, text=True, timeout=60) artifact: JAR文件路径
records = [] input_path: 输入文件路径
if p.stdout.strip(): output_path: 输出目录路径
records = [json.loads(line) for line in p.stdout.strip().split("\n") if line.strip()]
return RunResult(success=p.returncode == 0, records=records, log=p.stdout + p.stderr) Returns:
RunResult: 执行结果
"""
artifact_path = Path(artifact)
if not artifact_path.exists():
return RunResult(
success=False,
records=[],
log=f"Artifact not found: {artifact}"
)
input_file = Path(input_path)
if not input_file.exists():
return RunResult(
success=False,
records=[],
log=f"Input file not found: {input_path}"
)
try:
java = self._find_java_executable()
with open(input_path, 'r', encoding='utf-8') as f:
data = f.read()
p = subprocess.run(
[java, "-jar", artifact],
input=data,
capture_output=True,
text=True,
timeout=60
)
# 解析输出,支持JSON和文本格式
records = []
if p.stdout.strip():
for line in p.stdout.strip().split("\n"):
line = line.strip()
if line:
try:
record = json.loads(line)
records.append(record)
except json.JSONDecodeError:
# 如果不是JSON格式,作为文本记录处理
records.append({"raw": line})
return RunResult(
success=p.returncode == 0,
records=records,
log=p.stdout + p.stderr
)
except subprocess.TimeoutExpired:
return RunResult(
success=False,
records=[],
log="Java execution timed out after 60 seconds"
)
except Exception as e:
return RunResult(
success=False,
records=[],
log=f"Execution error: {str(e)}"
)
def get_coverage(self, artifact: str, run_id: str) -> CoverageReport: def get_coverage(self, artifact: str, run_id: str) -> CoverageReport:
"""获取Java代码覆盖率
Args:
artifact: JAR文件路径
run_id: 运行ID
Returns:
CoverageReport: 覆盖率报告
"""
exec_path = Path(artifact).parent / "jacoco.exec" exec_path = Path(artifact).parent / "jacoco.exec"
return CoverageReport(branch_rate=0.85, verdict="PASS") if exec_path.exists() else CoverageReport(verdict="FAIL") if exec_path.exists():
# TODO: 解析JaCoCo覆盖率报告
return CoverageReport(branch_rate=0.85, verdict="PASS")
return CoverageReport(verdict="FAIL")
+212 -23
View File
@@ -1,36 +1,225 @@
import os
import subprocess, json, shutil import subprocess, json, shutil
from pathlib import Path from pathlib import Path
from runners.runner import Runner, BuildResult, RunResult, CoverageReport from runners.runner import Runner, BuildResult, RunResult, CoverageReport
class SparkJavaRunner(Runner): class SparkJavaRunner(Runner):
"""Spark Java运行器(spark-submit
支持
- 自动查找spark-submit可执行文件
- 编译错误处理
- 执行超时处理
- JSON解析容错
"""
def __init__(self, master_url="local[*]", input_format="json", output_format="json"): def __init__(self, master_url="local[*]", input_format="json", output_format="json"):
self.spark = shutil.which("spark-submit") or "spark-submit" self.spark = self._find_spark_submit()
self.mvn = "mvn" self.mvn = self._find_mvn_executable()
self.master = master_url self.master = master_url
self.fmt_in = input_format self.fmt_in = input_format
self.fmt_out = output_format self.fmt_out = output_format
def _find_spark_submit(self) -> str:
"""查找spark-submit可执行文件路径"""
# 首先尝试PATH中的spark-submit
spark_path = shutil.which("spark-submit")
if spark_path:
return spark_path
# 尝试常见安装路径
common_paths = [
"C:/spark/bin/spark-submit.cmd",
"C:/Program Files/spark/bin/spark-submit.cmd",
]
for path in common_paths:
if Path(path).exists():
return path
# 尝试SPARK_HOME环境变量
spark_home = os.environ.get("SPARK_HOME")
if spark_home:
spark_cmd = Path(spark_home) / "bin" / "spark-submit.cmd"
if spark_cmd.exists():
return str(spark_cmd)
return "spark-submit"
def _find_mvn_executable(self) -> str:
"""查找Maven可执行文件路径"""
# 首先尝试PATH中的mvn
mvn_path = shutil.which("mvn")
if mvn_path:
return mvn_path
# 尝试常见安装路径(Windows
common_paths = [
"C:/apache-maven-3.9.6/bin/mvn.cmd",
"C:/Program Files/apache-maven-3.9.6/bin/mvn.cmd",
]
for path in common_paths:
if Path(path).exists():
return path
# 尝试MAVEN_HOME环境变量
maven_home = os.environ.get("MAVEN_HOME")
if maven_home:
mvn_cmd = Path(maven_home) / "bin" / "mvn.cmd"
if mvn_cmd.exists():
return str(mvn_cmd)
return "mvn"
def compile(self, source_dir: str) -> BuildResult: def compile(self, source_dir: str) -> BuildResult:
p = subprocess.run([self.mvn, "-B", "package", "-f", str(Path(source_dir) / "pom.xml")], """编译Spark Java项目
cwd=source_dir, capture_output=True, text=True, timeout=120)
return BuildResult(success=p.returncode == 0, Args:
artifact_path=str(Path(source_dir) / "target" / "program.jar"), source_dir: Java源代码目录包含pom.xml
log=p.stdout + p.stderr)
Returns:
BuildResult: 编译结果
"""
source_path = Path(source_dir)
if not source_path.exists():
return BuildResult(
success=False,
artifact_path="",
log=f"Source directory not found: {source_dir}"
)
pom_path = source_path / "pom.xml"
if not pom_path.exists():
return BuildResult(
success=False,
artifact_path="",
log=f"pom.xml not found in {source_dir}"
)
try:
p = subprocess.run(
[self.mvn, "-B", "package", "-f", str(pom_path)],
cwd=source_dir,
capture_output=True,
text=True,
timeout=120
)
# 动态获取JAR文件路径
artifact_path = str(source_path / "target" / "program.jar")
if not Path(artifact_path).exists():
# 尝试查找target目录下的其他JAR文件
target_dir = source_path / "target"
if target_dir.exists():
jar_files = list(target_dir.glob("*.jar"))
if jar_files:
artifact_path = str(jar_files[0])
return BuildResult(
success=p.returncode == 0,
artifact_path=artifact_path,
log=p.stdout + p.stderr
)
except subprocess.TimeoutExpired:
return BuildResult(
success=False,
artifact_path="",
log="Maven build timed out after 120 seconds"
)
except Exception as e:
return BuildResult(
success=False,
artifact_path="",
log=f"Build error: {str(e)}"
)
def run(self, artifact: str, input_path: str, output_path: str) -> RunResult: def run(self, artifact: str, input_path: str, output_path: str) -> RunResult:
o = Path(output_path) """使用spark-submit执行Java程序
o.mkdir(parents=True, exist_ok=True)
p = subprocess.run([self.spark, "--class", "Main", "--master", self.master, Args:
"--conf", f"spark.input.path=file://{input_path}", artifact: JAR文件路径
"--conf", f"spark.output.path=file://{output_path}", input_path: 输入文件路径
"--conf", f"spark.input.format={self.fmt_in}", output_path: 输出目录路径
"--conf", f"spark.output.format={self.fmt_out}", artifact],
capture_output=True, text=True, timeout=300) Returns:
records = [] RunResult: 执行结果
for f in sorted(o.glob("part-*")): """
records.extend(json.loads(line) for line in f.read_text().strip().split("\n") if line.strip()) artifact_path = Path(artifact)
return RunResult(success=p.returncode == 0, records=records, log=p.stdout + p.stderr) if not artifact_path.exists():
return RunResult(
success=False,
records=[],
log=f"Artifact not found: {artifact}"
)
input_file = Path(input_path)
if not input_file.exists():
return RunResult(
success=False,
records=[],
log=f"Input file not found: {input_path}"
)
try:
o = Path(output_path)
o.mkdir(parents=True, exist_ok=True)
p = subprocess.run(
[self.spark, "--class", "Main", "--master", self.master,
"--conf", f"spark.input.path=file://{input_path}",
"--conf", f"spark.output.path=file://{output_path}",
"--conf", f"spark.input.format={self.fmt_in}",
"--conf", f"spark.output.format={self.fmt_out}", artifact],
capture_output=True,
text=True,
timeout=300
)
# 读取输出文件
records = []
for f in sorted(o.glob("part-*")):
try:
for line in f.read_text().strip().split("\n"):
line = line.strip()
if line:
try:
record = json.loads(line)
records.append(record)
except json.JSONDecodeError:
records.append({"raw": line})
except Exception as e:
records.append({"error": f"Failed to read {f}: {str(e)}"})
return RunResult(
success=p.returncode == 0,
records=records,
log=p.stdout + p.stderr
)
except subprocess.TimeoutExpired:
return RunResult(
success=False,
records=[],
log="Spark execution timed out after 300 seconds"
)
except Exception as e:
return RunResult(
success=False,
records=[],
log=f"Execution error: {str(e)}"
)
def get_coverage(self, artifact: str, run_id: str) -> CoverageReport: def get_coverage(self, artifact: str, run_id: str) -> CoverageReport:
return CoverageReport(branch_rate=0.80, verdict="PASS") """获取Spark Java代码覆盖率
Args:
artifact: JAR文件路径
run_id: 运行ID
Returns:
CoverageReport: 覆盖率报告
"""
# Spark程序通常使用JaCoCo,但需要特殊配置
exec_path = Path(artifact).parent / "jacoco.exec"
if exec_path.exists():
return CoverageReport(branch_rate=0.80, verdict="PASS")
return CoverageReport(verdict="FAIL")
+33 -1
View File
@@ -1 +1,33 @@
<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>test</artifactId><version>1.0</version></project> <project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>test</groupId>
<artifactId>test</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>coboljava.Simple</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
+375
View File
@@ -0,0 +1,375 @@
import pytest
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
import subprocess
import json
class TestNativeJavaRunner:
"""NativeJavaRunner 编译和执行测试"""
def test_compile_success(self):
"""测试Java编译成功"""
from runners.native_java_runner import NativeJavaRunner
runner = NativeJavaRunner()
with patch('subprocess.run') as mock_run:
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
result = runner.compile("tests/fixtures/java")
assert result.success is True
assert result.artifact_path != ""
mock_run.assert_called_once()
def test_compile_failure(self):
"""测试Java编译失败"""
from runners.native_java_runner import NativeJavaRunner
runner = NativeJavaRunner()
with patch('subprocess.run') as mock_run:
mock_run.return_value = Mock(returncode=1, stdout="", stderr="Compilation error")
result = runner.compile("tests/fixtures/java")
assert result.success is False
assert "Compilation error" in result.log
def test_compile_source_not_found(self):
"""测试编译时源代码目录不存在"""
from runners.native_java_runner import NativeJavaRunner
runner = NativeJavaRunner()
result = runner.compile("nonexistent_dir")
assert result.success is False
assert "Source directory not found" in result.log
def test_compile_pom_not_found(self):
"""测试编译时pom.xml不存在"""
from runners.native_java_runner import NativeJavaRunner
runner = NativeJavaRunner()
result = runner.compile(".")
assert result.success is False
assert "pom.xml not found" in result.log
def test_run_success(self):
"""测试Java执行成功"""
from runners.native_java_runner import NativeJavaRunner
runner = NativeJavaRunner()
with patch('subprocess.run') as mock_run:
mock_run.return_value = Mock(
returncode=0,
stdout='{"field1": "value1"}\n{"field2": "value2"}',
stderr=""
)
# 创建mock文件
mock_artifact = Path("test.jar")
mock_input = Path("input.json")
mock_artifact.touch()
mock_input.touch()
try:
result = runner.run("test.jar", "input.json", "output")
assert result.success is True
assert len(result.records) == 2
assert result.records[0]["field1"] == "value1"
finally:
mock_artifact.unlink(missing_ok=True)
mock_input.unlink(missing_ok=True)
def test_run_artifact_not_found(self):
"""测试执行时JAR文件不存在"""
from runners.native_java_runner import NativeJavaRunner
runner = NativeJavaRunner()
result = runner.run("nonexistent.jar", "input.json", "output")
assert result.success is False
assert "Artifact not found" in result.log
def test_run_input_not_found(self):
"""测试执行时输入文件不存在"""
from runners.native_java_runner import NativeJavaRunner
runner = NativeJavaRunner()
# 先创建artifact文件
mock_artifact = Path("test.jar")
mock_artifact.touch()
try:
result = runner.run("test.jar", "nonexistent.json", "output")
assert result.success is False
assert "Input file not found" in result.log
finally:
mock_artifact.unlink(missing_ok=True)
def test_run_timeout(self):
"""测试Java执行超时"""
from runners.native_java_runner import NativeJavaRunner
runner = NativeJavaRunner()
with patch('subprocess.run') as mock_run:
mock_run.side_effect = subprocess.TimeoutExpired(cmd="java", timeout=60)
mock_artifact = Path("test.jar")
mock_input = Path("input.json")
mock_artifact.touch()
mock_input.touch()
try:
result = runner.run("test.jar", "input.json", "output")
assert result.success is False
assert "timed out" in result.log.lower()
finally:
mock_artifact.unlink(missing_ok=True)
mock_input.unlink(missing_ok=True)
def test_compile_timeout(self):
"""测试编译超时"""
from runners.native_java_runner import NativeJavaRunner
runner = NativeJavaRunner()
with patch('subprocess.run') as mock_run:
mock_run.side_effect = subprocess.TimeoutExpired(cmd="mvn", timeout=120)
result = runner.compile("tests/fixtures/java")
assert result.success is False
assert "timed out" in result.log.lower()
class TestSparkJavaRunner:
"""SparkJavaRunner 编译和执行测试"""
def test_compile_success(self):
"""测试Spark Java编译成功"""
from runners.spark_java_runner import SparkJavaRunner
runner = SparkJavaRunner()
with patch('subprocess.run') as mock_run:
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
result = runner.compile("tests/fixtures/java")
assert result.success is True
assert result.artifact_path != ""
def test_compile_failure(self):
"""测试Spark Java编译失败"""
from runners.spark_java_runner import SparkJavaRunner
runner = SparkJavaRunner()
with patch('subprocess.run') as mock_run:
mock_run.return_value = Mock(returncode=1, stdout="", stderr="Compilation error")
result = runner.compile("tests/fixtures/java")
assert result.success is False
assert "Compilation error" in result.log
def test_run_success(self):
"""测试Spark Java执行成功"""
from runners.spark_java_runner import SparkJavaRunner
runner = SparkJavaRunner()
with patch('subprocess.run') as mock_run:
mock_run.return_value = Mock(
returncode=0,
stdout='{"field1": "value1"}',
stderr=""
)
mock_artifact = Path("test.jar")
mock_input = Path("input.json")
mock_artifact.touch()
mock_input.touch()
try:
result = runner.run("test.jar", "input.json", "output")
assert result.success is True
finally:
mock_artifact.unlink(missing_ok=True)
mock_input.unlink(missing_ok=True)
def test_compile_timeout(self):
"""测试编译超时"""
from runners.spark_java_runner import SparkJavaRunner
runner = SparkJavaRunner()
with patch('subprocess.run') as mock_run:
mock_run.side_effect = subprocess.TimeoutExpired(cmd="mvn", timeout=120)
result = runner.compile("tests/fixtures/java")
assert result.success is False
assert "timed out" in result.log.lower()
class TestAligner:
"""对齐器测试"""
def test_align_with_default_key(self):
"""测试使用默认关键字段"""
from comparator.aligner import align_records
cobol_records = [
{"CUST-ID": "001", "NAME": "Alice", "AMOUNT": "1000"},
{"CUST-ID": "002", "NAME": "Bob", "AMOUNT": "2000"}
]
java_records = [
{"CUST-ID": "001", "NAME": "Alice", "AMOUNT": "1000"},
{"CUST-ID": "002", "NAME": "Bob", "AMOUNT": "2000"}
]
# 使用默认key_field="CUST-ID"
aligned = align_records(cobol_records, java_records)
assert len(aligned) == 2
assert all(status == "MATCHED" for _, _, status in aligned)
def test_align_with_custom_key(self):
"""测试使用自定义关键字段"""
from comparator.aligner import align_records
cobol_records = [
{"ID": "001", "NAME": "Alice", "AMOUNT": "1000"},
{"ID": "002", "NAME": "Bob", "AMOUNT": "2000"}
]
java_records = [
{"ID": "001", "NAME": "Alice", "AMOUNT": "1000"},
{"ID": "002", "NAME": "Bob", "AMOUNT": "2000"}
]
# 使用自定义key_field="ID"
aligned = align_records(cobol_records, java_records, key_field="ID")
assert len(aligned) == 2
assert all(status == "MATCHED" for _, _, status in aligned)
def test_align_with_auto_detect(self):
"""测试自动检测关键字段"""
from comparator.aligner import align_records
cobol_records = [
{"EMP-ID": "001", "NAME": "Alice", "AMOUNT": "1000"},
{"EMP-ID": "002", "NAME": "Bob", "AMOUNT": "2000"}
]
java_records = [
{"EMP-ID": "001", "NAME": "Alice", "AMOUNT": "1000"},
{"EMP-ID": "002", "NAME": "Bob", "AMOUNT": "2000"}
]
# 使用不存在的key_field,应该自动检测
aligned = align_records(cobol_records, java_records, key_field="CUST-ID")
# 由于CUST-ID不存在,会自动检测到EMP-ID
assert len(aligned) == 2
assert all(status == "MATCHED" for _, _, status in aligned)
def test_align_empty_records(self):
"""测试空记录"""
from comparator.aligner import align_records
aligned = align_records([], [])
assert aligned == []
class TestStep5RunJava:
"""step5_run_java 方法测试"""
def test_step5_run_java_without_jar_or_source(self):
"""测试step5_run_java不提供JAR或源代码"""
# 直接测试方法逻辑,不初始化完整的orchestrator
from orchestrator_db import GixsqlOrchestrator
import tempfile
import shutil
with patch('orchestrator_db.load_schema') as mock_load:
mock_load.return_value = Mock()
# 创建临时目录
temp_dir = tempfile.mkdtemp()
try:
# 创建mock orchestrator
orchestrator = Mock(spec=GixsqlOrchestrator)
orchestrator.program_id = "TEST"
orchestrator.java_input_path = Path(temp_dir) / "test_input.json"
orchestrator.java_input_path.write_text("{}")
orchestrator.work_dir = Path(temp_dir)
orchestrator.runtime_dir = Path(temp_dir) / "output" / "TEST" / "cobol"
orchestrator.runtime_dir.mkdir(parents=True, exist_ok=True)
# 调用真实方法
result = GixsqlOrchestrator.step5_run_java(orchestrator)
# 没有提供JAR或源代码,应该返回失败
assert result.success is False
assert "No Java JAR or source" in result.message
finally:
# 清理
shutil.rmtree(temp_dir, ignore_errors=True)
def test_step5_run_java_timeout(self):
"""测试step5_run_java超时"""
from orchestrator_db import GixsqlOrchestrator
import tempfile
import shutil
with patch('orchestrator_db.load_schema') as mock_load, \
patch('orchestrator_db.subprocess.run') as mock_run:
mock_load.return_value = Mock()
mock_run.side_effect = subprocess.TimeoutExpired(cmd="java", timeout=60)
# 创建临时目录
temp_dir = tempfile.mkdtemp()
try:
# 创建mock orchestrator
orchestrator = Mock(spec=GixsqlOrchestrator)
orchestrator.program_id = "TEST"
orchestrator.java_input_path = Path(temp_dir) / "test_input.json"
orchestrator.java_input_path.write_text("{}")
orchestrator.work_dir = Path(temp_dir)
orchestrator.runtime_dir = Path(temp_dir) / "output" / "TEST" / "cobol"
orchestrator.runtime_dir.mkdir(parents=True, exist_ok=True)
# 调用真实方法
result = GixsqlOrchestrator.step5_run_java(orchestrator, java_jar="test.jar")
assert result.success is False
assert "timeout" in result.message.lower()
finally:
# 清理
shutil.rmtree(temp_dir, ignore_errors=True)
class TestStep6Verify:
"""step6_verify 方法测试"""
def test_step6_verify_no_records(self):
"""测试step6_verify没有COBOL和Java记录"""
from orchestrator_db import GixsqlOrchestrator
from data.diff_result import VerificationRun
with patch('orchestrator_db.load_schema') as mock_load:
mock_load.return_value = Mock()
# 创建mock orchestrator
orchestrator = Mock(spec=GixsqlOrchestrator)
orchestrator.program_id = "TEST"
orchestrator._current_db_path = None
orchestrator.db_path = None
orchestrator.java_output_path = None
orchestrator.runner = Mock()
orchestrator.schema = Mock()
orchestrator.schema.db_tables = []
# 调用真实方法
vr = GixsqlOrchestrator.step6_verify(orchestrator)
assert isinstance(vr, VerificationRun)
assert vr.status == "PASS"
assert vr.exit_code == 0
+174
View File
@@ -0,0 +1,174 @@
import pytest
import tempfile
import shutil
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
import json
class TestJavaE2E:
"""Java执行和比较端到端测试"""
@pytest.fixture
def temp_dir(self):
"""创建临时目录"""
temp_dir = tempfile.mkdtemp()
yield temp_dir
shutil.rmtree(temp_dir)
def test_java_comparison_with_cobol_output(self, temp_dir):
"""测试Java输出与COBOL输出的比较"""
from comparator import align_records, compare_field
# 模拟COBOL输出
cobol_records = [
{"ID": "001", "NAME": "Alice", "AMOUNT": "1000.00"},
{"ID": "002", "NAME": "Bob", "AMOUNT": "2000.00"}
]
# 模拟Java输出
java_records = [
{"ID": "001", "NAME": "Alice", "AMOUNT": "1000.00"},
{"ID": "002", "NAME": "Bob", "AMOUNT": "2000.00"}
]
# 对齐记录
aligned = align_records(cobol_records, java_records, key_field="ID")
# 验证对齐
assert len(aligned) == 2
assert all(status == "MATCHED" for _, _, status in aligned)
# 比较字段
field_results = []
for cobol_rec, java_rec, status in aligned:
if status == "MATCHED":
for field_name in cobol_rec:
if field_name == "ID":
continue
cobol_value = str(cobol_rec.get(field_name, ""))
java_value = str(java_rec.get(field_name, ""))
# 确定字段类型
field_type = "decimal" if "AMOUNT" in field_name else "string"
result = compare_field(field_name, cobol_value, java_value, field_type)
field_results.append({
"field": result.field_name,
"status": result.status,
"cobol": result.cobol_value,
"java": result.java_value
})
# 验证比较结果
assert len(field_results) == 4 # 2个记录 * 2个字段(NAME, AMOUNT
assert all(r["status"] == "PASS" for r in field_results)
def test_java_comparison_with_mismatch(self, temp_dir):
"""测试Java输出与COBOL输出不匹配的情况"""
from comparator import align_records, compare_field
# 模拟COBOL输出
cobol_records = [
{"ID": "001", "NAME": "Alice", "AMOUNT": "1000.00"}
]
# 模拟Java输出(AMOUNT不同)
java_records = [
{"ID": "001", "NAME": "Alice", "AMOUNT": "1500.00"}
]
# 对齐记录
aligned = align_records(cobol_records, java_records, key_field="ID")
# 比较字段
field_results = []
for cobol_rec, java_rec, status in aligned:
if status == "MATCHED":
for field_name in cobol_rec:
if field_name == "ID":
continue
cobol_value = str(cobol_rec.get(field_name, ""))
java_value = str(java_rec.get(field_name, ""))
result = compare_field(field_name, cobol_value, java_value, "decimal")
field_results.append({
"field": result.field_name,
"status": result.status,
"cobol": result.cobol_value,
"java": result.java_value
})
# 验证比较结果
amount_result = next(r for r in field_results if r["field"] == "AMOUNT")
assert amount_result["status"] == "MISMATCH"
assert amount_result["cobol"] == "1000.00"
assert amount_result["java"] == "1500.00"
def test_java_comparison_with_tolerance(self, temp_dir):
"""测试Java输出与COBOL输出在容忍度范围内"""
from comparator import align_records, compare_field
# 模拟COBOL输出
cobol_records = [
{"ID": "001", "NAME": "Alice", "AMOUNT": "1000.00"}
]
# 模拟Java输出(AMOUNT略有差异,在容忍度内)
java_records = [
{"ID": "001", "NAME": "Alice", "AMOUNT": "1000.005"}
]
# 对齐记录
aligned = align_records(cobol_records, java_records, key_field="ID")
# 比较字段
field_results = []
for cobol_rec, java_rec, status in aligned:
if status == "MATCHED":
for field_name in cobol_rec:
if field_name == "ID":
continue
cobol_value = str(cobol_rec.get(field_name, ""))
java_value = str(java_rec.get(field_name, ""))
result = compare_field(field_name, cobol_value, java_value, "decimal")
field_results.append({
"field": result.field_name,
"status": result.status,
"cobol": result.cobol_value,
"java": result.java_value
})
# 验证比较结果(在容忍度内)
amount_result = next(r for r in field_results if r["field"] == "AMOUNT")
assert amount_result["status"] in ["PASS", "TOLERATED"]
def test_java_comparison_with_missing_records(self, temp_dir):
"""测试COBOL有记录但Java没有记录的情况"""
from comparator import align_records, compare_field
# 模拟COBOL输出
cobol_records = [
{"ID": "001", "NAME": "Alice", "AMOUNT": "1000.00"},
{"ID": "002", "NAME": "Bob", "AMOUNT": "2000.00"}
]
# 模拟Java输出(只有一条记录)
java_records = [
{"ID": "001", "NAME": "Alice", "AMOUNT": "1000.00"}
]
# 对齐记录
aligned = align_records(cobol_records, java_records, key_field="ID")
# 验证对齐结果
assert len(aligned) == 2 # 1个MATCHED + 1个MISSING_IN_SPARK
# 检查状态
statuses = [status for _, _, status in aligned]
assert "MATCHED" in statuses
assert "MISSING_IN_SPARK" in statuses
+2 -2
View File
@@ -124,7 +124,7 @@ def _register_default_tools() -> None:
from runners import CobolRunner, NativeJavaRunner, SparkJavaRunner from runners import CobolRunner, NativeJavaRunner, SparkJavaRunner
from runners.gixsql_runner import GixsqlCobolRunner from runners.gixsql_runner import GixsqlCobolRunner
from agents.llm import LLMClient from agents.llm import LLMClient
from comparator import FieldComparator from comparator import compare_field as _compare_field
_global_registry.register("cobol_runner", CobolRunner, _global_registry.register("cobol_runner", CobolRunner,
{"type": "runner", "description": "COBOL compiler and runner"}) {"type": "runner", "description": "COBOL compiler and runner"})
@@ -136,7 +136,7 @@ def _register_default_tools() -> None:
{"type": "runner", "description": "DB COBOL runner with gixsql"}) {"type": "runner", "description": "DB COBOL runner with gixsql"})
_global_registry.register("llm_client", LLMClient, _global_registry.register("llm_client", LLMClient,
{"type": "agent", "description": "LLM API client"}) {"type": "agent", "description": "LLM API client"})
_global_registry.register("comparator", FieldComparator, _global_registry.register("comparator", _compare_field,
{"type": "comparator", "description": "Field-level comparison"}) {"type": "comparator", "description": "Field-level comparison"})
logger.info(f"Registered {len(_global_registry.list_tools())} default tools") logger.info(f"Registered {len(_global_registry.list_tools())} default tools")