Files
cobol-java-v3/cobol_testgen/cond.py
T
NB-076 e2a8d53e60 fix: 覆盖率统计全面修复 + 5漏洞修正
## 修复内容

### C1: _mark_eval 反向操作符 (coverage.py)
- EVALUATE 约束匹配支持  操作符
- WHEN OTHER 的自动检测(全部 WHEN 被否定时)

### C2: _mark_perform 反向操作符 (coverage.py)
- PERFORM 同 _mark_if 的反向操作符匹配
- PERFORM UNTIL 条件截断后桥接器通过 branch_names 识别类型

### H1: parse_single_condition 传递 fields (coverage.py)
- collect_decision_points 调用时传 fields 参数
- NOT 前缀条件解析 (NOT WS-X > 50 → WS-X <= 50)

### H4: generate_data 输入约束 (__init__.py)
- 文档注明接收原始源码,非预处理后文本

### M1: not_map break (cond.py)
- NOT 操作符映射循环添加 break

## 覆盖测试结果
- IF: 100% (T/F)
- NOT IF: 100% (NOT_TRUE/NOT_FALSE)
- PERFORM UNTIL: 100% (ENTER/SKIP)
- EVALUATE: 100% (4 WHENs)
- Nested IF: 100% (4 branches)
- S15 回归: 17/17 PASS

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 21:14:50 +08:00

360 lines
13 KiB
Python

