feat: SQL between/hostvar-key alignment, class-condition parsing, gcov merge across scenario runs

This commit is contained in:
hangshuo652
2026-08-09 17:43:00 +08:00
parent f331c8fa2a
commit 273a3f8211
31 changed files with 3789 additions and 272 deletions
+175 -36
View File
@@ -2,8 +2,9 @@
import re
import logging
import os
from .models import BrSeq, BrIf, BrEval, BrPerform, BrSearch, Assign, CallNode, CondNot, CondLeaf, ExitNode, GoTo
from .cond import parse_single_condition, parse_compound_condition, is_field, collect_leaves, mcdc_sets, satisfying_value
from .cond import parse_single_condition, parse_compound_condition, is_field, collect_leaves, mcdc_sets, satisfying_value, evaluate_class_value
from .core import trace_to_root, invert_through_chain, propagate_assignments, _basename
logger = logging.getLogger(__name__)
@@ -60,7 +61,7 @@ def _has_t_branch(cons):
for c in cons:
if len(c) >= 4 and c[0] == "__DP" and c[2] == "T":
return True
if c[3]:
if len(c) >= 4 and c[3]:
return True
return False
@@ -68,16 +69,16 @@ def _has_f_branch(cons):
for c in cons:
if len(c) >= 4 and c[0] == "__DP" and c[2] == "F":
return True
if not c[3]:
if len(c) >= 4 and not c[3]:
return True
return False
def _cap_paths(paths):
if len(paths) > _MAX_PATHS:
special = [(i, p) for i, p in enumerate(paths) if any(_is_sentinel(c) for c in p)]
std = [(i, p) for i, p in enumerate(paths) if not any(_is_sentinel(c) for c in p)]
t_paths = [(i, p) for i, p in std if _has_t_branch(p)]
f_paths = [(i, p) for i, p in std if _has_f_branch(p)]
special = [(i, p) for i, p in enumerate(paths) if any(_is_sentinel(c) for c in p[0])]
std = [(i, p) for i, p in enumerate(paths) if not any(_is_sentinel(c) for c in p[0])]
t_paths = [(i, p) for i, p in std if _has_t_branch(p[0])]
f_paths = [(i, p) for i, p in std if _has_f_branch(p[0])]
quota = _MAX_PATHS - len(special)
if quota <= 0:
return [p for _, p in special[:_MAX_PATHS]]
@@ -210,7 +211,11 @@ def enum_paths(node, fields):
continue
for cp_cons, cp_assign in child_paths:
merged_cons = p_cons + list(cp_cons)
sig = frozenset(_hashable_cons(merged_cons))
# 签名需保留约束的多重性:同一 (字段,运算符,值,期望) 在顺序
# IF 中重复出现(如连续 C01CHKRRC 校验)时,frozenset 会把
# 多重出现的约束折叠为单元素,导致"前面全部通过、最后一个校验
# 失败"等中间路径被去重丢失。改用按 repr 排序的元组签名。
sig = tuple(sorted(_hashable_cons(merged_cons), key=repr))
if sig not in covered_sigs:
covered_sigs.add(sig)
merged = {}
@@ -223,12 +228,24 @@ def enum_paths(node, fields):
if not any(_is_sentinel(c) for c in pc):
new_active.append((pc, dict(pa)))
break
paths = new_active
paths = _cap_paths(new_active)
return paths
elif isinstance(node, BrIf):
parsed = parse_single_condition(node.condition, fields)
if parsed and is_field(parsed[0], fields):
if len(parsed) == 4:
# class condition (field, 'IS', CLASS, base_want):
# T branch requires leaf truth = base_want, F branch = not base_want
field, op, val, base_want = parsed
paths = []
true_sub = _cap_paths(enum_paths(node.true_seq, fields))
for sp_cons, sp_assign in (true_sub or [([], {})]):
paths.append(([(field, op, val, base_want)] + sp_cons, sp_assign))
false_sub = _cap_paths(enum_paths(node.false_seq, fields))
for fp_cons, fp_assign in (false_sub or [([], {})]):
paths.append(([(field, op, val, not base_want)] + fp_cons, fp_assign))
return paths
field, op, val = parsed
paths = []
true_sub = _cap_paths(enum_paths(node.true_seq, fields))
@@ -275,7 +292,7 @@ def enum_paths(node, fields):
paths.append(([(leaf.field, leaf.op, leaf.value, False)] + fp_cons, fp_assign))
return paths
# Fallback: parsed condition but non-field (e.g. arithmetic expr)
if parsed:
if parsed and len(parsed) == 3:
field, op, val = parsed
paths = []
true_sub = enum_paths(node.true_seq, fields)
@@ -430,7 +447,7 @@ def enum_paths(node, fields):
elif node.perf_type in ('until', 'para_until', 'varying', 'para_varying'):
# 尝试单条件(现有逻辑)
parsed = parse_single_condition(node.condition, fields)
if parsed and is_field(parsed[0], fields):
if parsed and is_field(parsed[0], fields) and len(parsed) == 3:
field, op, val = parsed
paths = []
# Skip 路径放在首位,确保不被 _cap_paths 截断丢失
@@ -890,6 +907,8 @@ def _check_constraint_satisfied(rec, field_name, operator, value, want_true, fie
val = rec.get(field_name)
if val is None:
return False
if operator == 'IS':
return evaluate_class_value(val, value) == want_true
if operator == 'not_in':
cases = value if isinstance(value, list) else []
return str(val) not in cases
@@ -909,22 +928,32 @@ def _check_constraint_satisfied(rec, field_name, operator, value, want_true, fie
return ok == want_true
return True
else:
s_val = str(val).strip().upper()
s_target = str(value).strip().upper()
eq = s_val == s_target
s_val = str(val)
s_target = str(value)
# 图式常量:SPACES/SPACE 按空白比较(不剥离),否则按去除两端空白比较
_sv = s_val.strip().upper()
_tv = s_target.strip().upper()
if _tv in ('SPACE', 'SPACES'):
eq = (s_val.strip() == '')
elif _tv == 'LOW-VALUE':
eq = (s_val.strip('\x00') == '')
elif _tv == 'HIGH-VALUE':
eq = (s_val.strip('\xff') == '')
else:
eq = (_sv == _tv)
if operator == '=':
return eq == want_true
elif operator == '<>':
return (not eq) == want_true
elif operator in ('>', '<', '>=', '<='):
if operator == '>':
ok = s_val > s_target
ok = _sv > _tv
elif operator == '<':
ok = s_val < s_target
ok = _sv < _tv
elif operator == '>=':
ok = s_val >= s_target
ok = _sv >= _tv
elif operator == '<=':
ok = s_val <= s_target
ok = _sv <= _tv
return ok == want_true
return True
return False
@@ -1226,6 +1255,19 @@ def apply_constraint(rec, field_name, operator, value, want_true, fields, assign
# 如果当前值已满足该约束,跳过覆盖(保持先前约束的一致性)
# 但零值时强制使用边界值(非 0/非 min)
# 特例:字母数字字段 "字段 <> 空白" 期望为真时,即使当前值已含非空白字符,
# 也要强制整字段填满非空白。这是因为源条件可能使用引用修改(如
# WRK-C-INSURED-NO(11:)),仅"有非空白字符"不足以满足截取子串的判定。
_uv2 = str(value).strip().upper() if isinstance(value, str) else ''
if (want_true and operator in ('<>', '!=') and _uv2 in ('SPACE', 'SPACES')):
_fd2 = next((f for f in fields if f['name'] == field_name), None)
if _fd2 and _fd2.get('pic_info', {}).get('type') in ('alphanumeric', 'alphabetic'):
_pi2 = _fd2['pic_info']
_len2 = _pi2.get('length', 1)
_cur2 = str(rec.get(field_name, ''))
if _cur2.strip() and len(_cur2.rstrip()) < _len2:
rec[field_name] = ('U' * _len2)
return
if _check_constraint_satisfied(rec, field_name, operator, value, want_true, fields):
cur = str(rec.get(field_name, '')).strip('0')
if (cur == '' or cur == '.') and (
@@ -1578,27 +1620,30 @@ def generate_records(path_infos, data_fields, base_assignments=None, file_sec=No
pass
if skip_impossible:
continue
# Pass B.0: CALL 返回码一致性 — 将要求返回码非零的约束转为入参无效化
# Pass B.0: CALL 返回码一致性 — 按调用级联顺序定位"首个失败"的校验入参。
# 仅将导致该次校验失败的字段置为无效,其余级联字段修复为有效值,
# 使路径真正执行到目标校验分支(修复先前"全部入参无效化"导致提前分支底的问题)。
_b0_invalidated = set()
new_cons = []
_b0_rrc_counter = 0
_b0_cascade_plan = _plan_cascade_failures(path_cons, base_assignments)
# 对每个外部校验家系应用级联修复:首个"要求失败"的入参置无效,
# 其余入参修复为有效值;全通过(fail_pos=None)则全部修复为有效值。
# 覆盖"前面校验通过、最后一项校验失败"与"全部通过"两类路径。
for _fam, _plan in (_b0_cascade_plan or {}).items():
_feeds = _plan.get('feeds') or []
_fail_pos = _plan.get('fail_pos')
for _i, _src in enumerate(_feeds):
if _src not in rec:
continue
if _fail_pos is not None and _i == _fail_pos:
_set_invalid_value(rec, _src, data_fields, base_assignments)
_b0_invalidated.add(_src)
else:
_repair_valid_checked_field(rec, _src, data_fields)
for c in path_cons:
if len(c) == 4 and c[1] == '<>' and c[3] and c[0].endswith('RRC'):
rrc_field = c[0]
prefix = rrc_field[:-3]
if len(c) == 4 and c[1] == '<>' and c[0].endswith('RRC'):
_b0_rrc_counter += 1
for tgt, asgn_list in base_assignments.items():
if tgt.startswith(prefix) and tgt != rrc_field:
for asgn in asgn_list:
atyp = asgn.get('type', '').upper()
src = None
if atyp == 'MOVE':
src = asgn.get('src', asgn.get('source_vars', [None])[0] if asgn.get('source_vars') else None)
elif atyp == 'move' and asgn.get('source_vars'):
src = asgn['source_vars'][0]
if src and isinstance(src, str) and src in rec:
_set_invalid_value(rec, src, data_fields)
_b0_invalidated.add(src)
continue
new_cons.append(c)
path_cons = new_cons
@@ -1744,6 +1789,10 @@ def generate_records(path_infos, data_fields, base_assignments=None, file_sec=No
_status_0or1 = str(rec.get('WRK-CSV-STATUS', '')).strip() in ('0', '1')
if _emp_id_spaces and _status_0or1 and 'WRK-CSV-APPL-DATE' in rec:
_is_c01chk_want_true = True
# 命令行馈入字段 → 运行时命令行参数(供运行器传递,覆盖 PARM 校验分支)
_cli_args = _collect_cli_args(rec, base_assignments, data_fields)
if _cli_args:
rec['__CLI_ARGS__'] = _cli_args
records.append(rec)
kept_path_cons.append(path_cons)
term_types.append(term_type)
@@ -1861,15 +1910,105 @@ def _sync_unstring_targets_from_output(rec, base_assignments, data_fields):
rec[src] = tgt_val
def _set_invalid_value(rec, field_name, data_fields):
def _set_invalid_value(rec, field_name, data_fields, assignments=None):
"""将字段设为无效值,用于触发 CALL 返回码非零。"""
# 命令行馈入字段(ACCEPT ... FROM COMMAND-LINE):空白会被程序当作缺省值
# 处理(如 IF x = SPACES MOVE '202605'),因此必须用非空白无效值;
# 否则运行时取缺省值导致 PARM 校验分支(ABEND 路径)无法覆盖。
_is_cli = False
if assignments:
_al = assignments.get(field_name)
_al = _al if isinstance(_al, list) else ([_al] if _al else [])
for _a in _al:
if _a.get('type') == 'accept':
_is_cli = True
break
for f in data_fields:
if f['name'] == field_name:
pi = f.get('pic_info', {})
ftype = pi.get('type', '')
length = pi.get('length', 0) or pi.get('digits', 0) + pi.get('decimal', 0)
if ftype in ('alphanumeric', 'alphabetic'):
rec[field_name] = ' ' * length
if _is_cli:
rec[field_name] = ('Z' * length) if length else 'ZZZZZZ'
else:
rec[field_name] = ' ' * length
else:
rec[field_name] = '9' * length
return
def _collect_cli_args(rec, base_assignments, data_fields):
"""收集命令行馈入字段(ACCEPT ... FROM COMMAND-LINE)的运行时参数值。
返回 {字段名: 值},供运行器作为程序命令行参数传递。通用、无程序硬编码。
"""
if not base_assignments:
return {}
cli = {}
for f in data_fields:
name = f['name']
if name in rec:
_al = base_assignments.get(name)
_al = _al if isinstance(_al, list) else ([_al] if _al else [])
if any(_a.get('type') == 'accept' for _a in _al):
cli[name] = str(rec.get(name, ''))
return cli
def _plan_cascade_failures(path_cons, base_assignments):
"""预计算每个外部校验家系(如 C01CHK)的"首个失败"校验位置与级联入参顺序。
返回:{ family: {'feeds': [入参字段...], 'fail_pos': int|None} }
- feeds:按调用顺序排列的校验入参字段(C01CHKDAT 的 MOVE 源)。
- fail_pos:本路径中首个要求返回码非零的校验下标(无则为 None —— 全通过)。
该映射与 target 无关、不含程序名硬编码,对所有使用外部校验子(SUB04 等)重命名的 family 通用。
"""
plan = {}
feeds_map = {}
for tgt, alist in (base_assignments or {}).items():
for a in alist:
atyp = str(a.get('type', '')).upper()
if atyp in ('MOVE', 'MOVE_LITERAL') and a.get('source_vars'):
for src in a.get('source_vars', []):
if isinstance(src, str):
feeds_map.setdefault(tgt, []).append(src)
# 返回码字段形如 family+'RRC';入参数据字段形如 family+'DAT'<校验数据>)。
# 汇总同一 family 的 需要/通过 标志,取首个"要求失败"处为 fail_pos。
want_order = []
for c in path_cons:
if len(c) == 4 and c[1] == '<>' and c[0].endswith('RRC'):
want_order.append((c[0][:-3], bool(c[3])))
fam_wants = {}
for fam, want in want_order:
fam_wants.setdefault(fam, []).append(want)
for fam, wants in fam_wants.items():
data_field = None
for tgt in feeds_map:
if tgt.startswith(fam) and 'DAT' in tgt.upper():
data_field = tgt
break
feeds = feeds_map.get(data_field, []) if data_field else []
fail_pos = None
for i, want in enumerate(wants):
if want:
fail_pos = i
break
plan[fam] = {'feeds': feeds, 'fail_pos': fail_pos}
return plan
def _repair_valid_checked_field(rec, field_name, data_fields):
"""将校验入参修复为可通过外部校验器的有效值(日期/数字),供后续校验分支通过。"""
if field_name not in rec:
return
for f in data_fields:
if f['name'] == field_name:
pi = f.get('pic_info', {})
ftype = pi.get('type', '')
length = pi.get('length', 0) or pi.get('digits', 0) + pi.get('decimal', 0)
if ftype == 'numeric':
rec[field_name] = '1' * length if length else rec[field_name]
elif _is_date_field(field_name):
rec[field_name] = '20240115'
return