"""条件层:COBOL条件表达式解析 + MC/DC枚举 + 约束合并"""
import re
from .models import CondLeaf, CondAnd, CondOr, CondNot, PicInfo
# ── 条件解析 ──
def _split_at_operator(text, operator):
"""Split text on operator word, respecting parentheses."""
result = []
current = []
depth = 0
# Normalize so parentheses are space-delimited tokens
normalized = text.replace('(', ' ( ').replace(')', ' ) ')
for token in normalized.split():
if not token:
continue
if token == '(':
depth += 1
current.append(token)
elif token == ')':
depth -= 1
current.append(token)
elif token == operator and depth == 0:
result.append(' '.join(current).strip())
current = []
else:
current.append(token)
result.append(' '.join(current).strip())
return result
def parse_single_condition(text, fields=None):
"""Parse a COBOL condition into (field, operator, value) 3-tuple.
Handles:
- Basic: AMOUNT > 1000 → (AMOUNT, '>', '1000')
- 88-lev: STATUS-APPROVED → (parent, '=', value)
- NOT =: X NOT = 5 → (X, '<>', '5') (NOT = means <>)
- NOT >: X NOT > 5 → (X, '<=', '5')
- NOT <: X NOT < 5 → (X, '>=', '5')
- NOT 88: NOT WS-EOF-Y → (parent, '<>', value)
- Bare: WS-EOF → (WS-EOF, '=', 'Y')
- NOT bare: NOT WS-EOF → (WS-EOF, '<>', 'Y')
- NOT arith: A+B NOT = C → ('A+B', '<>', 'C')
- SQLCODE: SQLCODE = 100 → ('SQLCODE', '=', '100')
- SQLSTATE: SQLSTATE <> '02000' → ('SQLSTATE', '<>', '02000')
Returns None for compound (AND/OR) conditions.
"""
if ' AND ' in text or ' OR ' in text:
return None
text = text.strip()
field_name = text.split()[0] if text else ''
# SQLCODE special handling
if field_name.upper() == 'SQLCODE':
text_upper = text.upper()
if 'GREATER THAN 0' in text_upper or 'GREATER THAN ZERO' in text_upper:
return ('SQLCODE', '>', '0')
if 'LESS THAN 0' in text_upper:
return ('SQLCODE', '<', '0')
if '= 100' in text_upper:
return ('SQLCODE', '=', '100')
if 'NOT = 100' in text_upper:
return ('SQLCODE', '<>', '100')
# SQLSTATE special handling
if field_name.upper() == 'SQLSTATE':
normalized_sql = re.sub(r'\bNOT\s*=', '<>', text, flags=re.IGNORECASE)
m = re.match(r"SQLSTATE\s*(>=|<=|<>|>|<|=)\s*['\"]?(.+?)['\"]?\s*$", normalized_sql, re.IGNORECASE)
if m:
return ('SQLSTATE', m.group(1), m.group(2).strip().strip("'\""))
# Resolve 88-level condition names
if fields:
for f in fields:
if f.get('is_88') and f['name'] == text.upper():
return (f.get('parent', ''), '=', f.get('value', ''))
# NOT 88-level → invert operator
if f.get('is_88') and text.upper().startswith('NOT ') and f['name'] == text[4:].strip().upper():
return (f.get('parent', ''), '<>', f.get('value', ''))
# Bare NOT field reference (no operator): NOT WS-EOF → WS-EOF <> 'Y'
if text.upper().startswith('NOT ') and not re.search(r'(>=|<=|<>|>|<|=)', text):
fn = text[4:].strip()
if re.match(r'^[A-Z][A-Z0-9-]*(?:\([^)]*\))?$', fn, re.IGNORECASE):
return (fn, '<>', 'Y')
# NOT at start of condition: NOT WS-X > 50 → WS-X <= 50
# Strip leading NOT, parse the inner condition, invert the operator
if text.upper().startswith('NOT '):
inner = text[4:].strip()
inner_parsed = None
# Try standard regex on inner text
m_inner = re.match(r"^(\w[\w-]*(?:\s*\([^)]*\))?)\s*(>=|<=|<>|>|<|=)\s*(.+)$", inner)
if m_inner:
inv_op_map = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}
f = re.sub(r'\s*([(),])\s*', r'\1', m_inner.group(1))
op = m_inner.group(2)
val = m_inner.group(3).strip().strip("'").strip('"')
inv = inv_op_map.get(op, op)
return (f, inv, val)
# Normalize COBOL NOT-operators: X NOT = Y → X <> Y
normalized = text
not_map = [
(r'\bNOT\s+>=', '<'), (r'\bNOT\s+<=', '>'),
(r'\bNOT\s+<>', '='), (r'\bNOT\s+=', '<>'),
(r'\bNOT\s+>', '<='), (r'\bNOT\s+<', '>='),
]
for pat, repl in not_map:
if re.search(pat, text, re.IGNORECASE):
normalized = re.sub(pat, repl, text, flags=re.IGNORECASE)
break
# Standard regex: FIELD OP VALUE
m = re.match(
r"^(\w[\w-]*(?:\s*\([^)]*\))?)\s*(>=|<=|<>|>|<|=)\s*(.+)$",
normalized
)
if m:
field = re.sub(r'\s*([(),])\s*', r'\1', m.group(1))
return (field, m.group(2), m.group(3).strip().strip("'").strip('"'))
# Arithmetic expression regex (lazy match allows spaces in field expr)
m = re.match(
r"^(\w[\w\s+\-*/().-]+?)\s*(>=|<=|<>|>|<|=)\s*(.+)$",
normalized
)
if m:
field = re.sub(r'\s*([(),])\s*', r'\1', m.group(1)).strip()
# Clean trailing ' NOT' that got swallowed by lazy match
if field.upper().endswith(' NOT'):
field = field[:-4].strip()
return (field, m.group(2), m.group(3).strip().strip("'").strip('"'))
# Bare field: WS-EOF (no operator) → treat as WS-EOF = 'Y'
if re.match(r'^[A-Z][A-Z0-9-]*(?:\([^)]*\))?$', text, re.IGNORECASE):
return (text, '=', 'Y')
return None
def parse_compound_condition(text, fields=None):
"""Parse a COBOL condition into a condition tree (AND/OR/LEAF).
Handles AND > OR precedence and parentheses.
"""
text = text.strip()
if not text:
return None
# Normalize parentheses to be space-delimited for reliable tokenization
text = text.replace('(', ' ( ').replace(')', ' ) ')
text = re.sub(r'\s+', ' ', text).strip()
# Strip outer parentheses
if text.startswith('(') and text.endswith(')'):
depth = 0
wrapped = True
for i, c in enumerate(text):
if c == '(':
depth += 1
elif c == ')':
depth -= 1
if depth == 0 and i < len(text) - 1:
wrapped = False
break
if wrapped:
inner = parse_compound_condition(text[1:-1], fields)
if inner:
return inner
# Split on OR (lowest precedence)
parts = _split_at_operator(text, 'OR')
if len(parts) > 1:
node = parse_compound_condition(parts[0], fields)
for p in parts[1:]:
node = CondOr(node, parse_compound_condition(p, fields))
return node
# Split on AND
parts = _split_at_operator(text, 'AND')
if len(parts) > 1:
node = parse_compound_condition(parts[0], fields)
for p in parts[1:]:
node = CondAnd(node, parse_compound_condition(p, fields))
return node
# NOT prefix (highest precedence, after AND/OR splitting)
if text.upper().startswith('NOT '):
inner = parse_compound_condition(text[4:].strip(), fields)
return CondNot(inner) if inner else None
# Leaf condition
parsed = parse_single_condition(text, fields)
if parsed:
return CondLeaf(*parsed)
return None
def collect_leaves(tree):
"""Return list of all CondLeaf nodes in the tree."""
if isinstance(tree, CondLeaf):
return [tree]
elif isinstance(tree, CondNot):
return collect_leaves(tree.child)
elif isinstance(tree, (CondAnd, CondOr)):
return collect_leaves(tree.left) + collect_leaves(tree.right)
return []
def evaluate_tree(tree, assignment):
"""Evaluate condition tree given leaf→bool assignment dict."""
if isinstance(tree, CondLeaf):
return assignment[tree]
elif isinstance(tree, CondNot):
return not evaluate_tree(tree.child, assignment)
elif isinstance(tree, CondAnd):
return evaluate_tree(tree.left, assignment) and evaluate_tree(tree.right, assignment)
elif isinstance(tree, CondOr):
return evaluate_tree(tree.left, assignment) or evaluate_tree(tree.right, assignment)
return False
def is_field(name, fields):
# Strip subscript: WS-ITEM-STATUS(WS-INDEX-VAR) -> WS-ITEM-STATUS
bare = re.sub(r'\s*\(.*\)\s*$', '', name).strip()
for f in fields:
if f['name'] == bare.upper():
return True
return False
# ── MC/DC ──
def mcdc_sets(tree, fields=None):
"""Generate MC/DC constraint sets.
Returns list of (constraints_list, decision_outcome) or None for simple conditions.
Each constraint is (field, op, value, want_true).
"""
leaves = collect_leaves(tree)
n = len(leaves)
if n <= 1:
return None
# Evaluate all 2^n truth assignments
all_results = []
for bits in range(1 << n):
assignment = {}
for i, leaf in enumerate(leaves):
assignment[leaf] = bool(bits & (1 << i))
result = evaluate_tree(tree, assignment)
all_results.append((assignment, result))
# For each leaf, find a pair showing independent effect on decision
needed_pairs = {}
for leaf in leaves:
for a1, r1 in all_results:
if leaf in needed_pairs:
break
for a2, r2 in all_results:
if a1[leaf] != a2[leaf] and r1 != r2:
if all(a1[o] == a2[o] for o in leaves if o != leaf):
needed_pairs[leaf] = (dict(a1), r1, dict(a2), r2)
break
# Convert leaf assignments to constraint tuples
result = []
added = set()
for leaf, (a1, r1, a2, r2) in needed_pairs.items():
for assignment, decision in [(a1, r1), (a2, r2)]:
key = frozenset((l, assignment[l]) for l in leaves)
if key not in added:
added.add(key)
constraints = []
for l in leaves:
want = assignment[l]
constraints.append((l.field, l.op, l.value, want))
result.append((constraints, decision))
return result
# ── 值计算 ──
def satisfying_value(field_info: dict, operator: str, value, want_true: bool) -> str:
ftype = field_info.get('type', 'unknown')
digits = field_info.get('digits', 0)
decimal = field_info.get('decimal', 0)
total = digits + decimal
if ftype == 'numeric':
try:
val_str = str(value)
val_float = float(val_str)
val_int = int(val_float * (10 ** decimal) + 0.5)
except (ValueError, TypeError):
val_int = 0
if want_true:
if operator == '>':
val_int = val_int + 1
elif operator in ('>=', '=', '<='):
pass
elif operator == '<':
val_int = max(0, val_int - 1)
elif operator == '<>':
val_int = (val_int + 1) % (10 ** total)
else:
if operator in ('>', '>='):
val_int = 0
elif operator == '=':
val_int = (val_int + 1) % (10 ** total)
elif operator == '<':
pass
elif operator == '<=':
val_int = val_int + 1
elif operator == '<>':
pass
val_int = val_int % (10 ** total)
int_part = str(val_int // (10 ** decimal)).zfill(digits)
dec_part = str(val_int % (10 ** decimal)).zfill(decimal)
if decimal == 0:
return int_part
return int_part + dec_part
elif ftype in ('alphanumeric', 'alphabetic'):
length = field_info.get('length', 1)
base_chr = value[0].upper() if isinstance(value, str) and value else 'A'
if want_true:
if operator in ('=', '=='):
return base_chr.ljust(length, base_chr)
elif operator in ('<>', '!='):
other = chr(65 + (ord(base_chr) - 64) % 26)
return other.ljust(length, other)
elif operator == '>':
sv = str(value)[:length].ljust(length)
chars = list(sv)
last = chars[-1]
if last not in '9Zz':
chars[-1] = chr(ord(last) + 1)
return ''.join(chars)
elif operator == '<':
sv = str(value)[:length].ljust(length)
chars = list(sv)
last = chars[-1]
if last == ' ':
pass
elif last in '0Aa':
chars[-1] = ' '
else:
chars[-1] = chr(ord(last) - 1)
return ''.join(chars)
else:
if operator in ('=', '=='):
other = chr(65 + (ord(base_chr) - 64) % 26)
return other.ljust(length, other)
elif operator in ('<>', '!='):
return base_chr.ljust(length, base_chr)
elif operator in ('>', '<'):
return str(value)[:length].ljust(length)
return '0'.zfill(total)