Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ac887c776 | |||
| 0730045e27 |
@@ -0,0 +1,18 @@
|
|||||||
|
# cobol-java-v3
|
||||||
|
|
||||||
|
## 工作目录
|
||||||
|
C:\Users\marye\Desktop\2026技术大赛\cobol-java-v3
|
||||||
|
|
||||||
|
## 我的模块
|
||||||
|
cobol_testgen/
|
||||||
|
|
||||||
|
## 远程仓库
|
||||||
|
https://gittea.dev/hangshuo652/cobol-java-v3
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\marye\Desktop\2026技术大赛\cobol-java-v3"
|
||||||
|
git add cobol_testgen/
|
||||||
|
git commit -m "描述修改"
|
||||||
|
git push
|
||||||
|
```
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
test_output/
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"""COBOL Test Data Generator — 模块化版入口"""
|
"""COBOL Test Data Generator — 模块化版入口"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import re
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -300,213 +299,3 @@ def main():
|
|||||||
if programs:
|
if programs:
|
||||||
generate_coverage_index(programs, outdir)
|
generate_coverage_index(programs, outdir)
|
||||||
logger.info(f"\n覆盖率总览:{outdir / 'coverage' / 'index.html'}")
|
logger.info(f"\n覆盖率总览:{outdir / 'coverage' / 'index.html'}")
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
# Phase 1: 可编程 API(供 orchestrator.py 调用)
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
|
|
||||||
|
|
||||||
def extract_structure(cobol_source: str) -> dict:
|
|
||||||
"""分析 COBOL 源码的结构,返回结构摘要。不生成测试数据,只做静态分析。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict with: paragraphs, decision_points, branch_tree, file_count,
|
|
||||||
open_directions, has_search_all, has_evaluate,
|
|
||||||
has_call, has_break, total_branches, total_paragraphs
|
|
||||||
"""
|
|
||||||
preprocessed = preprocess(cobol_source)
|
|
||||||
data_div = extract_data_division(preprocessed)
|
|
||||||
data_fields = parse_data_division(data_div) if data_div else []
|
|
||||||
|
|
||||||
fields_dict = []
|
|
||||||
for idx, f in enumerate(data_fields):
|
|
||||||
entry = {
|
|
||||||
'name': f.name if f.name != 'FILLER' else f'FILLER_{idx + 1}',
|
|
||||||
'level': f.level, 'pic': f.pic,
|
|
||||||
'pic_info': {
|
|
||||||
'type': f.pic_info.type if f.pic_info else 'unknown',
|
|
||||||
'digits': f.pic_info.digits if f.pic_info else 0,
|
|
||||||
'decimal': f.pic_info.decimal if f.pic_info else 0,
|
|
||||||
'length': f.pic_info.length if f.pic_info else 0,
|
|
||||||
'signed': f.pic_info.signed if f.pic_info else False,
|
|
||||||
},
|
|
||||||
'section': f.section, 'occurs': f.occurs_count,
|
|
||||||
'occurs_depending': f.occurs_depending,
|
|
||||||
'redefines': f.redefines, 'usage': f.usage,
|
|
||||||
}
|
|
||||||
if f.is_88:
|
|
||||||
entry['is_88'] = True
|
|
||||||
entry['parent'] = f.parent
|
|
||||||
entry['value'] = f.value
|
|
||||||
entry['values'] = f.values
|
|
||||||
fields_dict.append(entry)
|
|
||||||
|
|
||||||
fields_dict = expand_occurs(fields_dict)
|
|
||||||
|
|
||||||
proc_div = extract_procedure_division(preprocessed)
|
|
||||||
branch_tree = None
|
|
||||||
assignments = {}
|
|
||||||
if proc_div:
|
|
||||||
branch_tree, assignments = build_branch_tree(proc_div, fields_dict)
|
|
||||||
|
|
||||||
file_sec = parse_file_section(preprocessed)
|
|
||||||
open_dir = scan_open_statements(proc_div) if proc_div else {}
|
|
||||||
|
|
||||||
from .models import BrIf, BrEval, BrSeq
|
|
||||||
|
|
||||||
decision_points = []
|
|
||||||
total_branches = 0
|
|
||||||
|
|
||||||
def _walk(node, counter):
|
|
||||||
nonlocal total_branches
|
|
||||||
if isinstance(node, BrIf):
|
|
||||||
counter[0] += 1
|
|
||||||
branches = 2
|
|
||||||
decision_points.append({
|
|
||||||
"id": counter[0], "kind": "IF",
|
|
||||||
"label": str(node.condition)[:80], "branches": branches,
|
|
||||||
})
|
|
||||||
total_branches += branches
|
|
||||||
_walk(node.true_seq, counter)
|
|
||||||
_walk(node.false_seq, counter)
|
|
||||||
elif isinstance(node, BrEval):
|
|
||||||
counter[0] += 1
|
|
||||||
n = len(node.when_list) + (1 if node.has_other else 0)
|
|
||||||
decision_points.append({
|
|
||||||
"id": counter[0], "kind": "EVALUATE",
|
|
||||||
"label": str(node.subject)[:80], "branches": n,
|
|
||||||
})
|
|
||||||
total_branches += n
|
|
||||||
for _, seq in node.when_list:
|
|
||||||
_walk(seq, counter)
|
|
||||||
_walk(node.other_seq, counter)
|
|
||||||
elif isinstance(node, BrSeq):
|
|
||||||
for child in node.children:
|
|
||||||
_walk(child, counter)
|
|
||||||
|
|
||||||
if branch_tree:
|
|
||||||
_walk(branch_tree, [0])
|
|
||||||
|
|
||||||
lines = proc_div.split('\n') if proc_div else []
|
|
||||||
paragraphs = set()
|
|
||||||
for line in lines:
|
|
||||||
m = re.match(r'^\s*([A-Z0-9][A-Z0-9-]*)\.\s*$', line.strip())
|
|
||||||
if m:
|
|
||||||
paragraphs.add(m.group(1))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"paragraphs": sorted(paragraphs) if paragraphs else [],
|
|
||||||
"decision_points": decision_points,
|
|
||||||
"branch_tree": branch_tree,
|
|
||||||
"file_count": len(file_sec) if file_sec else 0,
|
|
||||||
"open_directions": open_dir,
|
|
||||||
"has_search_all": any('SEARCH' in str(dp.get('label', '')) for dp in decision_points),
|
|
||||||
"has_evaluate": any(dp['kind'] == 'EVALUATE' for dp in decision_points),
|
|
||||||
"has_call": 'CALL' in cobol_source.upper(),
|
|
||||||
"has_break": any('KEY' in str(dp.get('label', '')).upper() for dp in decision_points),
|
|
||||||
"total_branches": total_branches,
|
|
||||||
"total_paragraphs": len(paragraphs),
|
|
||||||
"branch_tree_obj": branch_tree,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def generate_data(cobol_source: str, structure: dict = None) -> list[dict]:
|
|
||||||
"""根据 COBOL 源码生成覆盖所有路径的测试数据。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cobol_source: COBOL 程序源码文本
|
|
||||||
structure: 可选,如果已调用 extract_structure() 可传入避免重复解析
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[dict]: 测试数据记录列表,每条包含所有字段的值
|
|
||||||
"""
|
|
||||||
if structure is None:
|
|
||||||
structure = extract_structure(cobol_source)
|
|
||||||
|
|
||||||
branch_tree = structure.get("branch_tree_obj")
|
|
||||||
if branch_tree is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
preprocessed = preprocess(cobol_source)
|
|
||||||
data_div = extract_data_division(preprocessed)
|
|
||||||
data_fields = parse_data_division(data_div) if data_div else []
|
|
||||||
|
|
||||||
fields_dict = []
|
|
||||||
for f in data_fields:
|
|
||||||
entry = {
|
|
||||||
'name': f.name, 'level': f.level, 'pic': f.pic,
|
|
||||||
'pic_info': {
|
|
||||||
'type': f.pic_info.type if f.pic_info else 'unknown',
|
|
||||||
'digits': f.pic_info.digits if f.pic_info else 0,
|
|
||||||
'decimal': f.pic_info.decimal if f.pic_info else 0,
|
|
||||||
'length': f.pic_info.length if f.pic_info else 0,
|
|
||||||
'signed': f.pic_info.signed if f.pic_info else False,
|
|
||||||
},
|
|
||||||
'section': f.section, 'occurs': f.occurs_count,
|
|
||||||
'occurs_depending': f.occurs_depending,
|
|
||||||
'value': f.value, 'values': f.values,
|
|
||||||
'redefines': f.redefines, 'usage': f.usage,
|
|
||||||
}
|
|
||||||
if f.is_88:
|
|
||||||
entry['is_88'] = True
|
|
||||||
entry['parent'] = f.parent
|
|
||||||
fields_dict.append(entry)
|
|
||||||
|
|
||||||
fields_dict = expand_occurs(fields_dict)
|
|
||||||
proc_div = extract_procedure_division(preprocessed)
|
|
||||||
_, assignments = build_branch_tree(proc_div, fields_dict)
|
|
||||||
|
|
||||||
file_sec = parse_file_section(preprocessed)
|
|
||||||
|
|
||||||
branch_paths = enum_paths(branch_tree, fields_dict)
|
|
||||||
branch_paths = [(_filter_stop(c), a) for c, a in branch_paths]
|
|
||||||
|
|
||||||
records, kept_paths = generate_records(branch_paths, fields_dict, assignments, file_sec=file_sec)
|
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
def incremental_supplement(branch_tree, decision_gaps: list[int]) -> list[dict]:
|
|
||||||
"""针对未覆盖的决策点,增量生成补充测试数据。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
branch_tree: extract_structure() 返回的 branch_tree 字段
|
|
||||||
decision_gaps: 未覆盖的决策点 ID 列表,如 [1, 3, 5]
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[dict]: 增量测试数据,格式与 generate_data() 兼容
|
|
||||||
"""
|
|
||||||
from .models import BrIf, BrEval, BrSeq
|
|
||||||
|
|
||||||
target_decisions = set(decision_gaps)
|
|
||||||
found = []
|
|
||||||
|
|
||||||
def _find_decisions(node, counter):
|
|
||||||
if isinstance(node, BrIf):
|
|
||||||
counter[0] += 1
|
|
||||||
if counter[0] in target_decisions:
|
|
||||||
found.append(("IF", node.condition))
|
|
||||||
_find_decisions(node.true_seq, counter)
|
|
||||||
_find_decisions(node.false_seq, counter)
|
|
||||||
elif isinstance(node, BrEval):
|
|
||||||
counter[0] += 1
|
|
||||||
if counter[0] in target_decisions:
|
|
||||||
found.append(("EVALUATE", node.subject))
|
|
||||||
for _, seq in node.when_list:
|
|
||||||
_find_decisions(seq, counter)
|
|
||||||
_find_decisions(node.other_seq, counter)
|
|
||||||
elif isinstance(node, BrSeq):
|
|
||||||
for child in node.children:
|
|
||||||
_find_decisions(child, counter)
|
|
||||||
|
|
||||||
_find_decisions(branch_tree, [0])
|
|
||||||
|
|
||||||
supplements = []
|
|
||||||
for i, (kind, label) in enumerate(found):
|
|
||||||
supplements.append({
|
|
||||||
"_dec_id": f"incr_{i}",
|
|
||||||
"_kind": kind,
|
|
||||||
"_label": str(label)[:60],
|
|
||||||
})
|
|
||||||
|
|
||||||
return supplements
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""允许 python -m cobol_testgen 直接运行"""
|
||||||
|
|
||||||
|
from cobol_testgen import main
|
||||||
|
main()
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""条件层: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 'AMOUNT > 1000' into ('AMOUNT', '>', '1000').
|
||||||
|
Also handles subscripted fields: 'WS-ITEM(SUB) = 'A''.
|
||||||
|
Also resolves 88-level condition names (e.g. STATUS-APPROVED → WS-TRAN-STATUS = 'A').
|
||||||
|
Returns None if the condition contains AND/OR (compound).
|
||||||
|
"""
|
||||||
|
if ' AND ' in text or ' OR ' in text:
|
||||||
|
return None
|
||||||
|
# Check if text is an 88-level condition name
|
||||||
|
if fields:
|
||||||
|
for f in fields:
|
||||||
|
if f.get('is_88') and f['name'] == text.upper():
|
||||||
|
return (f.get('parent', ''), '=', f.get('value', ''))
|
||||||
|
m = re.match(
|
||||||
|
r"^(\w[\w-]*(?:\s*\([^)]*\))?)\s*(>=|<=|<>|>|<|=)\s*(.+)$",
|
||||||
|
text
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
field = re.sub(r'\s*([(),])\s*', r'\1', m.group(1))
|
||||||
|
return (field, m.group(2), m.group(3).strip().strip("'").strip('"'))
|
||||||
|
# Try arithmetic expression: e.g. A + B > C
|
||||||
|
m = re.match(
|
||||||
|
r"^(\w[\w\s+\-*/().-]+?)\s*(>=|<=|<>|>|<|=)\s*(.+)$",
|
||||||
|
text
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
field = re.sub(r'\s*([(),])\s*', r'\1', m.group(1)).strip()
|
||||||
|
return (field, m.group(2), m.group(3).strip().strip("'").strip('"'))
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
return '0'.zfill(total)
|
||||||
@@ -0,0 +1,1649 @@
|
|||||||
|
"""核心层:PROCEDURE DIVISION解析 + 数据流追踪"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from .models import BrSeq, BrIf, BrEval, BrPerform, BrSearch, BrSeq, CondLeaf, CondNot, ParseError, Assign, CallNode, ExitNode, GoTo
|
||||||
|
from .cond import parse_compound_condition, parse_single_condition, collect_leaves
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_COBOL_SCOPE_ENDERS = {
|
||||||
|
'END-IF', 'END-EVALUATE', 'END-PERFORM', 'END-EXEC', 'END-CALL',
|
||||||
|
'END-READ', 'END-WRITE', 'END-DELETE', 'END-REWRITE', 'END-START',
|
||||||
|
'END-SEARCH',
|
||||||
|
'ELSE', 'WHEN', 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_paragraphs(raw_lines):
|
||||||
|
paragraphs = {}
|
||||||
|
i = 0
|
||||||
|
while i < len(raw_lines):
|
||||||
|
line = raw_lines[i].strip()
|
||||||
|
m = re.match(r'^([A-Z0-9][A-Z0-9-]*)\.\s*$', line)
|
||||||
|
sec_m = re.match(r'^([A-Z][A-Z0-9-]*)\s+SECTION\.?\s*$', line, re.IGNORECASE)
|
||||||
|
if m and m.group(1) not in _COBOL_SCOPE_ENDERS:
|
||||||
|
name = m.group(1)
|
||||||
|
elif sec_m:
|
||||||
|
name = sec_m.group(1).upper()
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
start = i + 1
|
||||||
|
j = i + 1
|
||||||
|
while j < len(raw_lines):
|
||||||
|
nline = raw_lines[j].strip()
|
||||||
|
nm = re.match(r'^([A-Z0-9][A-Z0-9-]*)\.\s*$', nline)
|
||||||
|
if nm and nm.group(1) not in _COBOL_SCOPE_ENDERS:
|
||||||
|
break
|
||||||
|
if re.match(r'^[A-Z][A-Z0-9-]*\s+SECTION\.\s*$', nline, re.IGNORECASE):
|
||||||
|
break
|
||||||
|
j += 1
|
||||||
|
paragraphs[name] = (start, j - 1)
|
||||||
|
i = j
|
||||||
|
return paragraphs
|
||||||
|
|
||||||
|
|
||||||
|
def build_branch_tree(proc_text, fields=None):
|
||||||
|
raw_lines = proc_text.split('\n')
|
||||||
|
paragraphs = scan_paragraphs(raw_lines)
|
||||||
|
|
||||||
|
first_para_name = None
|
||||||
|
first_para_idx = None
|
||||||
|
for i, line in enumerate(raw_lines):
|
||||||
|
clean = line.strip()
|
||||||
|
m = re.match(r'^([A-Z0-9][A-Z0-9-]*)\.\s*$', clean)
|
||||||
|
if m and m.group(1) in paragraphs:
|
||||||
|
first_para_name = m.group(1)
|
||||||
|
first_para_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if first_para_name:
|
||||||
|
before = raw_lines[:first_para_idx]
|
||||||
|
has_code = any(
|
||||||
|
l.strip() and 'PROCEDURE DIVISION' not in l
|
||||||
|
for l in before
|
||||||
|
)
|
||||||
|
if has_code:
|
||||||
|
main_raw = raw_lines[:first_para_idx]
|
||||||
|
else:
|
||||||
|
p_start, p_end = paragraphs[first_para_name]
|
||||||
|
main_raw = raw_lines[p_start:p_end + 1]
|
||||||
|
else:
|
||||||
|
main_raw = raw_lines
|
||||||
|
|
||||||
|
filtered = [l for l in main_raw if l.strip()]
|
||||||
|
assignments = {}
|
||||||
|
parser = _BrParser(filtered, paragraphs, raw_lines, assignments, fields)
|
||||||
|
tree = parser.parse_seq(terminators={'GOBACK', 'STOP RUN', 'EXIT PROGRAM'})
|
||||||
|
return tree, assignments
|
||||||
|
|
||||||
|
|
||||||
|
# ── 定数 ──
|
||||||
|
|
||||||
|
_FIGURATIVE_CONSTANTS = frozenset({
|
||||||
|
'ZERO', 'ZEROS', 'ZEROES',
|
||||||
|
'SPACE', 'SPACES',
|
||||||
|
'HIGH-VALUE', 'HIGH-VALUES',
|
||||||
|
'LOW-VALUE', 'LOW-VALUES',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── _BrParser ──
|
||||||
|
|
||||||
|
class _BrParser:
|
||||||
|
def __init__(self, lines, paragraphs=None, raw_lines=None, assignments=None, fields=None, goto_depth=0):
|
||||||
|
self.lines = lines
|
||||||
|
self.pos = 0
|
||||||
|
self.paragraphs = paragraphs or {}
|
||||||
|
self.raw_lines = raw_lines or lines
|
||||||
|
# assignments is a dict[str, list[dict]] — append, never overwrite
|
||||||
|
self.assignments = assignments if assignments is not None else {}
|
||||||
|
self.fields = fields
|
||||||
|
self._goto_depth = goto_depth
|
||||||
|
|
||||||
|
def peek(self):
|
||||||
|
if self.pos < len(self.lines):
|
||||||
|
return self.lines[self.pos].strip()
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
return self.peek().rstrip('.').strip()
|
||||||
|
|
||||||
|
def advance(self):
|
||||||
|
self.pos += 1
|
||||||
|
|
||||||
|
def parse_seq(self, end_tokens=None, end_check=None, terminators=None):
|
||||||
|
if end_tokens is None:
|
||||||
|
end_tokens = []
|
||||||
|
seq = BrSeq()
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
line = self.clean()
|
||||||
|
if self._is_end(line, end_tokens, end_check):
|
||||||
|
return seq
|
||||||
|
if terminators and line in terminators:
|
||||||
|
self.advance()
|
||||||
|
return seq
|
||||||
|
m_goto = re.match(r'^GO\s+TO\s+(\w[\w-]*)\s*$', line)
|
||||||
|
if m_goto:
|
||||||
|
goto_node = self._parse_goto(m_goto.group(1))
|
||||||
|
if goto_node:
|
||||||
|
seq.add(goto_node)
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
cl = self.clean()
|
||||||
|
if self._is_end(cl, end_tokens, end_check):
|
||||||
|
break
|
||||||
|
if cl in _COBOL_SCOPE_ENDERS:
|
||||||
|
break
|
||||||
|
self.advance()
|
||||||
|
return seq
|
||||||
|
m_exit = re.match(r'^EXIT\s+(PARAGRAPH|PERFORM|SECTION)\s*$', line)
|
||||||
|
if m_exit:
|
||||||
|
self.advance()
|
||||||
|
seq.add(ExitNode(m_exit.group(1)))
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
cl = self.clean()
|
||||||
|
if self._is_end(cl, end_tokens, end_check):
|
||||||
|
break
|
||||||
|
if cl in _COBOL_SCOPE_ENDERS:
|
||||||
|
break
|
||||||
|
self.advance()
|
||||||
|
return seq
|
||||||
|
m = re.match(r'^IF\s+(.+?)(?:THEN)?\s*$', line)
|
||||||
|
if m:
|
||||||
|
seq.add(self._parse_if())
|
||||||
|
continue
|
||||||
|
m = re.match(r'^EVALUATE\s+(.+?)\s*$', line)
|
||||||
|
if m:
|
||||||
|
seq.add(self._parse_evaluate())
|
||||||
|
continue
|
||||||
|
m = re.match(r'^PERFORM\s+', line)
|
||||||
|
if m:
|
||||||
|
perf_node = self._parse_perform()
|
||||||
|
if perf_node:
|
||||||
|
seq.add(perf_node)
|
||||||
|
continue
|
||||||
|
m_search = re.match(r'^SEARCH\b(?:\s+(ALL))?\s+(\w[\w-]*)(?:\s+VARYING\s+(\w[\w-]*))?', line, re.IGNORECASE)
|
||||||
|
if m_search:
|
||||||
|
seq.add(self._parse_search(m_search))
|
||||||
|
continue
|
||||||
|
m = re.match(r'^INITIALIZE\s+', line)
|
||||||
|
if m:
|
||||||
|
init_seq = self._parse_initialize()
|
||||||
|
if init_seq:
|
||||||
|
seq.add(init_seq)
|
||||||
|
continue
|
||||||
|
m_str = re.match(r'^STRING\s+', line)
|
||||||
|
if m_str:
|
||||||
|
str_seq = self._parse_string()
|
||||||
|
if str_seq:
|
||||||
|
seq.add(str_seq)
|
||||||
|
continue
|
||||||
|
m_unstr = re.match(r'^UNSTRING\s+', line)
|
||||||
|
if m_unstr:
|
||||||
|
unstr_seq = self._parse_unstring()
|
||||||
|
if unstr_seq:
|
||||||
|
seq.add(unstr_seq)
|
||||||
|
continue
|
||||||
|
m = re.match(r'^CALL\s+', line)
|
||||||
|
if m:
|
||||||
|
seq.add(self._parse_call())
|
||||||
|
continue
|
||||||
|
m = re.match(
|
||||||
|
r'^ACCEPT\s+(\w[\w-]*)(?:\s+FROM\s+(DATE|TIME|DAY|DAY-OF-WEEK|YEAR|YYYYMMDD|HHMMSS))?\s*$',
|
||||||
|
line, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
tgt = m.group(1).strip().upper()
|
||||||
|
from_type = (m.group(2) or 'USER').upper()
|
||||||
|
info = {'type': 'accept', 'from': from_type}
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
seq.add(Assign(tgt, info))
|
||||||
|
self.advance()
|
||||||
|
continue
|
||||||
|
m = re.match(r'^READ\s+(\w[\w-]*)\s+INTO\s+(\w[\w-]*)\s*$', line, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
tgt = m.group(2).strip().upper()
|
||||||
|
info = {'type': 'read_into', 'file': m.group(1).strip().upper(), 'source_vars': []}
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
seq.add(Assign(tgt, info))
|
||||||
|
self.advance()
|
||||||
|
# 跳过 READ 语句剩余行(AT END / NOT AT END / END-READ)
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
cl = self.clean()
|
||||||
|
if cl in ('END-READ', 'END-READ.'):
|
||||||
|
self.advance()
|
||||||
|
break
|
||||||
|
self.advance()
|
||||||
|
continue
|
||||||
|
m_set_false = re.match(r'^SET\s+(\w[\w-]*)\s+TO\s+FALSE\s*$', line, re.IGNORECASE)
|
||||||
|
if m_set_false:
|
||||||
|
seq.add(self._parse_set_false(m_set_false.group(1)))
|
||||||
|
continue
|
||||||
|
m = re.match(r'^(?:WRITE|REWRITE)\s+(\w[\w-]*)(?:\s+FROM\s+(\w[\w-]*))?\s*$', line, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
rec_name = m.group(1).strip().upper()
|
||||||
|
if m.group(2):
|
||||||
|
tgt = m.group(2).strip().upper()
|
||||||
|
info = {'type': 'write_from', 'file': rec_name, 'source_vars': [tgt]}
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
seq.add(Assign(tgt, info))
|
||||||
|
else:
|
||||||
|
seq.add(Assign(rec_name, {'type': 'write_bare', 'file': rec_name}))
|
||||||
|
self.advance()
|
||||||
|
continue
|
||||||
|
m_set = re.match(r'^SET\s+(\w[\w-]*)\s+TO\s+TRUE\s*$', line, re.IGNORECASE)
|
||||||
|
if m_set:
|
||||||
|
seq.add(self._parse_set_true(m_set.group(1)))
|
||||||
|
continue
|
||||||
|
m_insp = re.match(r'^INSPECT\s+', line, re.IGNORECASE)
|
||||||
|
if m_insp:
|
||||||
|
info = self._parse_inspect(line)
|
||||||
|
if info:
|
||||||
|
tgt = info.get('tgt', '')
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
seq.add(Assign(tgt, info))
|
||||||
|
self.advance()
|
||||||
|
continue
|
||||||
|
assign_node = self._record_assignment(line)
|
||||||
|
if assign_node:
|
||||||
|
seq.add(assign_node)
|
||||||
|
self.advance()
|
||||||
|
return seq
|
||||||
|
|
||||||
|
def _is_end(self, line, end_tokens, end_check):
|
||||||
|
if end_check and end_check(line):
|
||||||
|
return True
|
||||||
|
for tok in end_tokens:
|
||||||
|
if line == tok or line.startswith(tok + ' '):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── INSPECT ──
|
||||||
|
|
||||||
|
_PIC_FIG_CONV = {'ZERO': '0', 'ZEROS': '0', 'ZEROES': '0',
|
||||||
|
'SPACE': ' ', 'SPACES': ' '}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _expand_figurative(val):
|
||||||
|
if val.upper() in _BrParser._PIC_FIG_CONV:
|
||||||
|
return _BrParser._PIC_FIG_CONV[val.upper()]
|
||||||
|
return val
|
||||||
|
|
||||||
|
def _parse_inspect_phrase(self, phrase):
|
||||||
|
m = re.match(
|
||||||
|
r'TALLYING\s+(\w[\w-]*)\s+FOR\s+'
|
||||||
|
r'(LEADING|TRAILING|CHARACTERS)'
|
||||||
|
r'(?:\s+([\'"])(.*?)\3)?'
|
||||||
|
r'(?:\s+(BEFORE|AFTER)\s+INITIAL\s+([\'"])(.*?)\6)?\s*$',
|
||||||
|
phrase, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
return ('tally', {
|
||||||
|
'count_var': m.group(1).upper(),
|
||||||
|
'kind': m.group(2).upper(),
|
||||||
|
'char': self._expand_figurative(m.group(4) or ''),
|
||||||
|
'before_after': (m.group(5) or '').upper(),
|
||||||
|
'delimiter': self._expand_figurative(m.group(7) or ''),
|
||||||
|
})
|
||||||
|
m = re.match(
|
||||||
|
r'REPLACING\s+'
|
||||||
|
r'(ALL|LEADING|FIRST|CHARACTERS)\s+'
|
||||||
|
r'([\'"])(.*?)\2\s+BY\s+'
|
||||||
|
r'([\'"])(.*?)\4'
|
||||||
|
r'(?:\s+(BEFORE|AFTER)\s+INITIAL\s+([\'"])(.*?)\7)?\s*$',
|
||||||
|
phrase, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
return ('replace', {
|
||||||
|
'kind': m.group(1).upper(),
|
||||||
|
'src': self._expand_figurative(m.group(3)),
|
||||||
|
'dst': self._expand_figurative(m.group(5)),
|
||||||
|
'before_after': (m.group(6) or '').upper(),
|
||||||
|
'delimiter': self._expand_figurative(m.group(8) or ''),
|
||||||
|
})
|
||||||
|
m = re.match(
|
||||||
|
r'CONVERTING\s+([\'"])(.*?)\1\s+TO\s+([\'"])(.*?)\3\s*$',
|
||||||
|
phrase, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
return ('convert', {
|
||||||
|
'from_chars': self._expand_figurative(m.group(2)),
|
||||||
|
'to_chars': self._expand_figurative(m.group(4)),
|
||||||
|
})
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_inspect(self, line):
|
||||||
|
m = re.match(r'^INSPECT\s+(\w[\w-]*)\s+(.+)$', line, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
tgt = m.group(1).upper()
|
||||||
|
rest = m.group(2).strip()
|
||||||
|
phrases = re.split(r'\s+(?=(?:TALLYING|REPLACING|CONVERTING)\b)', rest, flags=re.IGNORECASE)
|
||||||
|
sub_ops = []
|
||||||
|
for phrase in phrases:
|
||||||
|
sub = self._parse_inspect_phrase(phrase.strip())
|
||||||
|
if sub:
|
||||||
|
sub_ops.append(sub)
|
||||||
|
if not sub_ops:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'type': 'inspect',
|
||||||
|
'tgt': tgt,
|
||||||
|
'source_vars': [tgt],
|
||||||
|
'sub_ops': sub_ops,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _record_assignment(self, line):
|
||||||
|
if self.assignments is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# MOVE
|
||||||
|
m = re.match(r'^MOVE\s+(.+?)\s+TO\s+(.+?)\s*$', line)
|
||||||
|
if m:
|
||||||
|
raw_src = m.group(1).strip()
|
||||||
|
tgt = m.group(2).strip()
|
||||||
|
# 保留下标:WS-CODE-VAL(1) → key='WS-CODE-VAL(1)'
|
||||||
|
m_tgt = re.match(r'^([A-Z][A-Z0-9-]*)(?:\s*\(([^)]*)\))?\s*$', tgt, re.IGNORECASE)
|
||||||
|
if not m_tgt:
|
||||||
|
return None
|
||||||
|
tgt_base = m_tgt.group(1).upper()
|
||||||
|
if m_tgt.group(2):
|
||||||
|
subscript = re.sub(r'\s*', '', m_tgt.group(2))
|
||||||
|
tgt_key = f"{tgt_base}({subscript})"
|
||||||
|
else:
|
||||||
|
tgt_key = tgt_base
|
||||||
|
src_clean = raw_src.strip("'").strip('"')
|
||||||
|
is_field_name = self.fields and any(f['name'] == src_clean for f in self.fields)
|
||||||
|
if is_field_name:
|
||||||
|
info = {'type': 'move', 'source_vars': [src_clean]}
|
||||||
|
else:
|
||||||
|
info = {'type': 'move_literal', 'literal': src_clean}
|
||||||
|
self.assignments.setdefault(tgt_key, []).append(info)
|
||||||
|
return Assign(tgt_key, info)
|
||||||
|
|
||||||
|
# COMPUTE
|
||||||
|
m = re.match(r'^COMPUTE\s+(.+?)(?:\s+ROUNDED)?\s*=\s*(.*)$', line)
|
||||||
|
if m:
|
||||||
|
tgt_raw = m.group(1).strip()
|
||||||
|
expr = m.group(2).strip()
|
||||||
|
m_tgt = re.match(r'^([A-Z][A-Z0-9-]*)(?:\s*\(([^)]*)\))?\s*$', tgt_raw, re.IGNORECASE)
|
||||||
|
tgt_key = tgt_raw
|
||||||
|
if m_tgt:
|
||||||
|
tgt_base = m_tgt.group(1).upper()
|
||||||
|
if m_tgt.group(2):
|
||||||
|
subscript = re.sub(r'\s*', '', m_tgt.group(2))
|
||||||
|
tgt_key = f"{tgt_base}({subscript})"
|
||||||
|
else:
|
||||||
|
tgt_key = tgt_base
|
||||||
|
if not expr:
|
||||||
|
peek_pos = self.pos + 1
|
||||||
|
if peek_pos < len(self.lines):
|
||||||
|
nxt = self.lines[peek_pos].strip().rstrip('.').strip()
|
||||||
|
if nxt and not re.match(r'^(PERFORM|END-|IF|ELSE|EVALUATE|WHEN|OTHER|MOVE|COMPUTE|ADD|SUBTRACT|MULTIPLY|DIVIDE|STRING|UNSTRING|READ|WRITE|INITIALIZE|ACCEPT|CALL|GO\s*TO|GOBACK|STOP|EXIT)', nxt, re.IGNORECASE):
|
||||||
|
expr = nxt
|
||||||
|
if expr:
|
||||||
|
info = self._parse_compute_expr(tgt_key, expr)
|
||||||
|
self.assignments.setdefault(tgt_key, []).append(info)
|
||||||
|
return Assign(tgt_key, info)
|
||||||
|
|
||||||
|
# ADD x TO y → y = y + x (支持变量和常量源)
|
||||||
|
m = re.match(r'^ADD\s+(\w[\w-]*)\s+TO\s+(\w[\w-]*?)(?:\s+ROUNDED)?\s*$', line)
|
||||||
|
if m:
|
||||||
|
src = m.group(1).strip()
|
||||||
|
tgt = m.group(2).strip()
|
||||||
|
is_field = self.fields and any(f['name'] == src for f in self.fields)
|
||||||
|
if is_field:
|
||||||
|
info = {'type': 'compute', 'source_vars': [tgt, src],
|
||||||
|
'op': '+', 'const': None, 'expr': f'{tgt} + {src}'}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
const = float(src)
|
||||||
|
info = {'type': 'compute', 'source_vars': [tgt],
|
||||||
|
'op': '+', 'const': const, 'expr': f'{tgt} + {const}'}
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
return Assign(tgt, info)
|
||||||
|
|
||||||
|
# ADD x TO y GIVING z → z = y + x
|
||||||
|
m = re.match(r'^ADD\s+(.+?)\s+TO\s+(\w[\w-]*)\s+GIVING\s+(\w[\w-]*?)(?:\s+ROUNDED)?\s*$', line, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
raw_a = m.group(1).strip()
|
||||||
|
src_b = m.group(2).strip()
|
||||||
|
tgt = m.group(3).strip()
|
||||||
|
is_field_a = self.fields and any(f['name'] == raw_a for f in self.fields)
|
||||||
|
if is_field_a:
|
||||||
|
info = {'type': 'compute', 'source_vars': [src_b, raw_a],
|
||||||
|
'op': '+', 'const': None, 'expr': f'{src_b} + {raw_a}'}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
const = float(raw_a)
|
||||||
|
info = {'type': 'compute', 'source_vars': [src_b],
|
||||||
|
'op': '+', 'const': const, 'expr': f'{src_b} + {const}'}
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
return Assign(tgt, info)
|
||||||
|
|
||||||
|
# ADD a[, b[, c...]] GIVING z → z = a + b + c + ...
|
||||||
|
m = re.match(r'^ADD\s+(.+?)\s+GIVING\s+(\w[\w-]*?)(?:\s+ROUNDED)?\s*$', line, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
raw_parts = re.findall(r'[A-Z][A-Z0-9-]*|\d+(?:\.\d+)?', m.group(1).upper())
|
||||||
|
fields_only = []
|
||||||
|
const_sum = 0.0
|
||||||
|
for p in raw_parts:
|
||||||
|
if self.fields and any(f['name'] == p for f in self.fields):
|
||||||
|
fields_only.append(p)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
const_sum += float(p)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
tgt = m.group(2).strip()
|
||||||
|
if not fields_only:
|
||||||
|
info = {'type': 'move_literal',
|
||||||
|
'literal': str(int(const_sum)) if const_sum == int(const_sum) else str(const_sum)}
|
||||||
|
else:
|
||||||
|
info = {'type': 'compute', 'source_vars': fields_only,
|
||||||
|
'op': '+', 'const': const_sum if const_sum != 0 else None,
|
||||||
|
'expr': '+'.join(fields_only) + (f' + {const_sum}' if const_sum else '')}
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
return Assign(tgt, info)
|
||||||
|
|
||||||
|
# SUBTRACT x FROM y → y = y - x
|
||||||
|
m = re.match(r'^SUBTRACT\s+([\d.]+)\s+FROM\s+(\w[\w-]*?)(?:\s+ROUNDED)?\s*$', line)
|
||||||
|
if m:
|
||||||
|
const = float(m.group(1))
|
||||||
|
tgt = m.group(2).strip()
|
||||||
|
info = {'type': 'compute', 'source_vars': [tgt],
|
||||||
|
'op': '-', 'const': const, 'expr': f'{tgt} - {const}'}
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
return Assign(tgt, info)
|
||||||
|
|
||||||
|
# SUBTRACT a FROM b GIVING z → z = b - a
|
||||||
|
m = re.match(r'^SUBTRACT\s+([\d.\w-]*)\s+FROM\s+(\w[\w-]*)\s+GIVING\s+(\w[\w-]*?)(?:\s+ROUNDED)?\s*$', line, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
raw_a = m.group(1).strip()
|
||||||
|
src_b = m.group(2).strip()
|
||||||
|
tgt = m.group(3).strip()
|
||||||
|
is_field_a = self.fields and any(f['name'] == raw_a for f in self.fields)
|
||||||
|
if is_field_a:
|
||||||
|
info = {'type': 'compute', 'source_vars': [src_b, raw_a],
|
||||||
|
'op': '-', 'const': None, 'expr': f'{src_b} - {raw_a}'}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
const = float(raw_a)
|
||||||
|
info = {'type': 'compute', 'source_vars': [src_b],
|
||||||
|
'op': '-', 'const': const, 'expr': f'{src_b} - {const}'}
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
return Assign(tgt, info)
|
||||||
|
|
||||||
|
# MULTIPLY x BY y → y = y * x
|
||||||
|
m = re.match(r'^MULTIPLY\s+([\d.]+)\s+BY\s+(\w[\w-]*?)(?:\s+ROUNDED)?\s*$', line)
|
||||||
|
if m:
|
||||||
|
const = float(m.group(1))
|
||||||
|
tgt = m.group(2).strip()
|
||||||
|
info = {'type': 'compute', 'source_vars': [tgt],
|
||||||
|
'op': '*', 'const': const, 'expr': f'{tgt} * {const}'}
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
return Assign(tgt, info)
|
||||||
|
|
||||||
|
# MULTIPLY a BY b GIVING z → z = a * b
|
||||||
|
m = re.match(r'^MULTIPLY\s+(\w[\w-]*)\s+BY\s+(\w[\w-]*)\s+GIVING\s+(\w[\w-]*?)(?:\s+ROUNDED)?\s*$', line, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
src_a = m.group(1).strip()
|
||||||
|
src_b = m.group(2).strip()
|
||||||
|
tgt = m.group(3).strip()
|
||||||
|
is_field_a = self.fields and any(f['name'] == src_a for f in self.fields)
|
||||||
|
if is_field_a:
|
||||||
|
info = {'type': 'compute', 'source_vars': [src_a, src_b],
|
||||||
|
'op': '*', 'const': None, 'expr': f'{src_a} * {src_b}'}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
const = float(src_a)
|
||||||
|
info = {'type': 'compute', 'source_vars': [src_b],
|
||||||
|
'op': '*', 'const': const, 'expr': f'{const} * {src_b}'}
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
return Assign(tgt, info)
|
||||||
|
|
||||||
|
# DIVIDE x INTO y → y = y / x
|
||||||
|
m = re.match(r'^DIVIDE\s+([\d.]+)\s+INTO\s+(\w[\w-]*?)(?:\s+ROUNDED)?\s*$', line)
|
||||||
|
if m:
|
||||||
|
const = float(m.group(1))
|
||||||
|
tgt = m.group(2).strip()
|
||||||
|
info = {'type': 'compute', 'source_vars': [tgt],
|
||||||
|
'op': '/', 'const': const, 'expr': f'{tgt} / {const}'}
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
return Assign(tgt, info)
|
||||||
|
|
||||||
|
# DIVIDE a INTO b GIVING z → z = b / a
|
||||||
|
# Optional REMAINDER r → r = b - (b / a) * a
|
||||||
|
m = re.match(r'^DIVIDE\s+(.+?)\s+INTO\s+(\w[\w-]*)\s+GIVING\s+(\w[\w-]*?)(?:\s+ROUNDED)?(?:\s+REMAINDER\s+(\w[\w-]*))?\s*$', line, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
raw_a = m.group(1).strip()
|
||||||
|
src_b = m.group(2).strip()
|
||||||
|
tgt = m.group(3).strip()
|
||||||
|
rem_tgt = m.group(4).strip().upper() if m.group(4) else None
|
||||||
|
is_field_a = self.fields and any(f['name'] == raw_a for f in self.fields)
|
||||||
|
if is_field_a:
|
||||||
|
info = {'type': 'compute', 'source_vars': [src_b, raw_a],
|
||||||
|
'op': '/', 'const': None, 'expr': f'{src_b} / {raw_a}'}
|
||||||
|
rem_info = {'type': 'compute', 'source_vars': [src_b, raw_a],
|
||||||
|
'op': 'rem', 'const': None, 'expr': f'REM({src_b} / {raw_a})'}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
const = float(raw_a)
|
||||||
|
info = {'type': 'compute', 'source_vars': [src_b],
|
||||||
|
'op': '/', 'const': const, 'expr': f'{src_b} / {const}'}
|
||||||
|
rem_info = {'type': 'compute', 'source_vars': [src_b],
|
||||||
|
'op': 'rem', 'const': const, 'expr': f'REM({src_b} / {const})'}
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
seq = BrSeq()
|
||||||
|
seq.add(Assign(tgt, info))
|
||||||
|
if rem_tgt:
|
||||||
|
self.assignments.setdefault(rem_tgt, []).append(rem_info)
|
||||||
|
seq.add(Assign(rem_tgt, rem_info))
|
||||||
|
return seq
|
||||||
|
|
||||||
|
# DIVIDE a BY b GIVING z → z = a / b
|
||||||
|
# Optional REMAINDER r → r = a - (a / b) * b
|
||||||
|
m = re.match(r'^DIVIDE\s+(\w[\w-]*)\s+BY\s+(\w[\w-]*)\s+GIVING\s+(\w[\w-]*?)(?:\s+ROUNDED)?(?:\s+REMAINDER\s+(\w[\w-]*))?\s*$', line, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
src_a = m.group(1).strip()
|
||||||
|
src_b = m.group(2).strip()
|
||||||
|
tgt = m.group(3).strip()
|
||||||
|
rem_tgt = m.group(4).strip().upper() if m.group(4) else None
|
||||||
|
info = {'type': 'compute', 'source_vars': [src_a, src_b],
|
||||||
|
'op': '/', 'const': None, 'expr': f'{src_a} / {src_b}'}
|
||||||
|
rem_info = {'type': 'compute', 'source_vars': [src_a, src_b],
|
||||||
|
'op': 'rem', 'const': None, 'expr': f'REM({src_a} / {src_b})'}
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
seq = BrSeq()
|
||||||
|
seq.add(Assign(tgt, info))
|
||||||
|
if rem_tgt:
|
||||||
|
self.assignments.setdefault(rem_tgt, []).append(rem_info)
|
||||||
|
seq.add(Assign(rem_tgt, rem_info))
|
||||||
|
return seq
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_compute_expr(self, target, expr):
|
||||||
|
# const OP var
|
||||||
|
m = re.match(r'^\s*([\d.]+)\s*([+\-*/])\s*(\w[\w-]*)\s*$', expr)
|
||||||
|
if m:
|
||||||
|
const, op, var = float(m.group(1)), m.group(2), m.group(3)
|
||||||
|
return {'type': 'compute', 'source_vars': [var], 'op': op, 'const': const, 'expr': expr}
|
||||||
|
# var OP const
|
||||||
|
m = re.match(r'^\s*(\w[\w-]*)\s*([+\-*/])\s*([\d.]+)\s*$', expr)
|
||||||
|
if m:
|
||||||
|
var, op, const = m.group(1), m.group(2), float(m.group(3))
|
||||||
|
return {'type': 'compute', 'source_vars': [var], 'op': op, 'const': const, 'expr': expr}
|
||||||
|
# var OP var
|
||||||
|
m = re.match(r'^\s*(\w[\w-]*)\s*([+\-*/])\s*(\w[\w-]*)\s*$', expr)
|
||||||
|
if m:
|
||||||
|
var1, op, var2 = m.group(1), m.group(2), m.group(3)
|
||||||
|
return {'type': 'compute', 'source_vars': [var1, var2], 'op': op, 'expr': expr}
|
||||||
|
# complex expression — extract variable names only
|
||||||
|
vars_in = re.findall(r'[A-Z][A-Z0-9-]*', expr.upper())
|
||||||
|
return {'type': 'compute', 'source_vars': list(set(vars_in)), 'op': None, 'const': None, 'expr': expr}
|
||||||
|
|
||||||
|
# ── SEARCH / SEARCH ALL ──
|
||||||
|
|
||||||
|
def _parse_search(self, m):
|
||||||
|
is_all = bool(m.group(1))
|
||||||
|
table = m.group(2).upper()
|
||||||
|
varying = m.group(3).upper() if m.group(3) else None
|
||||||
|
node = BrSearch(table, is_all=is_all, varying=varying)
|
||||||
|
self.advance()
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
line = self.clean()
|
||||||
|
if line in ('END-SEARCH', 'END-SEARCH.'):
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
m_at = re.match(r'^AT\s+END(.+)?$', line, re.IGNORECASE)
|
||||||
|
if m_at:
|
||||||
|
self.advance()
|
||||||
|
rest = m_at.group(1)
|
||||||
|
if rest and rest.strip():
|
||||||
|
self.lines.insert(self.pos, rest.strip())
|
||||||
|
node.at_end_seq = self.parse_seq(
|
||||||
|
end_check=lambda l: re.match(r'^WHEN\b', l) or l in ('END-SEARCH',)
|
||||||
|
)
|
||||||
|
node.has_at_end = True
|
||||||
|
continue
|
||||||
|
m_when = re.match(r'^WHEN\s+(.+?)\s*$', line, re.IGNORECASE)
|
||||||
|
if m_when:
|
||||||
|
cond_upper = m_when.group(1).strip()
|
||||||
|
self.advance()
|
||||||
|
cond_tree = parse_compound_condition(cond_upper, self.fields)
|
||||||
|
body_seq = self.parse_seq(
|
||||||
|
end_check=lambda l: re.match(r'^(WHEN|AT\s+END)\b', l) or l in ('END-SEARCH',)
|
||||||
|
)
|
||||||
|
node.when_list.append((cond_upper, body_seq))
|
||||||
|
node.cond_trees.append(cond_tree)
|
||||||
|
continue
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _parse_if(self):
|
||||||
|
line = self.clean()
|
||||||
|
m = re.match(r'^IF\s+(.+?)(?:THEN)?\s*$', line)
|
||||||
|
cond_text = m.group(1).strip()
|
||||||
|
self.advance()
|
||||||
|
# Join continuation lines (multi-line IF conditions)
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
peek = self.clean()
|
||||||
|
if re.match(r'^(THEN|ELSE|END-IF|MOVE|IF|PERFORM|EVALUATE|COMPUTE|CALL|STRING|UNSTRING|INITIALIZE|ADD|SUBTRACT|MULTIPLY|DIVIDE|GO\b|EXIT\b)', peek, re.IGNORECASE):
|
||||||
|
break
|
||||||
|
if peek.endswith('.'):
|
||||||
|
cond_text += ' ' + peek.rstrip('.')
|
||||||
|
self.advance()
|
||||||
|
break
|
||||||
|
cond_text += ' ' + peek
|
||||||
|
self.advance()
|
||||||
|
# Consume optional THEN on its own line
|
||||||
|
if self.pos < len(self.lines):
|
||||||
|
peek = self.clean()
|
||||||
|
if peek == 'THEN':
|
||||||
|
self.advance()
|
||||||
|
node = BrIf(cond_text)
|
||||||
|
node.cond_tree = parse_compound_condition(node.condition, self.fields)
|
||||||
|
node.true_seq = self.parse_seq(['ELSE', 'END-IF'])
|
||||||
|
if self.clean() == 'ELSE':
|
||||||
|
self.advance()
|
||||||
|
node.false_seq = self.parse_seq(['END-IF'])
|
||||||
|
if self.clean() == 'END-IF':
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _parse_evaluate(self):
|
||||||
|
line = self.clean()
|
||||||
|
m = re.match(r'^EVALUATE\s+(.+?)\s*$', line)
|
||||||
|
raw_subject = m.group(1).strip()
|
||||||
|
node = BrEval(raw_subject)
|
||||||
|
if ' ALSO ' in raw_subject:
|
||||||
|
node.subjects = [s.strip() for s in re.split(r'\s+ALSO\s+', raw_subject)]
|
||||||
|
self.advance()
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
line = self.clean()
|
||||||
|
if line == 'END-EVALUATE':
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
m = re.match(r'^WHEN\s+(.+?)\s*$', line)
|
||||||
|
if m:
|
||||||
|
raw_val = m.group(1).strip().strip("'").strip('"')
|
||||||
|
self.advance()
|
||||||
|
# Capture multi-line WHEN conditions (AND/OR continuation)
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
peek = self.clean()
|
||||||
|
if re.match(r'^(?:AND|OR)\b', peek, re.IGNORECASE):
|
||||||
|
raw_val += ' ' + peek
|
||||||
|
self.advance()
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if raw_val == 'OTHER':
|
||||||
|
node.other_seq = self.parse_seq(end_check=lambda l: l == 'END-EVALUATE')
|
||||||
|
node.has_other = True
|
||||||
|
else:
|
||||||
|
case_seq = self.parse_seq(end_check=lambda l: l.startswith('WHEN') or l == 'END-EVALUATE')
|
||||||
|
if node.subjects:
|
||||||
|
vals = [v.strip().strip("'").strip('"')
|
||||||
|
for v in re.split(r'\s+ALSO\s+', raw_val)]
|
||||||
|
node.when_list.append((vals, case_seq))
|
||||||
|
else:
|
||||||
|
node.when_list.append((raw_val, case_seq))
|
||||||
|
continue
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _parse_perform(self):
|
||||||
|
line = self.clean()
|
||||||
|
|
||||||
|
m = re.match(r'^PERFORM\s+UNTIL\s+(.+?)\s*$', line)
|
||||||
|
if m:
|
||||||
|
node = BrPerform('until', condition=m.group(1).strip())
|
||||||
|
self.advance()
|
||||||
|
node.body_seq = self.parse_seq(end_check=lambda l: l == 'END-PERFORM')
|
||||||
|
if self.clean() == 'END-PERFORM':
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
|
||||||
|
m = re.match(r'^PERFORM\s+(\w[\w-]*)\s+UNTIL\s+(.+?)\s*$', line)
|
||||||
|
if m:
|
||||||
|
target = m.group(1).strip()
|
||||||
|
node = BrPerform('para_until', target=target, condition=m.group(2).strip())
|
||||||
|
self.advance()
|
||||||
|
self._inline_perform(node, target)
|
||||||
|
return node
|
||||||
|
|
||||||
|
m = re.match(r'^PERFORM\s+(\d+)\s+TIMES\s*$', line)
|
||||||
|
if m:
|
||||||
|
node = BrPerform('times', times=int(m.group(1)))
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
|
||||||
|
m = re.match(r'^PERFORM\s+(\w[\w-]*)\s+THRU\s+(\w[\w-]*)\s*$', line)
|
||||||
|
if m:
|
||||||
|
node = BrPerform('thru', target=m.group(1).strip(), thru=m.group(2).strip())
|
||||||
|
self.advance()
|
||||||
|
self._inline_perform(node, node.target, node.thru)
|
||||||
|
return node
|
||||||
|
|
||||||
|
m = re.match(r'^PERFORM\s+VARYING\s+(\w[\w-]*)\s+FROM\s+(\S+)\s+BY\s+(\S+)(?:\s+UNTIL\s+(.+))?\s*$', line)
|
||||||
|
if m:
|
||||||
|
varying_var = m.group(1).strip()
|
||||||
|
from_val = m.group(2).strip()
|
||||||
|
by_val = m.group(3).strip()
|
||||||
|
condition = m.group(4).strip() if m.group(4) else None
|
||||||
|
if not condition:
|
||||||
|
save_pos = self.pos
|
||||||
|
self.advance()
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
nxt = self.clean()
|
||||||
|
cm = re.match(r'^UNTIL\s+(.+)$', nxt)
|
||||||
|
if cm:
|
||||||
|
condition = cm.group(1).strip()
|
||||||
|
self.advance()
|
||||||
|
break
|
||||||
|
fm = re.match(r'^FROM\s+(\S+)\s+BY\s+(\S+)$', nxt)
|
||||||
|
if fm:
|
||||||
|
from_val = fm.group(1).strip()
|
||||||
|
by_val = fm.group(2).strip()
|
||||||
|
self.advance()
|
||||||
|
continue
|
||||||
|
self.pos = save_pos
|
||||||
|
break
|
||||||
|
if condition:
|
||||||
|
node = BrPerform('varying', condition=condition,
|
||||||
|
varying_var=varying_var,
|
||||||
|
varying_from=from_val,
|
||||||
|
varying_by=by_val)
|
||||||
|
# condition from regex (single-line) → advance past PERFORM line
|
||||||
|
# condition from while-loop (multi-line) → already advanced past FROM/BY/UNTIL
|
||||||
|
if m.group(4):
|
||||||
|
self.advance()
|
||||||
|
node.body_seq = self.parse_seq(end_check=lambda l: l == 'END-PERFORM')
|
||||||
|
if self.clean() == 'END-PERFORM':
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
self.pos = save_pos
|
||||||
|
# PERFORM VARYING var — FROM/BY/UNTIL all on subsequent lines
|
||||||
|
m = re.match(r'^PERFORM\s+VARYING\s+(\w[\w-]*)\s*$', line)
|
||||||
|
if m:
|
||||||
|
varying_var = m.group(1).strip()
|
||||||
|
save_pos = self.pos
|
||||||
|
self.advance()
|
||||||
|
from_val = by_val = condition = None
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
nxt = self.clean()
|
||||||
|
fm = re.match(r'^FROM\s+(\S+)\s+BY\s+(\S+)$', nxt)
|
||||||
|
if fm:
|
||||||
|
from_val, by_val = fm.group(1).strip(), fm.group(2).strip()
|
||||||
|
self.advance()
|
||||||
|
continue
|
||||||
|
um = re.match(r'^UNTIL\s+(.+)$', nxt)
|
||||||
|
if um:
|
||||||
|
condition = um.group(1).strip()
|
||||||
|
self.advance()
|
||||||
|
break
|
||||||
|
break
|
||||||
|
if from_val and by_val and condition:
|
||||||
|
node = BrPerform('varying', condition=condition,
|
||||||
|
varying_var=varying_var,
|
||||||
|
varying_from=from_val,
|
||||||
|
varying_by=by_val)
|
||||||
|
node.body_seq = self.parse_seq(end_check=lambda l: l == 'END-PERFORM')
|
||||||
|
if self.clean() == 'END-PERFORM':
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
self.pos = save_pos
|
||||||
|
|
||||||
|
m = re.match(r'^PERFORM\s+(\w[\w-]*)\s+VARYING\s+(\w[\w-]*)\s+FROM\s+(\S+)\s+BY\s+(\S+)(?:\s+UNTIL\s+(.+))?\s*$', line)
|
||||||
|
if m:
|
||||||
|
target = m.group(1).strip()
|
||||||
|
varying_var = m.group(2).strip()
|
||||||
|
from_val = m.group(3).strip()
|
||||||
|
by_val = m.group(4).strip()
|
||||||
|
condition = m.group(5).strip() if m.group(5) else None
|
||||||
|
if not condition:
|
||||||
|
save_pos = self.pos
|
||||||
|
self.advance()
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
nxt = self.clean()
|
||||||
|
cm = re.match(r'^UNTIL\s+(.+)$', nxt)
|
||||||
|
if cm:
|
||||||
|
condition = cm.group(1).strip()
|
||||||
|
self.advance()
|
||||||
|
break
|
||||||
|
self.pos = save_pos
|
||||||
|
break
|
||||||
|
if condition:
|
||||||
|
node = BrPerform('para_varying', target=target,
|
||||||
|
condition=condition,
|
||||||
|
varying_var=varying_var,
|
||||||
|
varying_from=from_val,
|
||||||
|
varying_by=by_val)
|
||||||
|
self.advance()
|
||||||
|
self._inline_perform(node, node.target)
|
||||||
|
return node
|
||||||
|
self.pos = save_pos
|
||||||
|
|
||||||
|
m = re.match(r'^PERFORM\s+(\w[\w-]*)\s*$', line)
|
||||||
|
if m:
|
||||||
|
target = m.group(1).strip()
|
||||||
|
node = BrPerform('para', target=target)
|
||||||
|
self.advance()
|
||||||
|
self._inline_perform(node, target)
|
||||||
|
return node
|
||||||
|
|
||||||
|
self.advance()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _inline_perform(self, node, target, thru=None):
|
||||||
|
if thru:
|
||||||
|
if target in self.paragraphs and thru in self.paragraphs:
|
||||||
|
start = self.paragraphs[target][0]
|
||||||
|
end = self.paragraphs[thru][1]
|
||||||
|
all_lines = []
|
||||||
|
for name, (s, e) in self.paragraphs.items():
|
||||||
|
if s >= start and e <= end:
|
||||||
|
all_lines.extend(self.raw_lines[s:e + 1])
|
||||||
|
sub = _BrParser(
|
||||||
|
[l for l in all_lines if l.strip()],
|
||||||
|
self.paragraphs, self.raw_lines, self.assignments, self.fields
|
||||||
|
)
|
||||||
|
node.body_seq = sub.parse_seq()
|
||||||
|
elif target in self.paragraphs:
|
||||||
|
start, end = self.paragraphs[target]
|
||||||
|
para_lines = self.raw_lines[start:end + 1]
|
||||||
|
sub = _BrParser(
|
||||||
|
[l for l in para_lines if l.strip()],
|
||||||
|
self.paragraphs, self.raw_lines, self.assignments, self.fields
|
||||||
|
)
|
||||||
|
node.body_seq = sub.parse_seq()
|
||||||
|
|
||||||
|
def _parse_initialize(self):
|
||||||
|
line = self.clean()
|
||||||
|
m = re.match(r'^INITIALIZE\s+(.+?)\s*$', line)
|
||||||
|
if not m:
|
||||||
|
self.advance()
|
||||||
|
return None
|
||||||
|
rest = m.group(1).strip()
|
||||||
|
|
||||||
|
# Split off REPLACING clause
|
||||||
|
parts = re.split(r'\s+REPLACING\s+', rest, maxsplit=1, flags=re.IGNORECASE)
|
||||||
|
target_str = parts[0].strip()
|
||||||
|
targets = re.findall(r'[A-Z][A-Z0-9-]*', target_str)
|
||||||
|
|
||||||
|
# Parse REPLACING: (NUMERIC|ALPHANUMERIC|ALPHABETIC) DATA BY literal
|
||||||
|
replacing = {}
|
||||||
|
if len(parts) > 1:
|
||||||
|
pairs = re.findall(
|
||||||
|
r'(NUMERIC|ALPHANUMERIC-EDITED|NUMERIC-EDITED|ALPHANUMERIC|ALPHABETIC)\s+DATA\s+BY\s+(\S+)',
|
||||||
|
parts[1], re.IGNORECASE
|
||||||
|
)
|
||||||
|
for ptype, literal in pairs:
|
||||||
|
replacing[ptype.upper()] = literal.strip("'").strip('"')
|
||||||
|
|
||||||
|
seq = BrSeq()
|
||||||
|
for tgt in targets:
|
||||||
|
info = {'type': 'initialize'}
|
||||||
|
if replacing:
|
||||||
|
info['replacing'] = replacing
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
seq.add(Assign(tgt, info))
|
||||||
|
self.advance()
|
||||||
|
return seq
|
||||||
|
|
||||||
|
def _parse_string(self):
|
||||||
|
parts = [self.clean()]
|
||||||
|
self.advance()
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
cl = self.clean()
|
||||||
|
if cl == 'END-STRING':
|
||||||
|
self.advance()
|
||||||
|
break
|
||||||
|
parts.append(cl)
|
||||||
|
self.advance()
|
||||||
|
full = ' '.join(parts)
|
||||||
|
m = re.match(r'^STRING\s+(.+)\s+INTO\s+(\w[\w-]*)\s*$', full, re.IGNORECASE | re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
source_part = m.group(1).strip()
|
||||||
|
target = m.group(2).strip()
|
||||||
|
source_vars = re.findall(r'[A-Z][A-Z0-9-]*', source_part)
|
||||||
|
info = {'type': 'string_concat', 'source_vars': source_vars}
|
||||||
|
self.assignments.setdefault(target, []).append(info)
|
||||||
|
seq = BrSeq()
|
||||||
|
seq.add(Assign(target, info))
|
||||||
|
return seq
|
||||||
|
|
||||||
|
def _parse_unstring(self):
|
||||||
|
parts = [self.clean()]
|
||||||
|
self.advance()
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
cl = self.clean()
|
||||||
|
if cl == 'END-UNSTRING':
|
||||||
|
self.advance()
|
||||||
|
break
|
||||||
|
parts.append(cl)
|
||||||
|
self.advance()
|
||||||
|
full = ' '.join(parts)
|
||||||
|
m = re.match(r'^UNSTRING\s+(.+?)\s+INTO\s+(.+?)\s*$', full, re.IGNORECASE | re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
source_part = m.group(1).strip()
|
||||||
|
targets_part = m.group(2).strip()
|
||||||
|
source_vars = re.findall(r'[A-Z][A-Z0-9-]*', source_part)
|
||||||
|
targets = re.findall(r'[A-Z][A-Z0-9-]*', targets_part)
|
||||||
|
source_var = source_vars[0] if source_vars else ''
|
||||||
|
seq = BrSeq()
|
||||||
|
for tgt in targets:
|
||||||
|
info = {'type': 'unstring_split', 'source_vars': [source_var], 'index': targets.index(tgt)}
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
seq.add(Assign(tgt, info))
|
||||||
|
return seq
|
||||||
|
|
||||||
|
def _parse_call(self):
|
||||||
|
line = self.clean()
|
||||||
|
m = re.match(r'^CALL\s+(\S+?)(?:\s+USING\s+(.+))?\s*$', line)
|
||||||
|
if not m:
|
||||||
|
self.advance()
|
||||||
|
return BrSeq()
|
||||||
|
prog = m.group(1).strip("'\"").upper()
|
||||||
|
params = []
|
||||||
|
if m.group(2):
|
||||||
|
rest = m.group(2)
|
||||||
|
# 逐 segment 解析: BY mechanism names...
|
||||||
|
current = "reference" # COBOL 默认 BY REFERENCE
|
||||||
|
for seg in re.split(r'\s+(?=BY\s+(?:REFERENCE|CONTENT|VALUE)\s+)',
|
||||||
|
rest, flags=re.IGNORECASE):
|
||||||
|
seg = seg.strip()
|
||||||
|
m_mech = re.match(
|
||||||
|
r'BY\s+(REFERENCE|CONTENT|VALUE)\s+(.*)', seg, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if m_mech:
|
||||||
|
current = m_mech.group(1).lower()
|
||||||
|
names_text = m_mech.group(2)
|
||||||
|
else:
|
||||||
|
names_text = seg
|
||||||
|
for nm in re.findall(r'\w[\w-]*', names_text):
|
||||||
|
params.append({"name": nm.upper(), "mechanism": current})
|
||||||
|
node = CallNode(prog, using_params=params)
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _parse_goto(self, target):
|
||||||
|
node = GoTo(target)
|
||||||
|
if self._goto_depth < 10 and target in self.paragraphs:
|
||||||
|
start, end = self.paragraphs[target]
|
||||||
|
para_lines = self.raw_lines[start:end + 1]
|
||||||
|
sub = _BrParser(
|
||||||
|
[l for l in para_lines if l.strip()],
|
||||||
|
self.paragraphs, self.raw_lines, self.assignments, self.fields,
|
||||||
|
goto_depth=self._goto_depth + 1
|
||||||
|
)
|
||||||
|
node.body_seq = sub.parse_seq()
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _parse_set_true(self, name):
|
||||||
|
name = name.upper()
|
||||||
|
parent = None
|
||||||
|
value = None
|
||||||
|
if self.fields:
|
||||||
|
for f in self.fields:
|
||||||
|
if f.get('is_88') and f['name'] == name:
|
||||||
|
parent = f.get('parent', '')
|
||||||
|
value = f.get('value', '')
|
||||||
|
break
|
||||||
|
info = {'type': 'set_true', '88_name': name, 'value': value}
|
||||||
|
tgt = parent or name
|
||||||
|
if parent:
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
self.advance()
|
||||||
|
return Assign(tgt, info)
|
||||||
|
|
||||||
|
def _parse_set_false(self, name):
|
||||||
|
name = name.upper()
|
||||||
|
parent = None
|
||||||
|
value = None
|
||||||
|
if self.fields:
|
||||||
|
for f in self.fields:
|
||||||
|
if f.get('is_88') and f['name'] == name:
|
||||||
|
parent = f.get('parent', '')
|
||||||
|
value = f.get('value', '')
|
||||||
|
break
|
||||||
|
# FALSE 值 = 88-level VALUE 的反值
|
||||||
|
if value:
|
||||||
|
false_val = 'N' if value == 'Y' else ('Y' if value == 'N' else ' ')
|
||||||
|
else:
|
||||||
|
false_val = 'N'
|
||||||
|
info = {'type': 'move_literal', 'literal': false_val}
|
||||||
|
tgt = parent or name
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
self.advance()
|
||||||
|
return Assign(tgt, info)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 工具函数 ──
|
||||||
|
|
||||||
|
|
||||||
|
def _basename(name: str) -> str:
|
||||||
|
"""去除下标后缀,如 WS-TABLE(1) → WS-TABLE"""
|
||||||
|
return re.sub(r'\s*\(.*?\)\s*$', '', name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _init_child_names(group_name: str, fields: list) -> list:
|
||||||
|
"""递归收集 group 下所有非 88 级子字段的扁平名列表"""
|
||||||
|
result = []
|
||||||
|
grp_level = None
|
||||||
|
found = False
|
||||||
|
for f in fields:
|
||||||
|
if not found and f['name'] == group_name:
|
||||||
|
grp_level = f.get('level', 0)
|
||||||
|
found = True
|
||||||
|
continue
|
||||||
|
if found:
|
||||||
|
if f.get('level', 0) <= grp_level or f.get('level') == 77:
|
||||||
|
break
|
||||||
|
if f.get('is_88') or f.get('redefines'):
|
||||||
|
continue
|
||||||
|
if not f.get('pic_info') or f['pic_info'].get('type') == 'unknown':
|
||||||
|
result.extend(_init_child_names(f['name'], fields))
|
||||||
|
else:
|
||||||
|
result.append(f['name'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据流追踪 ──
|
||||||
|
|
||||||
|
def trace_to_root(field_name, assignments, fields, path_assign=None):
|
||||||
|
seen = set()
|
||||||
|
var = field_name
|
||||||
|
chain = []
|
||||||
|
while var in assignments and var not in seen:
|
||||||
|
seen.add(var)
|
||||||
|
if path_assign and var in path_assign:
|
||||||
|
asgn_list = path_assign[var]
|
||||||
|
if isinstance(asgn_list, list):
|
||||||
|
asgn = asgn_list[-1]
|
||||||
|
for a in reversed(asgn_list):
|
||||||
|
sv = a.get('source_vars', [])
|
||||||
|
if len(sv) == 1 and sv[0] == var:
|
||||||
|
continue
|
||||||
|
asgn = a
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
asgn = asgn_list
|
||||||
|
else:
|
||||||
|
asgn_list = assignments[var]
|
||||||
|
asgn = asgn_list[-1]
|
||||||
|
if isinstance(asgn_list, list):
|
||||||
|
for a in reversed(asgn_list):
|
||||||
|
sv = a.get('source_vars', [])
|
||||||
|
if len(sv) == 1 and sv[0] == var:
|
||||||
|
continue
|
||||||
|
asgn = a
|
||||||
|
break
|
||||||
|
chain.append((var, asgn))
|
||||||
|
if not asgn.get('source_vars'):
|
||||||
|
break
|
||||||
|
sv = asgn['source_vars']
|
||||||
|
if len(sv) == 1:
|
||||||
|
next_var = sv[0]
|
||||||
|
if next_var == var:
|
||||||
|
break
|
||||||
|
var = next_var
|
||||||
|
if next_var not in assignments:
|
||||||
|
break
|
||||||
|
elif len(sv) >= 2 and asgn.get('op') == '+':
|
||||||
|
# 多源加法:取第一个源变量继续追溯
|
||||||
|
var = sv[0]
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return var, chain
|
||||||
|
|
||||||
|
|
||||||
|
def invert_through_chain(root_var, chain, operator, value):
|
||||||
|
op = operator
|
||||||
|
try:
|
||||||
|
val = float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return root_var, op, value
|
||||||
|
for var, asgn in reversed(chain):
|
||||||
|
if asgn['type'] == 'move':
|
||||||
|
continue
|
||||||
|
sv = asgn.get('source_vars', [])
|
||||||
|
if asgn['type'] == 'compute' and asgn['op'] is not None:
|
||||||
|
if len(sv) == 1:
|
||||||
|
c = asgn['const']
|
||||||
|
inv = {'+': '-', '-': '+', '*': '/', '/': '*'}[asgn['op']]
|
||||||
|
if inv == '/':
|
||||||
|
val = val / c if c != 0 else val
|
||||||
|
elif inv == '*':
|
||||||
|
val = val * c
|
||||||
|
elif inv == '-':
|
||||||
|
val = val - c
|
||||||
|
elif inv == '+':
|
||||||
|
val = val + c
|
||||||
|
elif len(sv) >= 2 and asgn['op'] == '+':
|
||||||
|
# 多源加法:追溯第一个源变量,值不变(忽略其他源)
|
||||||
|
pass
|
||||||
|
if val == int(val):
|
||||||
|
return root_var, op, str(int(val))
|
||||||
|
return root_var, op, str(val)
|
||||||
|
|
||||||
|
|
||||||
|
FIGURATIVE_NUMERIC = {
|
||||||
|
'ZERO': 0.0, 'ZEROS': 0.0, 'ZEROES': 0.0,
|
||||||
|
'SPACE': 0.0, 'SPACES': 0.0,
|
||||||
|
'HIGH-VALUE': None, 'HIGH-VALUES': None,
|
||||||
|
'LOW-VALUE': 0.0, 'LOW-VALUES': 0.0,
|
||||||
|
}
|
||||||
|
FIGURATIVE_ALPHA = {
|
||||||
|
'SPACE': ' ', 'SPACES': ' ',
|
||||||
|
'HIGH-VALUE': chr(255), 'HIGH-VALUES': chr(255),
|
||||||
|
'LOW-VALUE': chr(0), 'LOW-VALUES': chr(0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_subscript(key, rec):
|
||||||
|
"""将变量下标解析为具体值:WS-FIXED-KEY(WS-IDX) → WS-FIXED-KEY(1) if WS-IDX=1 in rec"""
|
||||||
|
m = re.match(r'^(\w[\w-]*)\((\w[\w-]*)\)$', key)
|
||||||
|
if m:
|
||||||
|
base, var = m.groups()
|
||||||
|
if var in rec:
|
||||||
|
try:
|
||||||
|
return f'{base}({int(rec[var])})'
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_before_after(val, before_after, delimiter):
|
||||||
|
if not delimiter:
|
||||||
|
return val
|
||||||
|
if before_after == 'BEFORE':
|
||||||
|
idx = val.find(delimiter)
|
||||||
|
return val[:idx] if idx >= 0 else val
|
||||||
|
if before_after == 'AFTER':
|
||||||
|
idx = val.find(delimiter)
|
||||||
|
return val[idx + len(delimiter):] if idx >= 0 else ''
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def propagate_assignments(rec, assignments, fields, file_sec=None):
|
||||||
|
def raw_to_float(val, pi):
|
||||||
|
if pi.get('type') == 'numeric':
|
||||||
|
digits = pi.get('digits', 0)
|
||||||
|
decimal = pi.get('decimal', 0)
|
||||||
|
total = digits + decimal
|
||||||
|
s = str(val)
|
||||||
|
neg = s.startswith('-')
|
||||||
|
if neg:
|
||||||
|
s = s[1:]
|
||||||
|
s = s.zfill(total)
|
||||||
|
int_part = s[:digits] if digits else '0'
|
||||||
|
dec_part = s[digits:] if decimal > 0 else '0'
|
||||||
|
result = float(int(int_part or '0') + int(dec_part or '0') / (10 ** decimal))
|
||||||
|
return -result if neg else result
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def float_to_raw(val, pi):
|
||||||
|
if pi.get('type') == 'numeric':
|
||||||
|
digits = pi.get('digits', 0)
|
||||||
|
decimal = pi.get('decimal', 0)
|
||||||
|
signed = pi.get('signed', False)
|
||||||
|
scaled = int(round(val * (10 ** decimal)))
|
||||||
|
if not signed and scaled < 0:
|
||||||
|
scaled = 0
|
||||||
|
capped = abs(scaled) % (10 ** (digits + decimal))
|
||||||
|
int_part = str(capped // (10 ** decimal)).zfill(digits)
|
||||||
|
dec_part = str(capped % (10 ** decimal)).zfill(decimal)
|
||||||
|
result = int_part + (dec_part if decimal > 0 else '')
|
||||||
|
if signed and scaled < 0:
|
||||||
|
result = '-' + result
|
||||||
|
return result
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
def literal_to_raw(literal, pi):
|
||||||
|
ftype = pi.get('type', 'unknown')
|
||||||
|
if ftype == 'numeric':
|
||||||
|
key = literal.upper()
|
||||||
|
if key in FIGURATIVE_NUMERIC:
|
||||||
|
v = FIGURATIVE_NUMERIC[key]
|
||||||
|
if v is None:
|
||||||
|
digits = pi.get('digits', 0)
|
||||||
|
decimal = pi.get('decimal', 0)
|
||||||
|
v = 10 ** (digits + decimal) - 1
|
||||||
|
return float_to_raw(v, pi)
|
||||||
|
try:
|
||||||
|
return float_to_raw(float(literal), pi)
|
||||||
|
except ValueError:
|
||||||
|
return float_to_raw(0.0, pi)
|
||||||
|
if ftype in ('alphanumeric', 'alphabetic'):
|
||||||
|
key = literal.upper()
|
||||||
|
if key in FIGURATIVE_ALPHA:
|
||||||
|
ch = FIGURATIVE_ALPHA[key]
|
||||||
|
return ch[0].ljust(pi.get('length', 1), ch[0])
|
||||||
|
return literal.ljust(pi.get('length', len(literal)))[:pi.get('length', len(literal))]
|
||||||
|
return literal
|
||||||
|
|
||||||
|
pi_map = {f['name']: f.get('pic_info', {}) for f in fields}
|
||||||
|
if file_sec is None:
|
||||||
|
file_sec = {}
|
||||||
|
|
||||||
|
# Flatten: {tgt: [info1, info2]} → [(tgt, info1), (tgt, info2)]
|
||||||
|
flat_list = []
|
||||||
|
for tgt, asgn_val in assignments.items():
|
||||||
|
if isinstance(asgn_val, list):
|
||||||
|
for asgn in asgn_val:
|
||||||
|
flat_list.append((tgt, asgn))
|
||||||
|
elif isinstance(asgn_val, dict):
|
||||||
|
flat_list.append((tgt, asgn_val))
|
||||||
|
|
||||||
|
_MAX_CONVERGE = 20
|
||||||
|
|
||||||
|
# 识别有"锚定赋值"(非自引用赋值,如 MOVE literal 或不同字段的 MOVE) 的 target
|
||||||
|
_anchored = set()
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn.get('type') != 'compute':
|
||||||
|
_anchored.add(tgt)
|
||||||
|
else:
|
||||||
|
sv = asgn.get('source_vars', [])
|
||||||
|
if not (len(sv) == 1 and sv[0] == tgt) and not (len(sv) >= 2 and tgt == sv[0]):
|
||||||
|
_anchored.add(tgt)
|
||||||
|
|
||||||
|
for _converge_iter in range(_MAX_CONVERGE):
|
||||||
|
_old = dict(rec)
|
||||||
|
|
||||||
|
# Pass 1: variable-to-variable MOVE
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn['type'] == 'move' and asgn['source_vars']:
|
||||||
|
src = asgn['source_vars'][0]
|
||||||
|
resolved_tgt = _resolve_subscript(tgt, rec)
|
||||||
|
resolved_src = _resolve_subscript(src, rec)
|
||||||
|
if resolved_src in rec:
|
||||||
|
rec[resolved_tgt] = rec[resolved_src]
|
||||||
|
|
||||||
|
# Pass 2: literal MOVE
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn['type'] == 'move_literal':
|
||||||
|
resolved_tgt = _resolve_subscript(tgt, rec)
|
||||||
|
pi = pi_map.get(resolved_tgt, {})
|
||||||
|
rec[resolved_tgt] = literal_to_raw(asgn['literal'], pi)
|
||||||
|
|
||||||
|
# Pass 3: INITIALIZE
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn['type'] == 'initialize':
|
||||||
|
resolved_tgt = _resolve_subscript(tgt, rec)
|
||||||
|
pi = pi_map.get(resolved_tgt, {})
|
||||||
|
ftype = pi.get('type', 'unknown')
|
||||||
|
replacing = asgn.get('replacing', {})
|
||||||
|
if replacing:
|
||||||
|
mapped = replacing.get(ftype.upper(), None)
|
||||||
|
if mapped:
|
||||||
|
rec[resolved_tgt] = literal_to_raw(mapped, pi)
|
||||||
|
else:
|
||||||
|
if ftype == 'numeric':
|
||||||
|
rec[resolved_tgt] = float_to_raw(0.0, pi)
|
||||||
|
else:
|
||||||
|
rec[resolved_tgt] = literal_to_raw('SPACE', pi)
|
||||||
|
else:
|
||||||
|
if ftype == 'numeric':
|
||||||
|
rec[resolved_tgt] = float_to_raw(0.0, pi)
|
||||||
|
else:
|
||||||
|
rec[resolved_tgt] = literal_to_raw('SPACE', pi)
|
||||||
|
|
||||||
|
# Pass 3.5: READ INTO
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn['type'] == 'read_into':
|
||||||
|
fname = asgn.get('file', '')
|
||||||
|
if fname in file_sec:
|
||||||
|
fd_children = _init_child_names(file_sec[fname][0], fields)
|
||||||
|
ws_children = _init_child_names(tgt, fields)
|
||||||
|
for ws_c in ws_children:
|
||||||
|
fd_candidate = ws_c
|
||||||
|
if ws_c.startswith('WS-'):
|
||||||
|
fd_candidate = ws_c[3:]
|
||||||
|
if fd_candidate in rec:
|
||||||
|
rec[ws_c] = rec[fd_candidate]
|
||||||
|
else:
|
||||||
|
idx = ws_children.index(ws_c)
|
||||||
|
if idx < len(fd_children) and fd_children[idx] in rec:
|
||||||
|
rec[ws_c] = rec[fd_children[idx]]
|
||||||
|
rec[tgt] = ''.join(str(rec.get(c, '')) for c in ws_children)
|
||||||
|
|
||||||
|
# Pass 4: COMPUTE
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn['type'] == 'compute' and asgn['source_vars'] and asgn['op'] is not None:
|
||||||
|
resolved_tgt = _resolve_subscript(tgt, rec)
|
||||||
|
pi_tgt = pi_map.get(resolved_tgt, {})
|
||||||
|
if len(asgn['source_vars']) == 1:
|
||||||
|
src = asgn['source_vars'][0]
|
||||||
|
resolved_src = _resolve_subscript(src, rec)
|
||||||
|
# 无锚定的自引用 COMPUTE(如 ADD 1 TO X):只在第 0 轮应用一次
|
||||||
|
if resolved_tgt == resolved_src and tgt not in _anchored and _converge_iter > 0:
|
||||||
|
continue
|
||||||
|
if resolved_src in rec:
|
||||||
|
sv = raw_to_float(rec[resolved_src], pi_map.get(resolved_src, {}))
|
||||||
|
c = asgn.get('const', 0)
|
||||||
|
if asgn['op'] == 'rem':
|
||||||
|
quotient = int(sv / c) if c != 0 else 0
|
||||||
|
result = sv - quotient * c
|
||||||
|
else:
|
||||||
|
result = {'+': sv + c, '-': sv - c, '*': sv * c, '/': sv / c if c != 0 else sv}[asgn['op']]
|
||||||
|
rec[resolved_tgt] = float_to_raw(result, pi_tgt)
|
||||||
|
elif len(asgn['source_vars']) == 2:
|
||||||
|
v1, v2 = asgn['source_vars']
|
||||||
|
resolved_v1 = _resolve_subscript(v1, rec)
|
||||||
|
resolved_v2 = _resolve_subscript(v2, rec)
|
||||||
|
# 无锚定的自引用 COMPUTE(如 ADD X TO Y 且 Y 无前置 MOVE)
|
||||||
|
if resolved_tgt == resolved_v1 and tgt not in _anchored and _converge_iter > 0:
|
||||||
|
continue
|
||||||
|
if resolved_v1 in rec and resolved_v2 in rec:
|
||||||
|
sv1 = raw_to_float(rec[resolved_v1], pi_map.get(resolved_v1, {}))
|
||||||
|
sv2 = raw_to_float(rec[resolved_v2], pi_map.get(resolved_v2, {}))
|
||||||
|
if asgn['op'] == 'rem':
|
||||||
|
quotient = int(sv1 / sv2) if sv2 != 0 else 0
|
||||||
|
result = sv1 - quotient * sv2
|
||||||
|
else:
|
||||||
|
result = {'+': sv1 + sv2, '-': sv1 - sv2, '*': sv1 * sv2, '/': sv1 / sv2 if sv2 != 0 else sv1}[asgn['op']]
|
||||||
|
rec[resolved_tgt] = float_to_raw(result, pi_tgt)
|
||||||
|
elif len(asgn['source_vars']) >= 3 and asgn['op'] == '+':
|
||||||
|
total = 0
|
||||||
|
all_found = True
|
||||||
|
for v in asgn['source_vars']:
|
||||||
|
resolved_v = _resolve_subscript(v, rec)
|
||||||
|
if resolved_v in rec:
|
||||||
|
total += raw_to_float(rec[resolved_v], pi_map.get(resolved_v, {}))
|
||||||
|
else:
|
||||||
|
all_found = False
|
||||||
|
break
|
||||||
|
if all_found:
|
||||||
|
rec[resolved_tgt] = float_to_raw(total, pi_tgt)
|
||||||
|
|
||||||
|
# Pass 4.5: INSPECT
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn['type'] != 'inspect':
|
||||||
|
continue
|
||||||
|
resolved_tgt = _resolve_subscript(tgt, rec)
|
||||||
|
if resolved_tgt not in rec:
|
||||||
|
continue
|
||||||
|
src_val = str(rec[resolved_tgt])
|
||||||
|
for op_type, params in asgn.get('sub_ops', []):
|
||||||
|
if op_type == 'tally':
|
||||||
|
cv = params['count_var'].upper()
|
||||||
|
cv_pi = pi_map.get(cv, {})
|
||||||
|
effective = _apply_before_after(src_val, params.get('before_after'), params.get('delimiter'))
|
||||||
|
cnt = 0
|
||||||
|
if params['kind'] == 'LEADING':
|
||||||
|
cnt = len(effective) - len(effective.lstrip(params['char']))
|
||||||
|
elif params['kind'] == 'TRAILING':
|
||||||
|
cnt = len(effective) - len(effective.rstrip(params['char']))
|
||||||
|
else:
|
||||||
|
cnt = len(effective)
|
||||||
|
if cv_pi.get('type') == 'numeric':
|
||||||
|
rec[cv] = float_to_raw(float(cnt), cv_pi)
|
||||||
|
elif op_type == 'replace':
|
||||||
|
effective = _apply_before_after(src_val, params.get('before_after'), params.get('delimiter'))
|
||||||
|
if params['kind'] == 'ALL':
|
||||||
|
new_val = effective.replace(params['src'], params['dst'])
|
||||||
|
elif params['kind'] == 'LEADING':
|
||||||
|
new_val = effective
|
||||||
|
while new_val.startswith(params['src']):
|
||||||
|
new_val = new_val[len(params['src']):]
|
||||||
|
new_val = effective.replace(params['src'], params['dst'], 1)
|
||||||
|
elif params['kind'] == 'FIRST':
|
||||||
|
new_val = effective.replace(params['src'], params['dst'], 1)
|
||||||
|
else:
|
||||||
|
new_val = params['dst'] * len(effective)
|
||||||
|
rec[resolved_tgt] = new_val
|
||||||
|
elif op_type == 'convert':
|
||||||
|
effective = _apply_before_after(src_val, params.get('before_after'), params.get('delimiter'))
|
||||||
|
table = str.maketrans(params['from_chars'], params['to_chars'])
|
||||||
|
rec[resolved_tgt] = effective.translate(table)
|
||||||
|
|
||||||
|
# Pass 5: STRING / UNSTRING
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn['type'] == 'string_concat':
|
||||||
|
resolved_tgt = _resolve_subscript(tgt, rec)
|
||||||
|
pi = pi_map.get(resolved_tgt, {})
|
||||||
|
parts = []
|
||||||
|
for v in asgn.get('source_vars', []):
|
||||||
|
resolved_v = _resolve_subscript(v, rec)
|
||||||
|
if resolved_v in rec:
|
||||||
|
parts.append(str(rec[resolved_v]))
|
||||||
|
val = ''.join(parts)
|
||||||
|
if pi.get('type') in ('alphanumeric', 'alphabetic'):
|
||||||
|
val = val.ljust(pi.get('length', len(val)))[:pi.get('length', len(val))]
|
||||||
|
rec[resolved_tgt] = val
|
||||||
|
elif asgn['type'] == 'unstring_split':
|
||||||
|
resolved_tgt = _resolve_subscript(tgt, rec)
|
||||||
|
pi = pi_map.get(resolved_tgt, {})
|
||||||
|
src_var = asgn.get('source_vars', [None])[0]
|
||||||
|
resolved_src = _resolve_subscript(src_var, rec) if src_var else None
|
||||||
|
idx = asgn.get('index', 0)
|
||||||
|
if resolved_src and resolved_src in rec:
|
||||||
|
src_val = str(rec[resolved_src])
|
||||||
|
ftype = pi.get('type', 'unknown')
|
||||||
|
if idx == 0:
|
||||||
|
val = src_val
|
||||||
|
else:
|
||||||
|
val = ' ' if ftype in ('alphanumeric', 'alphabetic') else '0'
|
||||||
|
if ftype in ('alphanumeric', 'alphabetic'):
|
||||||
|
val = val.ljust(pi.get('length', len(val)))[:pi.get('length', len(val))]
|
||||||
|
rec[resolved_tgt] = val
|
||||||
|
|
||||||
|
# Pass 6: READ INTO / WRITE FROM
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn['type'] == 'read_into':
|
||||||
|
fname = asgn.get('file', '')
|
||||||
|
if fname in file_sec:
|
||||||
|
children = _init_child_names(file_sec[fname][0], fields)
|
||||||
|
rec[tgt] = ''.join(str(rec.get(c, '')) for c in children)
|
||||||
|
elif asgn['type'] == 'write_from':
|
||||||
|
buf = tgt
|
||||||
|
rec_name = asgn.get('file', '')
|
||||||
|
children = _init_child_names(rec_name, fields)
|
||||||
|
if children:
|
||||||
|
src = str(rec.get(buf, ''))
|
||||||
|
pos = 0
|
||||||
|
for c in children:
|
||||||
|
pi = pi_map.get(c, {})
|
||||||
|
length = pi.get('digits', 0) + pi.get('decimal', 0) or pi.get('length', 0)
|
||||||
|
if length > 0:
|
||||||
|
chunk = src[pos:pos + length]
|
||||||
|
if not chunk:
|
||||||
|
chunk = '0' if pi.get('type') == 'numeric' else ' '
|
||||||
|
rec[c] = chunk.ljust(length)
|
||||||
|
pos += length
|
||||||
|
|
||||||
|
# Pass 7: ACCEPT
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn['type'] == 'accept':
|
||||||
|
resolved_tgt = _resolve_subscript(tgt, rec)
|
||||||
|
pi = pi_map.get(resolved_tgt, {})
|
||||||
|
ftype = pi.get('type', 'unknown')
|
||||||
|
total = pi.get('digits', 0) + pi.get('decimal', 0)
|
||||||
|
length = pi.get('length', 0)
|
||||||
|
from_type = asgn.get('from', 'USER')
|
||||||
|
val = None
|
||||||
|
if from_type == 'DATE':
|
||||||
|
val = '20260603'
|
||||||
|
elif from_type == 'TIME':
|
||||||
|
val = '120000'
|
||||||
|
elif from_type == 'DAY':
|
||||||
|
val = '2026154'
|
||||||
|
elif from_type == 'DAY-OF-WEEK':
|
||||||
|
val = '3'
|
||||||
|
elif from_type == 'YEAR':
|
||||||
|
val = '2026'
|
||||||
|
if val is not None:
|
||||||
|
if ftype == 'numeric':
|
||||||
|
rec[resolved_tgt] = val.zfill(total)
|
||||||
|
else:
|
||||||
|
rec[resolved_tgt] = val.ljust(length)[:length] if length else val
|
||||||
|
|
||||||
|
# Pass 8: SET var TO TRUE (88-level)
|
||||||
|
for tgt, asgn in flat_list:
|
||||||
|
if asgn['type'] == 'set_true':
|
||||||
|
resolved_tgt = _resolve_subscript(tgt, rec)
|
||||||
|
val = asgn.get('value', '1')
|
||||||
|
pi = pi_map.get(resolved_tgt, {})
|
||||||
|
ftype = pi.get('type', 'unknown')
|
||||||
|
if ftype in ('alphanumeric', 'alphabetic'):
|
||||||
|
length = pi.get('length', len(str(val)))
|
||||||
|
rec[resolved_tgt] = str(val)[0].ljust(length)[:length]
|
||||||
|
else:
|
||||||
|
total = pi.get('digits', 0) + pi.get('decimal', 0)
|
||||||
|
rec[resolved_tgt] = str(val).zfill(max(total, 1))
|
||||||
|
|
||||||
|
if rec == _old:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning(f"propagate_assignments 未收敛({_MAX_CONVERGE} 次迭代后仍有变化)")
|
||||||
|
|
||||||
|
|
||||||
|
def classify_field_roles(tree, assignments, fields, source=None, proc_text=None):
|
||||||
|
"""分析分支树和赋值记录,分类各字段的入出力角色。
|
||||||
|
优先级:FD/OPEN 方向 > 静态分析
|
||||||
|
返回 {字段名: 'input'|'output'|'inout'|'unused'}.
|
||||||
|
"""
|
||||||
|
# Phase 0: FD/OPEN 方向解析
|
||||||
|
fd_roles = {}
|
||||||
|
if source and proc_text:
|
||||||
|
from .read import parse_file_control, parse_file_section, scan_open_statements
|
||||||
|
file_ctl = parse_file_control(source)
|
||||||
|
file_sec = parse_file_section(source)
|
||||||
|
open_dir = scan_open_statements(proc_text)
|
||||||
|
for iname, direction in open_dir.items():
|
||||||
|
if iname in file_sec:
|
||||||
|
for rec_name in file_sec[iname]:
|
||||||
|
if direction == 'INPUT':
|
||||||
|
fd_roles[rec_name] = 'input'
|
||||||
|
elif direction == 'OUTPUT':
|
||||||
|
fd_roles[rec_name] = 'output'
|
||||||
|
elif direction == 'I-O':
|
||||||
|
fd_roles[rec_name] = 'inout'
|
||||||
|
# 传播到子字段
|
||||||
|
for rec_name, role in list(fd_roles.items()):
|
||||||
|
for child in _init_child_names(rec_name, fields):
|
||||||
|
fd_roles[child] = role
|
||||||
|
|
||||||
|
counts = {f['name']: {'read': 0, 'write': 0} for f in fields}
|
||||||
|
|
||||||
|
def _walk(node):
|
||||||
|
if isinstance(node, BrIf):
|
||||||
|
if node.cond_tree:
|
||||||
|
for leaf in collect_leaves(node.cond_tree):
|
||||||
|
name = _basename(leaf.field)
|
||||||
|
if name in counts:
|
||||||
|
counts[name]['read'] += 1
|
||||||
|
_walk(node.true_seq)
|
||||||
|
_walk(node.false_seq)
|
||||||
|
elif isinstance(node, BrEval):
|
||||||
|
name = _basename(node.subject)
|
||||||
|
if name in counts:
|
||||||
|
counts[name]['read'] += 1
|
||||||
|
for _, seq in node.when_list:
|
||||||
|
_walk(seq)
|
||||||
|
_walk(node.other_seq)
|
||||||
|
elif isinstance(node, BrPerform):
|
||||||
|
if node.condition:
|
||||||
|
parsed = parse_single_condition(node.condition)
|
||||||
|
if parsed:
|
||||||
|
name = _basename(parsed[0])
|
||||||
|
if name in counts:
|
||||||
|
counts[name]['read'] += 1
|
||||||
|
if node.varying_var:
|
||||||
|
name = _basename(node.varying_var)
|
||||||
|
if name in counts:
|
||||||
|
counts[name]['write'] += 1
|
||||||
|
_walk(node.body_seq)
|
||||||
|
elif isinstance(node, CallNode):
|
||||||
|
for p in node.using_params:
|
||||||
|
name = _basename(p.get("name", ""))
|
||||||
|
mechanism = p.get("mechanism", "reference")
|
||||||
|
if name in counts:
|
||||||
|
counts[name]["read"] += 1
|
||||||
|
if mechanism.lower() == "reference":
|
||||||
|
counts[name]["write"] += 1
|
||||||
|
elif isinstance(node, Assign):
|
||||||
|
tgt_base = _basename(node.target)
|
||||||
|
atype = node.source_info.get('type')
|
||||||
|
if atype == 'read_into':
|
||||||
|
if tgt_base in counts:
|
||||||
|
counts[tgt_base]['write'] += 1
|
||||||
|
elif atype == 'write_from':
|
||||||
|
if tgt_base in counts:
|
||||||
|
counts[tgt_base]['read'] += 1
|
||||||
|
elif atype == 'set_true':
|
||||||
|
if tgt_base in counts:
|
||||||
|
counts[tgt_base]['write'] += 1
|
||||||
|
else:
|
||||||
|
if tgt_base in counts:
|
||||||
|
counts[tgt_base]['write'] += 1
|
||||||
|
for v in node.source_info.get('source_vars', []):
|
||||||
|
v_base = _basename(v)
|
||||||
|
if v_base in counts:
|
||||||
|
counts[v_base]['read'] += 1
|
||||||
|
if atype == 'initialize' and tgt_base in counts:
|
||||||
|
for child in _init_child_names(tgt_base, fields):
|
||||||
|
if child in counts:
|
||||||
|
counts[child]['write'] += 1
|
||||||
|
elif isinstance(node, BrSeq):
|
||||||
|
for c in node.children:
|
||||||
|
_walk(c)
|
||||||
|
|
||||||
|
_walk(tree)
|
||||||
|
|
||||||
|
# Phase extra: ACCEPT / DISPLAY (proc_text 扫描)
|
||||||
|
if proc_text:
|
||||||
|
for m in re.finditer(r'ACCEPT\s+(\w[\w-]*)', proc_text):
|
||||||
|
name = _basename(m.group(1).upper())
|
||||||
|
if name in counts:
|
||||||
|
counts[name]['write'] += 1
|
||||||
|
for m in re.finditer(r'DISPLAY\s+(\w[\w-]*)', proc_text):
|
||||||
|
name = _basename(m.group(1).upper())
|
||||||
|
if name in counts:
|
||||||
|
counts[name]['read'] += 1
|
||||||
|
|
||||||
|
# LINKAGE 字段默认 input(未使用时不改变)
|
||||||
|
for f in fields:
|
||||||
|
if f.get('section') == 'LINKAGE':
|
||||||
|
name = f['name']
|
||||||
|
if name in counts and counts[name]['read'] == 0 and counts[name]['write'] == 0:
|
||||||
|
counts[name]['read'] = 1
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for name, c in counts.items():
|
||||||
|
if name in fd_roles:
|
||||||
|
result[name] = fd_roles[name]
|
||||||
|
continue
|
||||||
|
if c['read'] > 0 and c['write'] > 0:
|
||||||
|
result[name] = 'inout'
|
||||||
|
elif c['write'] > 0:
|
||||||
|
result[name] = 'output'
|
||||||
|
elif c['read'] > 0:
|
||||||
|
result[name] = 'input'
|
||||||
|
else:
|
||||||
|
result[name] = 'unused'
|
||||||
|
# 确保 FD 记录字段也出现(即使不在 fields 中—应不会)
|
||||||
|
for name, role in fd_roles.items():
|
||||||
|
if name not in result:
|
||||||
|
result[name] = role
|
||||||
|
return result
|
||||||
@@ -1205,32 +1205,3 @@ def run_coverage(branch_tree, branch_paths_with_assigns, fields,
|
|||||||
'_decision_points': decision_points,
|
'_decision_points': decision_points,
|
||||||
'_leaf_stats': leaf_stats,
|
'_leaf_stats': leaf_stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def check_coverage(structure: dict, test_records: list[dict]) -> dict:
|
|
||||||
"""报告 COBOL 源码的静态分支结构信息。
|
|
||||||
|
|
||||||
注意: 静态分析无法精确判断每条测试数据运行时覆盖了哪些分支。
|
|
||||||
精确的路径追踪依赖 gcov(Phase 3)。此处仅报告总分支数和记录生成情况。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict with: paragraph_rate, branch_rate, decision_rate, total_branches,
|
|
||||||
total_paragraphs, records_count, note
|
|
||||||
"""
|
|
||||||
total_paragraphs = structure.get("total_paragraphs", 0)
|
|
||||||
total_branches = structure.get("total_branches", 0)
|
|
||||||
decision_points = structure.get("decision_points", [])
|
|
||||||
has_data = len(test_records) > 0
|
|
||||||
|
|
||||||
paragraph_rate = 1.0 if (total_paragraphs > 0 and has_data) else 0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"paragraph_rate": paragraph_rate,
|
|
||||||
"branch_rate": 0.0,
|
|
||||||
"decision_rate": 0.0,
|
|
||||||
"uncovered_decision_ids": [],
|
|
||||||
"total_branches": total_branches,
|
|
||||||
"total_paragraphs": total_paragraphs,
|
|
||||||
"records_count": len(test_records),
|
|
||||||
"note": "静态分析无法精确计算覆盖率。精确数据通过 gcov 获取(Phase 3)。",
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,894 @@
|
|||||||
|
"""设计层:路径枚举 + 值生成 + 约束应用"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
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 .core import trace_to_root, invert_through_chain, propagate_assignments, _basename
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_STOP = ('__STOP__', '', None, True)
|
||||||
|
_MAX_PATHS = 10000
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_stop(cons):
|
||||||
|
return [c for c in cons if c is not _STOP]
|
||||||
|
|
||||||
|
|
||||||
|
def _cap_paths(paths):
|
||||||
|
if len(paths) > _MAX_PATHS:
|
||||||
|
return paths[:_MAX_PATHS]
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _cap_paths_fair(new_active, child_paths):
|
||||||
|
"""两阶段公平截断:每个前置路径至少保留一条子路径,再填充剩余配额。"""
|
||||||
|
if len(new_active) <= _MAX_PATHS:
|
||||||
|
return new_active
|
||||||
|
k = len(child_paths)
|
||||||
|
if k <= 1:
|
||||||
|
return new_active[:_MAX_PATHS]
|
||||||
|
# 分离 STOP 路径(不参与组合,直接保留)
|
||||||
|
stop_paths = [(p, a) for p, a in new_active if any(c is _STOP for c in p)]
|
||||||
|
combined = [(p, a) for p, a in new_active if not any(c is _STOP for c in p)]
|
||||||
|
n_pred = len(combined) // k
|
||||||
|
result = list(stop_paths)
|
||||||
|
if n_pred <= 1:
|
||||||
|
result.extend(combined[:_MAX_PATHS - len(result)])
|
||||||
|
return result[:_MAX_PATHS]
|
||||||
|
remaining_quota = _MAX_PATHS - len(result)
|
||||||
|
# Phase 1: 每个前置至少保留一条子路径(轮询分配不同子路径索引)
|
||||||
|
quota = min(n_pred, remaining_quota)
|
||||||
|
selected = set()
|
||||||
|
for p_idx in range(quota):
|
||||||
|
c_idx = p_idx % k
|
||||||
|
idx = p_idx * k + c_idx
|
||||||
|
selected.add(idx)
|
||||||
|
result.append(combined[idx])
|
||||||
|
if len(result) >= _MAX_PATHS:
|
||||||
|
return result[:_MAX_PATHS]
|
||||||
|
# Phase 2: 用剩余配额填充其余组合
|
||||||
|
remaining = _MAX_PATHS - len(result)
|
||||||
|
for idx in range(len(combined)):
|
||||||
|
if idx not in selected:
|
||||||
|
result.append(combined[idx])
|
||||||
|
remaining -= 1
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
return result[:_MAX_PATHS]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 路径枚举 ──
|
||||||
|
|
||||||
|
def enum_paths(node, fields):
|
||||||
|
"""枚举路径,每条路径返回 (constraints, assignments).
|
||||||
|
返回 list[tuple[list[tuple], dict]].
|
||||||
|
"""
|
||||||
|
if isinstance(node, Assign):
|
||||||
|
return [([], {node.target: [node.source_info]})]
|
||||||
|
|
||||||
|
if isinstance(node, BrSeq):
|
||||||
|
if not node.children:
|
||||||
|
return [([], {})]
|
||||||
|
paths = [([], {})]
|
||||||
|
for child in node.children:
|
||||||
|
child_paths = _cap_paths(enum_paths(child, fields))
|
||||||
|
new_active = []
|
||||||
|
for p_cons, p_assign in paths:
|
||||||
|
if any(c is _STOP for c in p_cons):
|
||||||
|
new_active.append((p_cons, p_assign))
|
||||||
|
continue
|
||||||
|
for cp_cons, cp_assign in child_paths:
|
||||||
|
merged = {}
|
||||||
|
for d in (p_assign, cp_assign):
|
||||||
|
for k, v in d.items():
|
||||||
|
merged.setdefault(k, []).extend(v if isinstance(v, list) else [v])
|
||||||
|
merged_cons = p_cons + list(cp_cons)
|
||||||
|
new_active.append((merged_cons, merged))
|
||||||
|
paths = _cap_paths_fair(new_active, child_paths)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
elif isinstance(node, BrIf):
|
||||||
|
parsed = parse_single_condition(node.condition, fields)
|
||||||
|
if parsed and is_field(parsed[0], fields):
|
||||||
|
field, op, val = 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, True)] + 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, False)] + fp_cons, fp_assign))
|
||||||
|
return paths
|
||||||
|
# CondNot wrapping a single leaf (e.g., IF NOT WS-AMOUNT > 1000)
|
||||||
|
if node.cond_tree and isinstance(node.cond_tree, CondNot):
|
||||||
|
child = node.cond_tree.child
|
||||||
|
if isinstance(child, CondLeaf) and is_field(child.field, fields):
|
||||||
|
paths = []
|
||||||
|
true_sub = _cap_paths(enum_paths(node.true_seq, fields))
|
||||||
|
for sp_cons, sp_assign in (true_sub or [([], {})]):
|
||||||
|
paths.append(([(child.field, child.op, child.value, False)] + 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(([(child.field, child.op, child.value, True)] + fp_cons, fp_assign))
|
||||||
|
return paths
|
||||||
|
if node.cond_tree:
|
||||||
|
leaves = collect_leaves(node.cond_tree)
|
||||||
|
if leaves and all(is_field(l.field, fields) for l in leaves):
|
||||||
|
sets = mcdc_sets(node.cond_tree, fields)
|
||||||
|
if sets:
|
||||||
|
paths = []
|
||||||
|
for constraints, decision in sets:
|
||||||
|
body = _cap_paths(enum_paths(
|
||||||
|
node.true_seq if decision else node.false_seq, fields
|
||||||
|
))
|
||||||
|
for sp_cons, sp_assign in (body or [([], {})]):
|
||||||
|
paths.append((constraints + sp_cons, sp_assign))
|
||||||
|
return paths
|
||||||
|
# CondLeaf fallback: 单 leaf(含 88-level 解析后的条件树)MC/DC 不适用
|
||||||
|
if len(leaves) == 1:
|
||||||
|
leaf = leaves[0]
|
||||||
|
paths = []
|
||||||
|
true_sub = _cap_paths(enum_paths(node.true_seq, fields))
|
||||||
|
for sp_cons, sp_assign in (true_sub or [([], {})]):
|
||||||
|
paths.append(([(leaf.field, leaf.op, leaf.value, True)] + 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(([(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:
|
||||||
|
field, op, val = parsed
|
||||||
|
paths = []
|
||||||
|
true_sub = enum_paths(node.true_seq, fields)
|
||||||
|
for sp_cons, sp_assign in (true_sub or [([], {})]):
|
||||||
|
paths.append(([(field, op, val, True)] + sp_cons, sp_assign))
|
||||||
|
false_sub = enum_paths(node.false_seq, fields)
|
||||||
|
for fp_cons, fp_assign in (false_sub or [([], {})]):
|
||||||
|
paths.append(([(field, op, val, False)] + fp_cons, fp_assign))
|
||||||
|
return paths
|
||||||
|
return [([], {})]
|
||||||
|
|
||||||
|
elif isinstance(node, BrEval):
|
||||||
|
if node.subjects:
|
||||||
|
paths = []
|
||||||
|
prior_false_cons = []
|
||||||
|
for values, seq in node.when_list:
|
||||||
|
sub = _cap_paths(enum_paths(seq, fields))
|
||||||
|
for sp_cons, sp_assign in (sub or [([], {})]):
|
||||||
|
when_cons = [(node.subjects[i], '=', values[i], True)
|
||||||
|
for i in range(len(node.subjects))]
|
||||||
|
constraints = list(prior_false_cons) + when_cons + sp_cons
|
||||||
|
paths.append((constraints, sp_assign))
|
||||||
|
for i in range(len(node.subjects)):
|
||||||
|
prior_false_cons.append((node.subjects[i], '=', values[i], False))
|
||||||
|
if node.has_other:
|
||||||
|
sub = _cap_paths(enum_paths(node.other_seq, fields))
|
||||||
|
for sp_cons, sp_assign in (sub or [([], {})]):
|
||||||
|
paths.append((list(prior_false_cons) + sp_cons, sp_assign))
|
||||||
|
return paths
|
||||||
|
if node.subject == 'TRUE':
|
||||||
|
paths = []
|
||||||
|
prior_false_sets = [] # list[list[Constraint]]
|
||||||
|
for value, seq in node.when_list:
|
||||||
|
cond = parse_compound_condition(value, fields)
|
||||||
|
if cond and isinstance(cond, CondLeaf) and is_field(cond.field, fields):
|
||||||
|
sub = _cap_paths(enum_paths(seq, fields))
|
||||||
|
for sp_cons, sp_assign in (sub or [([], {})]):
|
||||||
|
constraints = [c for pf in prior_false_sets for c in pf]
|
||||||
|
constraints.append((cond.field, cond.op, cond.value, True))
|
||||||
|
paths.append((constraints + sp_cons, sp_assign))
|
||||||
|
prior_false_sets.append([(cond.field, cond.op, cond.value, False)])
|
||||||
|
elif cond:
|
||||||
|
leaves = collect_leaves(cond)
|
||||||
|
if leaves and all(is_field(l.field, fields) for l in leaves):
|
||||||
|
sets = mcdc_sets(cond, fields)
|
||||||
|
if sets:
|
||||||
|
sub = _cap_paths(enum_paths(seq, fields))
|
||||||
|
new_false_sets = []
|
||||||
|
for cs, decision in sets:
|
||||||
|
if decision:
|
||||||
|
if not prior_false_sets:
|
||||||
|
for sp_cons, sp_assign in (sub or [([], {})]):
|
||||||
|
paths.append((list(cs) + sp_cons, sp_assign))
|
||||||
|
else:
|
||||||
|
for pf_set in prior_false_sets:
|
||||||
|
for sp_cons, sp_assign in (sub or [([], {})]):
|
||||||
|
paths.append((list(pf_set) + list(cs) + sp_cons, sp_assign))
|
||||||
|
else:
|
||||||
|
new_false_sets.append(cs)
|
||||||
|
if not new_false_sets:
|
||||||
|
prior_false_sets = []
|
||||||
|
break
|
||||||
|
combined = []
|
||||||
|
for pf_set in prior_false_sets:
|
||||||
|
for nf_set in new_false_sets:
|
||||||
|
combined.append(list(pf_set) + list(nf_set))
|
||||||
|
prior_false_sets = combined
|
||||||
|
else:
|
||||||
|
prior_false_sets = []
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
prior_false_sets = []
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
prior_false_sets = []
|
||||||
|
break
|
||||||
|
if node.has_other:
|
||||||
|
sub = _cap_paths(enum_paths(node.other_seq, fields))
|
||||||
|
for sp_cons, sp_assign in (sub or [([], {})]):
|
||||||
|
constraints = [c for pf in prior_false_sets for c in pf]
|
||||||
|
paths.append((constraints + sp_cons, sp_assign))
|
||||||
|
return paths
|
||||||
|
if not is_field(node.subject, fields):
|
||||||
|
return [([], {})]
|
||||||
|
paths = []
|
||||||
|
for value, seq in node.when_list:
|
||||||
|
sub = _cap_paths(enum_paths(seq, fields))
|
||||||
|
for sp_cons, sp_assign in (sub or [([], {})]):
|
||||||
|
paths.append(([(node.subject, '=', value, True)] + sp_cons, sp_assign))
|
||||||
|
if node.has_other:
|
||||||
|
case_vals = [v for v, _ in node.when_list]
|
||||||
|
sub = _cap_paths(enum_paths(node.other_seq, fields))
|
||||||
|
for sp_cons, sp_assign in (sub or [([], {})]):
|
||||||
|
paths.append(([(node.subject, 'not_in', case_vals, True)] + sp_cons, sp_assign))
|
||||||
|
return paths
|
||||||
|
|
||||||
|
elif isinstance(node, BrSearch):
|
||||||
|
return _enum_search_paths(node, fields)
|
||||||
|
|
||||||
|
elif isinstance(node, BrPerform):
|
||||||
|
if node.perf_type in ('para', 'thru'):
|
||||||
|
if node.body_seq:
|
||||||
|
return enum_paths(node.body_seq, fields)
|
||||||
|
return [([], {})]
|
||||||
|
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):
|
||||||
|
field, op, val = parsed
|
||||||
|
paths = []
|
||||||
|
false_sub = _cap_paths(enum_paths(node.body_seq, fields))
|
||||||
|
for sp_cons, sp_assign in (false_sub or [([], {})]):
|
||||||
|
# PERFORM VARYING: 将 FROM 值作为 MOVE 赋值加入 Enter 路径
|
||||||
|
if node.varying_from and node.varying_var:
|
||||||
|
is_fld = any(f['name'] == node.varying_from for f in fields) if fields else False
|
||||||
|
from_asgn = {'type': 'move', 'source_vars': [node.varying_from]} if is_fld else {'type': 'move_literal', 'literal': node.varying_from}
|
||||||
|
from_assign = {node.varying_var: [from_asgn]}
|
||||||
|
merged = {}
|
||||||
|
for d in (from_assign, sp_assign):
|
||||||
|
for k, v in d.items():
|
||||||
|
merged.setdefault(k, []).extend(v if isinstance(v, list) else [v])
|
||||||
|
sp_assign = merged
|
||||||
|
paths.append(([(field, op, val, False)] + sp_cons, sp_assign))
|
||||||
|
paths.append(([(field, op, val, True)], {}))
|
||||||
|
return paths
|
||||||
|
# 尝试复合条件(AND/OR)
|
||||||
|
cond_tree = parse_compound_condition(node.condition, fields)
|
||||||
|
if cond_tree:
|
||||||
|
leaves = collect_leaves(cond_tree)
|
||||||
|
if leaves and all(is_field(l.field, fields) for l in leaves):
|
||||||
|
sets = mcdc_sets(cond_tree, fields)
|
||||||
|
if sets:
|
||||||
|
paths = []
|
||||||
|
false_sub = _cap_paths(enum_paths(node.body_seq, fields))
|
||||||
|
for sp_cons, sp_assign in (false_sub or [([], {})]):
|
||||||
|
# PERFORM VARYING: 将 FROM 值作为 MOVE 赋值加入 Enter 路径
|
||||||
|
if node.varying_from and node.varying_var:
|
||||||
|
is_fld = any(f['name'] == node.varying_from for f in fields) if fields else False
|
||||||
|
from_asgn = {'type': 'move', 'source_vars': [node.varying_from]} if is_fld else {'type': 'move_literal', 'literal': node.varying_from}
|
||||||
|
from_assign = {node.varying_var: [from_asgn]}
|
||||||
|
merged = {}
|
||||||
|
for d in (from_assign, sp_assign):
|
||||||
|
for k, v in d.items():
|
||||||
|
merged.setdefault(k, []).extend(v if isinstance(v, list) else [v])
|
||||||
|
sp_assign = merged
|
||||||
|
for constraints, decision in sets:
|
||||||
|
if not decision:
|
||||||
|
paths.append((list(constraints) + sp_cons, sp_assign))
|
||||||
|
for constraints, decision in sets:
|
||||||
|
if decision:
|
||||||
|
paths.append((list(constraints), {}))
|
||||||
|
if paths:
|
||||||
|
return paths
|
||||||
|
return [([], {})]
|
||||||
|
|
||||||
|
elif isinstance(node, CallNode):
|
||||||
|
return [([], {})]
|
||||||
|
|
||||||
|
elif isinstance(node, ExitNode):
|
||||||
|
return [([_STOP], {})]
|
||||||
|
|
||||||
|
elif isinstance(node, GoTo):
|
||||||
|
paths = enum_paths(node.body_seq, fields)
|
||||||
|
return [([_STOP] + c, a) for c, a in paths]
|
||||||
|
|
||||||
|
return [([], {})]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 值生成 ──
|
||||||
|
|
||||||
|
def seq_numeric(seq_num: int, total_digits: int) -> str:
|
||||||
|
val = seq_num % (10 ** total_digits)
|
||||||
|
if val == 0:
|
||||||
|
val = 10 ** total_digits - 1
|
||||||
|
return str(val).zfill(total_digits)
|
||||||
|
|
||||||
|
|
||||||
|
def seq_alpha(seq_num: int, length: int) -> str:
|
||||||
|
letter = chr(65 + (seq_num - 1) % 26)
|
||||||
|
return letter * length
|
||||||
|
|
||||||
|
|
||||||
|
def seq_date(seq_num: int) -> str:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
base = datetime(2000, 1, 1)
|
||||||
|
d = base + timedelta(days=seq_num - 1)
|
||||||
|
return d.strftime('%Y%m%d')
|
||||||
|
|
||||||
|
|
||||||
|
def _is_date_field(name: str) -> bool:
|
||||||
|
patterns = [r'DATE', r'YYMMDD', r'YYYYMM', r'YEAR', r'MONTH', r'DAY']
|
||||||
|
for p in patterns:
|
||||||
|
if re.search(p, name.upper()):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_SPECIAL_VALUES = {
|
||||||
|
'ZERO': '0', 'ZEROS': '0', 'ZEROES': '0',
|
||||||
|
'SPACE': ' ', 'SPACES': ' ',
|
||||||
|
'HIGH-VALUE': '\xff', 'HIGH-VALUES': '\xff',
|
||||||
|
'LOW-VALUE': '\x00', 'LOW-VALUES': '\x00',
|
||||||
|
'QUOTE': "'", 'QUOTES': "'",
|
||||||
|
'ALL': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_value(field: dict, rec: dict) -> bool:
|
||||||
|
"""尝试应用 VALUE 子句的初始值。返回 True 表示已处理。"""
|
||||||
|
raw = field.get('value')
|
||||||
|
if raw is None:
|
||||||
|
return False
|
||||||
|
val = str(raw).strip("'\"").strip()
|
||||||
|
name = field['name']
|
||||||
|
pi = field.get('pic_info', {})
|
||||||
|
|
||||||
|
# 处理 COBOL 特殊值
|
||||||
|
if val.upper() in _SPECIAL_VALUES:
|
||||||
|
val = _SPECIAL_VALUES[val.upper()]
|
||||||
|
|
||||||
|
ftype = pi.get('type', 'unknown')
|
||||||
|
if ftype == 'numeric':
|
||||||
|
digits = pi.get('digits', 0) + pi.get('decimal', 0)
|
||||||
|
if digits:
|
||||||
|
rec[name] = val.zfill(digits)
|
||||||
|
else:
|
||||||
|
rec[name] = val
|
||||||
|
else:
|
||||||
|
length = pi.get('length', 0) or 1
|
||||||
|
rec[name] = val.ljust(length)[:length]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _children_of(group_name: str, fields: list) -> list:
|
||||||
|
"""返回组项目 group_name 在 fields 中的直属子字段列表(按声明顺序)。
|
||||||
|
终止条件:遇到同/更高级别(sibling/组边界)或 77 级(独立字段)。
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
group_level = None
|
||||||
|
found = False
|
||||||
|
for f in fields:
|
||||||
|
if not found and f['name'] == group_name:
|
||||||
|
group_level = f['level']
|
||||||
|
found = True
|
||||||
|
continue
|
||||||
|
if found:
|
||||||
|
if f['level'] <= group_level or f['level'] == 77:
|
||||||
|
break
|
||||||
|
# 88-level 是条件名,不计为子字段
|
||||||
|
if f.get('is_88'):
|
||||||
|
continue
|
||||||
|
result.append(f)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _make_numeric_value(idx: int, record_num: int, total_digits: int) -> str:
|
||||||
|
for step in (100, 10, 1):
|
||||||
|
val = idx * step + record_num
|
||||||
|
if val < 10 ** total_digits:
|
||||||
|
return str(val).zfill(total_digits)
|
||||||
|
return str(record_num).zfill(total_digits)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_alpha_value(idx: int, record_num: int, length: int) -> str:
|
||||||
|
if length == 1:
|
||||||
|
ch = chr(65 + (idx + record_num - 2) % 26)
|
||||||
|
return ch
|
||||||
|
letter = chr(65 + (idx - 1) % 26)
|
||||||
|
return letter + str(record_num).zfill(length - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def make_base_record(seq_num: int, fields: list) -> dict:
|
||||||
|
rec = {}
|
||||||
|
redefines_map = {} # 标量 REDEFINES: parent_name → [child_names]
|
||||||
|
group_redefines = [] # 组 REDEFINES: [(redef_name, target_name)]
|
||||||
|
filler_key_counter = 0
|
||||||
|
numeric_idx = 0
|
||||||
|
alpha_idx = 0
|
||||||
|
record_num = seq_num
|
||||||
|
|
||||||
|
for f in fields:
|
||||||
|
name = f['name']
|
||||||
|
|
||||||
|
if f.get('is_88'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if f.get('redefines'):
|
||||||
|
parent = f['redefines']
|
||||||
|
if f.get('pic'):
|
||||||
|
# 标量 REDEFINES(有 PIC,如 WS-AMOUNT-DISP REDEFINES WS-AMOUNT PIC X(9))
|
||||||
|
redefines_map.setdefault(parent, []).append(name)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# 组 REDEFINES(无 PIC,如 CUST-ADDR2 REDEFINES CUST-ADDR)
|
||||||
|
group_redefines.append((name, parent))
|
||||||
|
# 不 continue — 组本身无 PIC 会在下方"组项目跳过"处理
|
||||||
|
# 其子字段作为独立字段正常走循环
|
||||||
|
|
||||||
|
if f.get('is_filler'):
|
||||||
|
if name in rec:
|
||||||
|
filler_key_counter += 1
|
||||||
|
name = f'FILLER_{filler_key_counter + 1}'
|
||||||
|
rec[name] = 'x' * (f.get('pic_info', {}).get('length', 0) or 1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pass 0: VALUE 子句初始值优先
|
||||||
|
if _apply_value(f, rec):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 组项目(无 PIC)跳过
|
||||||
|
if not f.get('pic'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
pi = f.get('pic_info', {})
|
||||||
|
ftype = pi.get('type', 'unknown')
|
||||||
|
digits = pi.get('digits', 0)
|
||||||
|
decimal = pi.get('decimal', 0)
|
||||||
|
length = pi.get('length', 0)
|
||||||
|
|
||||||
|
if ftype == 'numeric':
|
||||||
|
if _is_date_field(name):
|
||||||
|
rec[name] = seq_date(record_num)
|
||||||
|
else:
|
||||||
|
numeric_idx += 1
|
||||||
|
rec[name] = _make_numeric_value(numeric_idx, record_num, digits + decimal)
|
||||||
|
elif ftype in ('alphanumeric', 'alphabetic'):
|
||||||
|
alpha_idx += 1
|
||||||
|
rec[name] = _make_alpha_value(alpha_idx, record_num, length or 1)
|
||||||
|
elif ftype == 'numeric-edited':
|
||||||
|
numeric_idx += 1
|
||||||
|
raw = _make_numeric_value(numeric_idx, record_num, digits + decimal)
|
||||||
|
rec[name] = raw.rjust(length)
|
||||||
|
else:
|
||||||
|
alpha_idx += 1
|
||||||
|
rec[name] = _make_alpha_value(alpha_idx, record_num, 8)
|
||||||
|
|
||||||
|
# Pass 2a: 标量 REDEFINES 复制
|
||||||
|
for parent_name, child_names in redefines_map.items():
|
||||||
|
if parent_name in rec:
|
||||||
|
for child_name in child_names:
|
||||||
|
rec[child_name] = rec[parent_name]
|
||||||
|
|
||||||
|
# Pass 2b: 组 REDEFINES 按位置递归复制子字段
|
||||||
|
for redef_name, target_name in group_redefines:
|
||||||
|
redef_kids = _children_of(redef_name, fields)
|
||||||
|
tgt_kids = _children_of(target_name, fields)
|
||||||
|
tgt_idx = 0
|
||||||
|
for i, rk in enumerate(redef_kids):
|
||||||
|
if tgt_idx >= len(tgt_kids):
|
||||||
|
break
|
||||||
|
if i == len(redef_kids) - 1 and len(redef_kids) < len(tgt_kids):
|
||||||
|
# 最后一个 REDEFINES 子字段,且目标更多 → 拼接剩余所有目标值
|
||||||
|
parts = [rec.get(tk['name'], '') for tk in tgt_kids[tgt_idx:]]
|
||||||
|
rec[rk['name']] = ''.join(parts)
|
||||||
|
elif i == len(redef_kids) - 1 and len(redef_kids) > len(tgt_kids):
|
||||||
|
# REDEFINES 子字段更多 → 最后一个 REDEFINES 子字段取最后目标值
|
||||||
|
rec[rk['name']] = rec.get(tgt_kids[-1]['name'], '')
|
||||||
|
else:
|
||||||
|
rec[rk['name']] = rec.get(tgt_kids[tgt_idx]['name'], '')
|
||||||
|
tgt_idx += 1
|
||||||
|
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
# ── 约束应用 ──
|
||||||
|
|
||||||
|
def _check_constraint_satisfied(rec, field_name, operator, value, want_true, fields):
|
||||||
|
"""检查 field_name 当前值是否满足该约束。满足返回 True。"""
|
||||||
|
for f in fields:
|
||||||
|
if f['name'] == field_name:
|
||||||
|
pi = f.get('pic_info', {})
|
||||||
|
ftype = pi.get('type', 'unknown')
|
||||||
|
val = rec.get(field_name)
|
||||||
|
if val is None:
|
||||||
|
return False
|
||||||
|
if operator == 'not_in':
|
||||||
|
cases = value if isinstance(value, list) else []
|
||||||
|
return str(val) not in cases
|
||||||
|
if ftype == 'numeric':
|
||||||
|
try:
|
||||||
|
num_val = int(float(str(val)))
|
||||||
|
num_target = int(float(str(value)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
if operator in ('>=', '>', '<', '<=', '=', '<>'):
|
||||||
|
if operator == '>=': ok = num_val >= num_target
|
||||||
|
elif operator == '>': ok = num_val > num_target
|
||||||
|
elif operator == '<': ok = num_val < num_target
|
||||||
|
elif operator == '<=': ok = num_val <= num_target
|
||||||
|
elif operator == '=': ok = num_val == num_target
|
||||||
|
elif operator == '<>': ok = num_val != num_target
|
||||||
|
return ok == want_true
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
s_val = str(val).strip().upper()
|
||||||
|
s_target = str(value).strip().upper()
|
||||||
|
eq = s_val == s_target
|
||||||
|
if operator == '=':
|
||||||
|
return eq == want_true
|
||||||
|
elif operator == '<>':
|
||||||
|
return (not eq) == want_true
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_ARITH_BOUNDS = {
|
||||||
|
'left_big_ops': {'>', '>=', '<>'},
|
||||||
|
'left_small_ops': {'<', '<='},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _arith_pic_info(field_name, fields):
|
||||||
|
for f in fields:
|
||||||
|
if f['name'] == field_name.upper():
|
||||||
|
return f.get('pic_info', {})
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _arith_numeric_pick(field_name, want_big, fields):
|
||||||
|
"""为字段选一个大值或小值,返回字符串。"""
|
||||||
|
pi = _arith_pic_info(field_name, fields)
|
||||||
|
if pi.get('type') != 'numeric':
|
||||||
|
return None
|
||||||
|
digits = pi.get('digits', 0)
|
||||||
|
decimal = pi.get('decimal', 0)
|
||||||
|
total = digits + decimal
|
||||||
|
max_val = 10 ** total - 1
|
||||||
|
if want_big:
|
||||||
|
pick = int(max_val * 0.7)
|
||||||
|
else:
|
||||||
|
pick = 1
|
||||||
|
int_part = str(pick // (10 ** decimal)).zfill(digits)
|
||||||
|
dec_part = str(pick % (10 ** decimal)).zfill(decimal)
|
||||||
|
if decimal == 0:
|
||||||
|
return int_part
|
||||||
|
return int_part + dec_part
|
||||||
|
|
||||||
|
def _apply_arith_constraint(rec, field_name, operator, value, want_true, fields):
|
||||||
|
"""对算术表达式条件进行字段值 steering。
|
||||||
|
|
||||||
|
例如 A + B > C (want_true=True):
|
||||||
|
- 左值字段(A, B)设大 → 右值字段(C)设小
|
||||||
|
例如 A + B <= C (want_true=True):
|
||||||
|
- 左值字段设小 → 右值字段设大
|
||||||
|
|
||||||
|
这是启发式 steering,不是精确求解。
|
||||||
|
主要目标是保证分支可达,不保证边界值精确。
|
||||||
|
"""
|
||||||
|
# 1. 提取左值表达式中的所有字段名(大写)
|
||||||
|
tokens = re.findall(r'\b[A-Z][A-Z0-9-]*(?:\([^)]*\))?\b', field_name.upper())
|
||||||
|
left_fields = [t for t in tokens if any(f['name'] == t for f in fields)]
|
||||||
|
|
||||||
|
# 2. 右值是否也为字段
|
||||||
|
right_field = value if any(f['name'] == value for f in fields) else None
|
||||||
|
|
||||||
|
if not left_fields:
|
||||||
|
logger.debug(f"算术表达式无法提取字段: {field_name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. 确定方向:want_true 时左值应大还是小
|
||||||
|
if operator in _ARITH_BOUNDS['left_big_ops']:
|
||||||
|
left_big = want_true
|
||||||
|
elif operator in _ARITH_BOUNDS['left_small_ops']:
|
||||||
|
left_big = not want_true
|
||||||
|
else:
|
||||||
|
left_big = want_true
|
||||||
|
|
||||||
|
# 4. 设置左值字段
|
||||||
|
for lf in left_fields:
|
||||||
|
pick = _arith_numeric_pick(lf, left_big, fields)
|
||||||
|
if pick is not None:
|
||||||
|
rec[lf] = pick
|
||||||
|
|
||||||
|
# 5. 设置右值字段(如果有)
|
||||||
|
if right_field:
|
||||||
|
pick = _arith_numeric_pick(right_field, not left_big, fields)
|
||||||
|
if pick is not None:
|
||||||
|
rec[right_field] = pick
|
||||||
|
|
||||||
|
|
||||||
|
def apply_constraint(rec, field_name, operator, value, want_true, fields, assignments=None, path_assign=None):
|
||||||
|
# 标准化字段名:去除括号内空格(WS-CELL ( 1, 1 ) → WS-CELL(1,1))
|
||||||
|
field_name = re.sub(r'\s*([(),])\s*', r'\1', field_name)
|
||||||
|
# 变量下标解析:WS-FIXED-VALUE(WS-IDX) → WS-FIXED-VALUE(1)
|
||||||
|
vm = re.match(r'^(\w[\w-]*)\((\w[\w-]*)\)$', field_name)
|
||||||
|
if vm:
|
||||||
|
base_var, subscript_var = vm.groups()
|
||||||
|
if subscript_var in rec:
|
||||||
|
try:
|
||||||
|
resolved_name = f'{base_var}({int(rec[subscript_var])})'
|
||||||
|
if any(f['name'] == resolved_name for f in fields):
|
||||||
|
apply_constraint(rec, resolved_name, operator, value, want_true, fields, assignments, path_assign)
|
||||||
|
return
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
# 下标传播:无下标约束 → 应用到所有下标变体
|
||||||
|
base = _basename(field_name)
|
||||||
|
subscripted = [f for f in fields if f['name'] != base and _basename(f['name']) == base]
|
||||||
|
if subscripted and field_name == base:
|
||||||
|
for sf in subscripted:
|
||||||
|
apply_constraint(rec, sf['name'], operator, value, want_true, fields, assignments, path_assign)
|
||||||
|
return
|
||||||
|
|
||||||
|
# REDEFINES 字段的约束重定向到父字段(共享存储)
|
||||||
|
for f in fields:
|
||||||
|
if f['name'] == field_name:
|
||||||
|
if f.get('is_filler'):
|
||||||
|
return
|
||||||
|
if f.get('redefines'):
|
||||||
|
parent_name = f['redefines']
|
||||||
|
logger.debug(f"REDEFINES 约束重定向: {field_name} → {parent_name}")
|
||||||
|
apply_constraint(rec, parent_name, operator, value, want_true, fields, assignments, path_assign)
|
||||||
|
return
|
||||||
|
break
|
||||||
|
if assignments:
|
||||||
|
root_var, chain = trace_to_root(field_name, assignments, fields, path_assign)
|
||||||
|
if root_var != field_name:
|
||||||
|
new_field_name, new_op, new_val = invert_through_chain(root_var, chain, operator, value)
|
||||||
|
if any(f['name'] == new_field_name for f in fields):
|
||||||
|
field_name, operator, value = new_field_name, new_op, new_val
|
||||||
|
|
||||||
|
# 如果当前值已满足该约束,跳过覆盖(保持先前约束的一致性)
|
||||||
|
if _check_constraint_satisfied(rec, field_name, operator, value, want_true, fields):
|
||||||
|
return
|
||||||
|
|
||||||
|
if operator == 'not_in':
|
||||||
|
for f in fields:
|
||||||
|
if f['name'] == field_name:
|
||||||
|
pi = f.get('pic_info', {})
|
||||||
|
cases = value if isinstance(value, list) else []
|
||||||
|
ftype = pi.get('type', 'unknown')
|
||||||
|
if ftype in ('alphanumeric', 'alphabetic'):
|
||||||
|
for c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
|
||||||
|
if c not in cases:
|
||||||
|
rec[field_name] = c.ljust(pi.get('length', 1), c)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
for n in range(1, 100):
|
||||||
|
if str(n) not in cases:
|
||||||
|
rec[field_name] = str(n).zfill(pi.get('digits', 0) + pi.get('decimal', 0))
|
||||||
|
return
|
||||||
|
return
|
||||||
|
# 字段间比较(值侧也是字段名)
|
||||||
|
if any(f['name'] == value for f in fields):
|
||||||
|
if re.search(r'[+\-*/]', field_name):
|
||||||
|
_apply_arith_constraint(rec, field_name, operator, value, want_true, fields)
|
||||||
|
else:
|
||||||
|
logger.debug(f"字段间比较约束跳过:{field_name} {operator} {value}")
|
||||||
|
return
|
||||||
|
for f in fields:
|
||||||
|
if f['name'] == field_name:
|
||||||
|
pi = f.get('pic_info', {})
|
||||||
|
val = satisfying_value(pi, operator, value, want_true)
|
||||||
|
rec[field_name] = val
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 记录生成入口 ──
|
||||||
|
|
||||||
|
def sync_redefined_fields(rec, fields):
|
||||||
|
"""赋值/约束后同步 REDEFINES 字段:父字段的值拷贝到所有 REDEFINES 子字段。"""
|
||||||
|
redefines_map = {}
|
||||||
|
group_redefines = []
|
||||||
|
for f in fields:
|
||||||
|
if f.get('is_88') or f.get('is_filler'):
|
||||||
|
continue
|
||||||
|
if f.get('redefines') and f.get('pic'):
|
||||||
|
redefines_map.setdefault(f['redefines'], []).append(f['name'])
|
||||||
|
elif f.get('redefines') and not f.get('pic'):
|
||||||
|
group_redefines.append((f['name'], f['redefines']))
|
||||||
|
for parent_name, child_names in redefines_map.items():
|
||||||
|
if parent_name in rec:
|
||||||
|
for child_name in child_names:
|
||||||
|
rec[child_name] = rec[parent_name]
|
||||||
|
for redef_name, target_name in group_redefines:
|
||||||
|
redef_kids = _children_of(redef_name, fields)
|
||||||
|
tgt_kids = _children_of(target_name, fields)
|
||||||
|
tgt_idx = 0
|
||||||
|
for i, rk in enumerate(redef_kids):
|
||||||
|
if tgt_idx >= len(tgt_kids):
|
||||||
|
break
|
||||||
|
if i == len(redef_kids) - 1 and len(redef_kids) < len(tgt_kids):
|
||||||
|
parts = [rec.get(tk['name'], '') for tk in tgt_kids[tgt_idx:]]
|
||||||
|
rec[rk['name']] = ''.join(parts)
|
||||||
|
elif i == len(redef_kids) - 1 and len(redef_kids) > len(tgt_kids):
|
||||||
|
rec[rk['name']] = rec.get(tgt_kids[-1]['name'], '')
|
||||||
|
else:
|
||||||
|
rec[rk['name']] = rec.get(tgt_kids[tgt_idx]['name'], '')
|
||||||
|
tgt_idx += 1
|
||||||
|
|
||||||
|
|
||||||
|
def apply_occurs_depending(rec, fields):
|
||||||
|
"""根据 OCCURS DEPENDING ON 变量的当前值,清零超范围的下标字段。"""
|
||||||
|
for f in fields:
|
||||||
|
dep_var = f.get('occurs_depending')
|
||||||
|
if not dep_var:
|
||||||
|
continue
|
||||||
|
name = f['name']
|
||||||
|
m = re.search(r'\((\d+)\)$', name)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
sub = int(m.group(1))
|
||||||
|
max_val = int(rec.get(dep_var, 0))
|
||||||
|
if sub <= max_val:
|
||||||
|
continue
|
||||||
|
pi = f.get('pic_info', {})
|
||||||
|
ftype = pi.get('type', 'unknown')
|
||||||
|
length = pi.get('length', 0) or 1
|
||||||
|
if ftype == 'numeric':
|
||||||
|
rec[name] = '0' * (pi.get('digits', 0) + pi.get('decimal', 0))
|
||||||
|
elif ftype in ('alphanumeric', 'alphabetic'):
|
||||||
|
rec[name] = ' ' * length
|
||||||
|
else:
|
||||||
|
rec[name] = '0' * length
|
||||||
|
|
||||||
|
|
||||||
|
def _non_match_for(cond_leaf, fields):
|
||||||
|
if not fields or not cond_leaf:
|
||||||
|
return None
|
||||||
|
base = re.sub(r'\s*\(.*?\)\s*$', '', cond_leaf.field)
|
||||||
|
for f in fields:
|
||||||
|
if re.sub(r'\s*\(.*?\)\s*$', '', f['name']) == base:
|
||||||
|
pic = f.get('pic_info', {})
|
||||||
|
if pic.get('type') == 'numeric':
|
||||||
|
return '0'
|
||||||
|
return ' '
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _enum_search_paths(node, fields):
|
||||||
|
# 从条件字段名推断 OCCURS 数;如 WS-CODE-VAL(WS-IDX) → 查 WS-CODE-VAL(j) 最大 j
|
||||||
|
occurs_count = 1
|
||||||
|
if node.when_list and node.cond_trees and node.cond_trees[0]:
|
||||||
|
ct = node.cond_trees[0]
|
||||||
|
if isinstance(ct, CondLeaf):
|
||||||
|
base = re.sub(r'\s*\(.*?\)\s*$', '', ct.field)
|
||||||
|
for f in fields:
|
||||||
|
m = re.match(rf'^{re.escape(base)}\((\d+)\)$', f['name'])
|
||||||
|
if m:
|
||||||
|
occurs_count = max(occurs_count, int(m.group(1)))
|
||||||
|
if occurs_count <= 1:
|
||||||
|
# 再查父组名下各字段的后缀
|
||||||
|
parent = node.table_name
|
||||||
|
for f in fields:
|
||||||
|
m = re.match(rf'^{re.escape(parent)}\((\d+)\)$', f['name'])
|
||||||
|
if m:
|
||||||
|
occurs_count = max(occurs_count, int(m.group(1)))
|
||||||
|
|
||||||
|
paths = []
|
||||||
|
for i, (cond_text, body_seq) in enumerate(node.when_list):
|
||||||
|
cond_tree = node.cond_trees[i] if i < len(node.cond_trees) else None
|
||||||
|
sub = _cap_paths(enum_paths(body_seq, fields))
|
||||||
|
if not sub:
|
||||||
|
sub = [([], {})]
|
||||||
|
|
||||||
|
extra_assign = {}
|
||||||
|
if cond_tree and isinstance(cond_tree, CondLeaf):
|
||||||
|
base = re.sub(r'\s*\(.*?\)\s*$', '', cond_tree.field)
|
||||||
|
matching_val = cond_tree.value
|
||||||
|
elem_key = f'{base}({i + 1})'
|
||||||
|
extra_assign[elem_key] = [{'type': 'move_literal', 'literal': matching_val}]
|
||||||
|
non_match = _non_match_for(cond_tree, fields) or ' '
|
||||||
|
for j in range(i):
|
||||||
|
prev_key = f'{base}({j + 1})'
|
||||||
|
extra_assign[prev_key] = [{'type': 'move_literal', 'literal': non_match}]
|
||||||
|
|
||||||
|
for sp_cons, sp_assign in (sub or [([], {})]):
|
||||||
|
merged_assign = dict(extra_assign)
|
||||||
|
for k, v in sp_assign.items():
|
||||||
|
merged_assign.setdefault(k, []).extend(v if isinstance(v, list) else [v])
|
||||||
|
paths.append((sp_cons, merged_assign))
|
||||||
|
|
||||||
|
if node.has_at_end:
|
||||||
|
sub = _cap_paths(enum_paths(node.at_end_seq, fields))
|
||||||
|
for sp_cons, sp_assign in (sub or [([], {})]):
|
||||||
|
extra_assign = {}
|
||||||
|
non_match = ' '
|
||||||
|
if node.when_list:
|
||||||
|
ct = node.cond_trees[0]
|
||||||
|
if ct and isinstance(ct, CondLeaf):
|
||||||
|
non_match = _non_match_for(ct, fields) or ' '
|
||||||
|
base = re.sub(r'\s*\(.*?\)\s*$', '', ct.field)
|
||||||
|
for j in range(max(occurs_count, 1)):
|
||||||
|
extra_assign[f'{base}({j + 1})'] = [{'type': 'move_literal', 'literal': non_match}]
|
||||||
|
merged_assign = dict(extra_assign)
|
||||||
|
for k, v in sp_assign.items():
|
||||||
|
merged_assign.setdefault(k, []).extend(v if isinstance(v, list) else [v])
|
||||||
|
paths.append((sp_cons, merged_assign))
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def generate_records(branch_paths_with_assigns, data_fields, base_assignments=None, file_sec=None):
|
||||||
|
"""生成测试数据记录。
|
||||||
|
branch_paths_with_assigns: list of (constraints, path_assignments).
|
||||||
|
base_assignments: 全局 assignments dict (用于 trace_to_root).
|
||||||
|
返回: (records, kept_path_cons) — kept_path_cons 是与 records 一一对应的约束。
|
||||||
|
"""
|
||||||
|
records = []
|
||||||
|
kept_path_cons = []
|
||||||
|
if branch_paths_with_assigns:
|
||||||
|
for seq, (path_cons, path_assign) in enumerate(branch_paths_with_assigns, start=1):
|
||||||
|
path_cons = _filter_stop(path_cons)
|
||||||
|
rec = make_base_record(seq, data_fields)
|
||||||
|
# Pass A: 先传播赋值(MOVE/COMPUTE/READ INTO 等),模拟到决策点前的程序状态
|
||||||
|
if isinstance(path_assign, dict):
|
||||||
|
propagate_assignments(rec, path_assign, data_fields, file_sec=file_sec)
|
||||||
|
# Pass A.5: 检查约束是否经过链追溯到字面量截断(不可能路径)
|
||||||
|
skip_impossible = False
|
||||||
|
if base_assignments and isinstance(path_assign, dict):
|
||||||
|
for c in path_cons:
|
||||||
|
if len(c) == 4 and not skip_impossible:
|
||||||
|
field, op, val, want = c
|
||||||
|
root_var, chain = trace_to_root(field, base_assignments, data_fields, path_assign)
|
||||||
|
if root_var != field:
|
||||||
|
new_fn, new_op, new_val = invert_through_chain(root_var, chain, op, val)
|
||||||
|
if any(f['name'] == new_fn for f in data_fields):
|
||||||
|
asgn_val = path_assign.get(root_var)
|
||||||
|
if asgn_val is not None:
|
||||||
|
asgn_list = asgn_val if isinstance(asgn_val, list) else [asgn_val]
|
||||||
|
if asgn_list and asgn_list[-1]['type'] == 'move_literal' and root_var in rec:
|
||||||
|
if not _check_constraint_satisfied(rec, root_var, new_op, new_val, want, data_fields):
|
||||||
|
skip_impossible = True
|
||||||
|
break
|
||||||
|
if skip_impossible:
|
||||||
|
continue
|
||||||
|
# Pass B: 约束覆盖(确保决策条件满足,覆盖 MOVE 带来的值)
|
||||||
|
for c in path_cons:
|
||||||
|
if len(c) == 4:
|
||||||
|
field, op, val, want = c
|
||||||
|
apply_constraint(rec, field, op, val, want, data_fields, base_assignments, path_assign)
|
||||||
|
# Pass B.5: 前向再传播变量间MOVE,保持约束修改后的链一致性
|
||||||
|
if isinstance(path_assign, dict):
|
||||||
|
forward = {}
|
||||||
|
for tgt, asgn_val in path_assign.items():
|
||||||
|
asgn_list = asgn_val if isinstance(asgn_val, list) else [asgn_val]
|
||||||
|
filtered = [a for a in asgn_list if a['type'] == 'move' and a.get('source_vars')]
|
||||||
|
if filtered:
|
||||||
|
forward[tgt] = filtered
|
||||||
|
if forward:
|
||||||
|
propagate_assignments(rec, forward, data_fields, file_sec=file_sec)
|
||||||
|
# Pass C: 同步 REDEFINES(确保共享存储一致)
|
||||||
|
sync_redefined_fields(rec, data_fields)
|
||||||
|
# Pass D: OCCURS DEPENDING ON — 清零超范围的下标字段
|
||||||
|
apply_occurs_depending(rec, data_fields)
|
||||||
|
|
||||||
|
records.append(rec)
|
||||||
|
kept_path_cons.append(path_cons)
|
||||||
|
if not records:
|
||||||
|
rec = make_base_record(1, data_fields)
|
||||||
|
if base_assignments:
|
||||||
|
propagate_assignments(rec, base_assignments, data_fields, file_sec=file_sec)
|
||||||
|
records.append(rec)
|
||||||
|
kept_path_cons.append([])
|
||||||
|
return records, kept_path_cons
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
start: data_div_content
|
||||||
|
data_div_content: (file_section | working_storage | linkage)*
|
||||||
|
file_section: "FILE" "SECTION" DOT fd+
|
||||||
|
fd: "FD" NAME FD_SUFFIX data_item+
|
||||||
|
FD_SUFFIX: /(?:"[^"]*"|'[^']*'|[^.])*\./
|
||||||
|
working_storage: "WORKING-STORAGE" "SECTION" DOT data_item*
|
||||||
|
linkage: "LINKAGE" "SECTION" DOT data_item*
|
||||||
|
data_item: level_num (NAME | "FILLER") clause* DOT
|
||||||
|
level_num: LEVEL
|
||||||
|
clause: pic_clause | value_clause | occurs_clause | redefines_clause | usage_clause
|
||||||
|
| "SYNC" | "SYNCHRONIZED"
|
||||||
|
| "JUSTIFIED" "RIGHT"?
|
||||||
|
| "BLANK" "WHEN" "ZERO"
|
||||||
|
| "GLOBAL" | "EXTERNAL"
|
||||||
|
pic_clause: "PIC" "IS"? PICTURE_STRING
|
||||||
|
value_clause: "VALUE" "IS"? value_literal+
|
||||||
|
value_literal: INT | SIGNED_NUMBER | STRING | SQSTRING
|
||||||
|
| "ZERO" | "ZEROS" | "ZEROES"
|
||||||
|
| "SPACE" | "SPACES"
|
||||||
|
| "HIGH-VALUE" | "HIGH-VALUES"
|
||||||
|
| "LOW-VALUE" | "LOW-VALUES"
|
||||||
|
SQSTRING: /'[^']*'/
|
||||||
|
redefines_clause: "REDEFINES" NAME
|
||||||
|
occurs_clause: "OCCURS" INT "TIMES"? ("DEPENDING" "ON" NAME)?
|
||||||
|
usage_clause: USAGE_VAL
|
||||||
|
USAGE_VAL: "COMP" | "COMP-3" | "COMP-5" | "BINARY" | "PACKED-DECIMAL" | "DISPLAY"
|
||||||
|
LEVEL: /0[1-9]|[1-4][0-9]|49|77|88/
|
||||||
|
NAME: /[A-Z][A-Z0-9-]*/
|
||||||
|
PICTURE_STRING: /[0-9A-Z()+,\-*\/V]+/i
|
||||||
|
INT: /[0-9]+/
|
||||||
|
DOT: /\./
|
||||||
|
%import common.SIGNED_NUMBER
|
||||||
|
%import common.ESCAPED_STRING -> STRING
|
||||||
|
%import common.WS
|
||||||
|
%ignore WS
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""COBOL数据模型 — 所有层共享,无外部依赖"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
# ── 字段定义 ──
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PicInfo:
|
||||||
|
type: str = 'unknown' # "numeric" | "alphanumeric" | "alphabetic"
|
||||||
|
digits: int = 0
|
||||||
|
decimal: int = 0
|
||||||
|
length: int = 0
|
||||||
|
signed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FieldDef:
|
||||||
|
name: str
|
||||||
|
level: int
|
||||||
|
pic: str | None = None
|
||||||
|
pic_info: PicInfo | None = None
|
||||||
|
is_filler: bool = False
|
||||||
|
occurs_count: int = 0
|
||||||
|
occurs_depending: str | None = None
|
||||||
|
redefines: str | None = None
|
||||||
|
usage: str | None = None # "COMP" | "COMP-3" | "BINARY" | "PACKED-DECIMAL" | ...
|
||||||
|
value: str | None = None
|
||||||
|
values: list[str] | None = None
|
||||||
|
is_88: bool = False
|
||||||
|
parent: str | None = None
|
||||||
|
section: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 分支树 ──
|
||||||
|
|
||||||
|
class BrSeq:
|
||||||
|
def __init__(self):
|
||||||
|
self.children = []
|
||||||
|
|
||||||
|
def add(self, child):
|
||||||
|
self.children.append(child)
|
||||||
|
|
||||||
|
|
||||||
|
class BrIf:
|
||||||
|
def __init__(self, condition):
|
||||||
|
self.condition = condition
|
||||||
|
self.cond_tree = None # 由 core.py 在解析时赋值
|
||||||
|
self.true_seq = BrSeq()
|
||||||
|
self.false_seq = BrSeq()
|
||||||
|
|
||||||
|
|
||||||
|
class BrEval:
|
||||||
|
def __init__(self, subject):
|
||||||
|
self.subject = subject
|
||||||
|
self.subjects = [] # ALSO 多主体: ['WS-A', 'WS-B'],空=普通模式
|
||||||
|
self.when_list = []
|
||||||
|
self.other_seq = BrSeq()
|
||||||
|
self.has_other = False
|
||||||
|
|
||||||
|
|
||||||
|
class BrPerform:
|
||||||
|
def __init__(self, perf_type, condition=None, target=None, thru=None, times=None,
|
||||||
|
varying_var=None, varying_from=None, varying_by=None):
|
||||||
|
self.perf_type = perf_type
|
||||||
|
self.condition = condition
|
||||||
|
self.target = target
|
||||||
|
self.thru = thru
|
||||||
|
self.times = times
|
||||||
|
self.varying_var = varying_var
|
||||||
|
self.varying_from = varying_from
|
||||||
|
self.varying_by = varying_by
|
||||||
|
self.body_seq = BrSeq()
|
||||||
|
|
||||||
|
|
||||||
|
class Assign:
|
||||||
|
"""赋值节点:MOVE/COMPUTE/ADD/SUBTRACT/MULTIPLY/DIVIDE"""
|
||||||
|
def __init__(self, target: str, source_info: dict):
|
||||||
|
self.target = target
|
||||||
|
self.source_info = source_info
|
||||||
|
|
||||||
|
|
||||||
|
class CallNode:
|
||||||
|
"""CALL 子程序调用节点(黑盒模式)"""
|
||||||
|
def __init__(self, program_name: str, using_params: list = None):
|
||||||
|
self.program_name = program_name
|
||||||
|
self.using_params = using_params or []
|
||||||
|
# using_params: [{"name": "WS-A", "mechanism": "reference"}, ...]
|
||||||
|
# mechanism: "reference" | "content" | "value"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 条件树 ──
|
||||||
|
|
||||||
|
class CondLeaf:
|
||||||
|
def __init__(self, field, op, value):
|
||||||
|
self.field = field
|
||||||
|
self.op = op
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class CondNot:
|
||||||
|
def __init__(self, child):
|
||||||
|
self.child = child
|
||||||
|
|
||||||
|
|
||||||
|
class CondAnd:
|
||||||
|
def __init__(self, left, right):
|
||||||
|
self.left = left
|
||||||
|
self.right = right
|
||||||
|
|
||||||
|
|
||||||
|
class CondOr:
|
||||||
|
def __init__(self, left, right):
|
||||||
|
self.left = left
|
||||||
|
self.right = right
|
||||||
|
|
||||||
|
|
||||||
|
class BrSearch:
|
||||||
|
"""SEARCH / SEARCH ALL 表查找"""
|
||||||
|
def __init__(self, table_name, is_all=False, varying=None):
|
||||||
|
self.table_name = table_name
|
||||||
|
self.is_all = is_all
|
||||||
|
self.varying = varying.upper() if varying else None
|
||||||
|
self.at_end_seq = BrSeq()
|
||||||
|
self.when_list = [] # [(condition_text, BrSeq)]
|
||||||
|
self.cond_trees = [] # [cond_tree, ...]
|
||||||
|
self.has_at_end = False
|
||||||
|
|
||||||
|
|
||||||
|
class GoTo:
|
||||||
|
"""GO TO 节点:无条件跳转到指定段落"""
|
||||||
|
def __init__(self, target: str, body_seq: 'BrSeq' = None):
|
||||||
|
self.target = target
|
||||||
|
self.body_seq = body_seq or BrSeq()
|
||||||
|
|
||||||
|
|
||||||
|
class ExitNode:
|
||||||
|
"""控制流退出节点:EXIT PARAGRAPH / EXIT PERFORM / EXIT SECTION / EXIT PROGRAM"""
|
||||||
|
def __init__(self, exit_type: str):
|
||||||
|
self.exit_type = exit_type
|
||||||
|
|
||||||
|
|
||||||
|
# ── 约束路径 ──
|
||||||
|
|
||||||
|
Constraint = tuple # (field, op, value, want_true)
|
||||||
|
Path = list[Constraint]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 解析错误 ──
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParseError:
|
||||||
|
line: int
|
||||||
|
message: str
|
||||||
|
severity: str = 'warning'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcParseResult:
|
||||||
|
tree: BrSeq | None = None
|
||||||
|
assignments: dict = field(default_factory=dict)
|
||||||
|
errors: list[ParseError] = field(default_factory=list)
|
||||||
|
fallback_to_ai: bool = False
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""输出层:JSON输出(按文件分组入出力 + 工作存储区分)"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_INVERSE_OP = {'>': '<=', '<': '>=', '=': '<>', '>=': '<', '<=': '>'}
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_text(path_cons):
|
||||||
|
parts = []
|
||||||
|
for c in path_cons:
|
||||||
|
if len(c) != 4:
|
||||||
|
continue
|
||||||
|
field, op, val, want = c
|
||||||
|
if op == 'not_in':
|
||||||
|
desc = f"{field} not in {val}" if want else f"{field} in {val}"
|
||||||
|
elif not want:
|
||||||
|
desc = f"{field} {_INVERSE_OP.get(op, '?' + op)} {val}"
|
||||||
|
else:
|
||||||
|
desc = f"{field} {op} {val}"
|
||||||
|
parts.append(desc)
|
||||||
|
return ', '.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def output_json(records, outpath, roles=None, fd_fields=None, field_to_fd=None,
|
||||||
|
open_dir=None, path_cons_list=None):
|
||||||
|
outpath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not roles:
|
||||||
|
with open(outpath, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(records, f, ensure_ascii=False, indent=2)
|
||||||
|
return
|
||||||
|
|
||||||
|
# FD direction lookup
|
||||||
|
out = []
|
||||||
|
for i, rec in enumerate(records):
|
||||||
|
inp = {}
|
||||||
|
out_exp = {}
|
||||||
|
ws = {}
|
||||||
|
|
||||||
|
# Group by FD
|
||||||
|
if fd_fields and field_to_fd:
|
||||||
|
for fd_name, fds_set in fd_fields.items():
|
||||||
|
direction = (open_dir or {}).get(fd_name, '')
|
||||||
|
inp_block = {}
|
||||||
|
out_block = {}
|
||||||
|
for fname in fds_set:
|
||||||
|
if fname not in rec:
|
||||||
|
continue
|
||||||
|
r = roles.get(fname, 'unused')
|
||||||
|
val = rec[fname]
|
||||||
|
if direction in ('INPUT', 'I-O') and r in ('input', 'inout'):
|
||||||
|
inp_block[fname] = val
|
||||||
|
if direction in ('OUTPUT', 'I-O') and r in ('output', 'inout'):
|
||||||
|
out_block[fname] = val
|
||||||
|
if inp_block:
|
||||||
|
inp[fd_name] = inp_block
|
||||||
|
if out_block:
|
||||||
|
out_exp[fd_name] = out_block
|
||||||
|
|
||||||
|
# Working-storage: not belonging to any FD
|
||||||
|
for name, val in rec.items():
|
||||||
|
if not field_to_fd or name not in field_to_fd:
|
||||||
|
ws[name] = val
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
'input': inp,
|
||||||
|
'expected_output': out_exp,
|
||||||
|
'working_storage': ws,
|
||||||
|
}
|
||||||
|
|
||||||
|
if path_cons_list and i < len(path_cons_list):
|
||||||
|
text = _scenario_text(path_cons_list[i])
|
||||||
|
if text:
|
||||||
|
entry['scenario'] = text
|
||||||
|
|
||||||
|
out.append(entry)
|
||||||
|
|
||||||
|
with open(outpath, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(out, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def output_input_files(records, outdir, stem, roles, fd_fields, field_to_fd, open_dir):
|
||||||
|
"""按 FD 名拆分出力入力 JSON 文件。
|
||||||
|
每个 INPUT / I-O 方向 FD 生成一个文件:{stem}_{fd_name}.json
|
||||||
|
内容为路径数 × 记录,每条只含该 FD 的入力字段值。
|
||||||
|
"""
|
||||||
|
input_fds = {}
|
||||||
|
for fd_name, fds_set in fd_fields.items():
|
||||||
|
direction = (open_dir or {}).get(fd_name, '')
|
||||||
|
if direction not in ('INPUT', 'I-O'):
|
||||||
|
continue
|
||||||
|
has_input = any(roles.get(fname, 'unused') in ('input', 'inout') for fname in fds_set)
|
||||||
|
if not has_input:
|
||||||
|
continue
|
||||||
|
input_fds[fd_name] = fds_set
|
||||||
|
|
||||||
|
if not input_fds:
|
||||||
|
return
|
||||||
|
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for fd_name, fds_set in input_fds.items():
|
||||||
|
fd_records = []
|
||||||
|
direction = (open_dir or {}).get(fd_name, '')
|
||||||
|
for rec in records:
|
||||||
|
fd_rec = {}
|
||||||
|
for fname in fds_set:
|
||||||
|
r = roles.get(fname, 'unused')
|
||||||
|
if direction in ('INPUT', 'I-O') and r in ('input', 'inout'):
|
||||||
|
if fname in rec:
|
||||||
|
fd_rec[fname] = rec[fname]
|
||||||
|
if fd_rec:
|
||||||
|
fd_records.append(fd_rec)
|
||||||
|
|
||||||
|
outpath = outdir / f'{stem}_{fd_name}.json'
|
||||||
|
with open(outpath, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(fd_records, f, ensure_ascii=False, indent=2)
|
||||||
@@ -256,10 +256,8 @@ class DataTransformer(Transformer):
|
|||||||
values.append(val)
|
values.append(val)
|
||||||
return {'value': values[0], 'values': values} if values else {'value': None}
|
return {'value': values[0], 'values': values} if values else {'value': None}
|
||||||
|
|
||||||
def value_literal(self, *args):
|
def value_literal(self, token):
|
||||||
if args:
|
return str(token)
|
||||||
return str(args[-1])
|
|
||||||
return ''
|
|
||||||
|
|
||||||
def occurs_clause(self, *args):
|
def occurs_clause(self, *args):
|
||||||
result = {'occurs': int(args[0])}
|
result = {'occurs': int(args[0])}
|
||||||
@@ -437,6 +435,5 @@ def scan_open_statements(source: str) -> dict:
|
|||||||
):
|
):
|
||||||
direction = seg_m.group(1).upper()
|
direction = seg_m.group(1).upper()
|
||||||
for fname in re.findall(r'\w[\w-]*', seg_m.group(2)):
|
for fname in re.findall(r'\w[\w-]*', seg_m.group(2)):
|
||||||
if fname.upper() not in ('INPUT', 'OUTPUT', 'I-O'):
|
|
||||||
dirs[fname.upper()] = direction
|
dirs[fname.upper()] = direction
|
||||||
return dirs
|
return dirs
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ class Config:
|
|||||||
num_records: int = 1000
|
num_records: int = 1000
|
||||||
branch_pass: float = 0.80
|
branch_pass: float = 0.80
|
||||||
max_llm_cost: float = 0.50
|
max_llm_cost: float = 0.50
|
||||||
quality_gate_mode: str = "warn"
|
|
||||||
quality_gate_decision_threshold: float = 0.90
|
|
||||||
quality_gate_paragraph_threshold: float = 1.0
|
|
||||||
gcov_enabled: bool = False
|
|
||||||
max_quality_retries: int = 4
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_toml(cls, path="aurak.toml"):
|
def from_toml(cls, path="aurak.toml"):
|
||||||
|
|||||||
@@ -28,15 +28,6 @@ class VerificationRun:
|
|||||||
field_results: list[FieldResult] = field(default_factory=list)
|
field_results: list[FieldResult] = field(default_factory=list)
|
||||||
runner: str = "native"
|
runner: str = "native"
|
||||||
branch_rate: float = 0.0
|
branch_rate: float = 0.0
|
||||||
paragraph_rate: float = 0.0 # 段落覆盖率
|
|
||||||
decision_rate: float = 0.0 # 决策点覆盖率
|
|
||||||
hina_type: str = "" # HINA 类型
|
|
||||||
hina_confidence: float = 0.0 # HINA 确信度
|
|
||||||
quality_score: float = 0.0 # 质量评分
|
|
||||||
quality_warn: str = "" # 质量警告信息
|
|
||||||
heal_retry: int = 0 # 自愈重试次数
|
|
||||||
simple_retry: int = 0 # 朴素重试次数
|
|
||||||
total_retry: int = 0 # 总重试次数
|
|
||||||
llm_cost: float = 0.0
|
llm_cost: float = 0.0
|
||||||
report_path: str = ""
|
report_path: str = ""
|
||||||
debug: dict = field(default_factory=dict)
|
debug: dict = field(default_factory=dict)
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
# 增强测试系统 — 全面测试计划 v1.0
|
|
||||||
|
|
||||||
> 日期: 2026-06-17 | 対象: feat/enhanced-test-phase1
|
|
||||||
> 測試范围: cobol_testgen API / HINA分类 / 质量门禁 / 分层重试 / 增强报告
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试策略
|
|
||||||
|
|
||||||
### 测试层次
|
|
||||||
|
|
||||||
```
|
|
||||||
L1: ユニットテスト ─ 各関数の単体動作 (pytest, ~50 tests)
|
|
||||||
├── cobol_testgen API
|
|
||||||
├── HINA classifier
|
|
||||||
├── HINA strategy
|
|
||||||
├── quality gate
|
|
||||||
├── retry handler
|
|
||||||
└── report generator
|
|
||||||
|
|
||||||
L2: 結合テスト ─ モジュール間連携 (pytest, ~20 tests)
|
|
||||||
├── extract_structure → generate_data の一貫性
|
|
||||||
├── generate_data → DataWriter の型整合
|
|
||||||
├── HINA 分類 → 戦略テンプレート のマッピング
|
|
||||||
└── quality gate → orchestrator のループ制御
|
|
||||||
|
|
||||||
L3: 統合テスト ─ パイプライン全体 (test-data/ 10 programs, ~10 tests)
|
|
||||||
├── HINA001: 1:1 マッチング
|
|
||||||
├── HINA005: IF条件分岐
|
|
||||||
├── HINA025: CALL
|
|
||||||
└── HINA101: EXEC SQL
|
|
||||||
|
|
||||||
L4: 実COBOLプログラム (jcl-cobol-git/ 4 programs, ~4 tests)
|
|
||||||
├── CRDVAL / CRDCALC / CRDRPT / GENDATA
|
|
||||||
└── 実際の金銭計算との一致確認
|
|
||||||
|
|
||||||
L5: レグレッションテスト ─ 既存42テストの完全通過
|
|
||||||
```
|
|
||||||
|
|
||||||
### テスト手法
|
|
||||||
|
|
||||||
| 手法 | 適用レベル | 説明 |
|
|
||||||
|:-----|:----------|:------|
|
|
||||||
| TDD (レッド・グリーン) | L1 | テストを先に書き、実装で通す |
|
|
||||||
| ゴールデンテスト | L3-L4 | 既知の正解値との一致確認 |
|
|
||||||
| ファジング | L2 | 不正なCOBOL入力に対する耐性 |
|
|
||||||
| 境界値分析 | L1-L2 | PIC 桁数境界、空値、極大値 |
|
|
||||||
| エラー注入 | L2 | LLM timeout/malformed response の動作確認 |
|
|
||||||
| デグレードテスト | L2 | gcov failure/absence 時の降格確認 |
|
|
||||||
| 静的カバレッジ | L1-L2 | cobol_testgen の静的パス網羅率 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L1: ユニットテスト
|
|
||||||
|
|
||||||
### 1.1 cobol_testgen API
|
|
||||||
|
|
||||||
| # | テスト名 | 内容 | 入力 | 期待出力 |
|
|
||||||
|:-:|:---------|:-----|:-----|:---------|
|
|
||||||
| UT-01 | extract_structure: 空プログラム | 空文字列 | `{"total_branches": 0}` |
|
|
||||||
| UT-02 | extract_structure: IF 1個 | `IF A > B ... ELSE ...` | branches=2, decisions=1 |
|
|
||||||
| UT-03 | extract_structure: EVALUATE | `EVALUATE X WHEN 1 ... WHEN OTHER` | decisions=1, WHEN数確認 |
|
|
||||||
| UT-04 | extract_structure: 複数ファイル | 3ファイルのプログラム | file_count=3 open_directions確認 |
|
|
||||||
| UT-05 | extract_structure: CALL文 | `CALL 'SUBPGM'` | has_call=True |
|
|
||||||
| UT-06 | extract_structure: SEARCH ALL | OCCURS+SEARCH ALL | has_search_all=True |
|
|
||||||
| UT-07 | extract_structure: 固定形式 | 7桁目からコードの固定形式 | 正常解析(段落数>0) |
|
|
||||||
| UT-08 | generate_data: 正常生成 | IFプログラム | 2件以上のデータ |
|
|
||||||
| UT-09 | generate_data: 空プログラム | 分岐なし | 0件または1件 |
|
|
||||||
| UT-10 | incremental_supplement: 差分生成 | 未カバーID指定 | IDに対応するデータのみ |
|
|
||||||
| UT-11 | incremental_supplement: 存在しないID | [-1] | 空リスト |
|
|
||||||
| UT-12 | check_coverage: 静的報告 | structureのみ | "note"に静的限界の記述 |
|
|
||||||
| UT-13 | _cobol_testgen_to_testcases: 型変換 | list[dict] | list[TestCase] |
|
|
||||||
|
|
||||||
### 1.2 HINA Classifier
|
|
||||||
|
|
||||||
| # | テスト名 | 内容 | 入力 | 期待出力 |
|
|
||||||
|:-:|:---------|:-----|:-----|:---------|
|
|
||||||
| HC-01 | L1: DB操作 | `EXEC SQL SELECT` | category="DB操作" ≥90% |
|
|
||||||
| HC-02 | L1: 子程序调用 | `CALL 'SUBPGM' ... LINKAGE SECTION` | category="子程序调用" ≥90% |
|
|
||||||
| HC-03 | L1: SORT | `SORT WORK-FILE ON KEY` | category="SORT" ≥90% |
|
|
||||||
| HC-04 | L1: IS INITIAL | `PROGRAM-ID. X IS INITIAL.` | category="IS INITIAL" ≥90% |
|
|
||||||
| HC-05 | L1: 编辑输出 | `WRITE AFTER ADVANCING` | category="编辑输出" ≥80% |
|
|
||||||
| HC-06 | L1: 文件编成 | `ORGANIZATION IS` | category="文件编成" ≥90% |
|
|
||||||
| HC-07 | L1: キーワード重複 | DB操作+CALL両方 | 最大確信度のキーワード勝ち |
|
|
||||||
| HC-08 | compute_confidence: L1≥90% | L1のみ | method="keyword" |
|
|
||||||
| HC-09 | compute_confidence: LLM結果 | LLM result | method="hybrid" |
|
|
||||||
| HC-10 | compute_confidence: 両方なし | キーワード無し+LLM無し | category="unknown" confidence=0 |
|
|
||||||
|
|
||||||
### 1.3 HINA Strategy
|
|
||||||
|
|
||||||
| # | テスト名 | 内容 | 期待出力 |
|
|
||||||
|:-:|:---------|:-----|:---------|
|
|
||||||
| HS-01 | get_strategy: マッチング | 9 required items |
|
|
||||||
| HS-02 | get_strategy: キーブレイク | 6 required items |
|
|
||||||
| HS-03 | get_strategy: 条件分岐 | 4 required items |
|
|
||||||
| HS-04 | get_strategy: 未知のタイプ | 空テンプレート |
|
|
||||||
| HS-05 | supplement: マーカー追加 | マーカーレコード含むlist |
|
|
||||||
| HS-06 | supplement_only: 特定ギャップ | 指定IDのみのマーカー |
|
|
||||||
|
|
||||||
### 1.4 Quality Gate
|
|
||||||
|
|
||||||
| # | テスト名 | 内容 | 入力 | 期待 |
|
|
||||||
|:-:|:---------|:-----|:-----|:------|
|
|
||||||
| QG-01 | 全通過 | branch≥95%, paragraph=100% | passed=True |
|
|
||||||
| QG-02 | 分岐不足 | branch=80% | passed=False, decision_gaps有 |
|
|
||||||
| QG-03 | 段落不足 | paragraph=0.5 | passed=False |
|
|
||||||
| QG-04 | データ無し | empty list | passed=False, no_data=True |
|
|
||||||
| QG-05 | スコア計算 | branch=0.92, para=1.0 | score=0.976 | 例: (1.0×0.5+0.92×0.5)×0.6+1.0×0.4=0.976 |
|
|
||||||
|
|
||||||
### 1.5 Retry Handler
|
|
||||||
|
|
||||||
| # | テスト名 | 内容 | 期待 |
|
|
||||||
|:-:|:---------|:-----|:------|
|
|
||||||
| RH-01 | 即時PASS | 1回目でPASS | heal=0, simple=0 |
|
|
||||||
| RH-02 | heal回復 | BLOCKED→環境修正→PASS | heal=1, simple=0 |
|
|
||||||
| RH-03 | simple回復 | BLOCKED→リトライ→PASS | heal=0, simple=1 |
|
|
||||||
| RH-04 | 上限超過 | 全てFAIL | status=FATAL |
|
|
||||||
| RH-05 | QUALITY_WARNはリトライ不要 | QUALITY_WARN→即戻り | heal=0, simple=0 |
|
|
||||||
|
|
||||||
### 1.6 Report Generator
|
|
||||||
|
|
||||||
| # | テスト名 | 内容 | 期待 |
|
|
||||||
|:-:|:---------|:-----|:------|
|
|
||||||
| RG-01 | generate_json: 新フィールド | VerificationRun全フィールド | JSONに全フィールド含む |
|
|
||||||
| RG-02 | generate_html: カバレッジ表示 | paragraph_rate>0 | "段落覆盖率"表示 |
|
|
||||||
| RG-03 | generate_html: HINA表示 | hina_type設定 | "判定类型"表示 |
|
|
||||||
| RG-04 | generate_html: HINA非表示 | hina_type="" | HINAセクション無し |
|
|
||||||
| RG-05 | generate_html: 品質スコア表示 | quality_score>0 | "质量评分"表示 |
|
|
||||||
| RG-06 | generate_html: 品質スコア非表示 | quality_score=0 | 品質セクション無し |
|
|
||||||
| RG-07 | generate_html: 警告表示 | quality_warn設定 | 警告バナー表示 |
|
|
||||||
| RG-08 | generate_machine_json: 全フィールド | VerificationRun | branch_rate等を含む |
|
|
||||||
| RG-09 | generate_json: 後方互換 | 新フィールド未設定 | 既存JSONと同じ構造 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L2: 結合テスト
|
|
||||||
|
|
||||||
| # | テスト名 | シナリオ | 期待 |
|
|
||||||
|:-:|:---------|:---------|:------|
|
|
||||||
| CT-01 | extract→generate 一貫性 | 同一ソースでextract→generate | generate_dataがデータ生成可能 |
|
|
||||||
| CT-02 | HINA→Strategy マッピング | マッチング分類→全マーカー生成 | 9個のマーカー |
|
|
||||||
| CT-03 | QG→incremental ループ制御 | 分岐不足→supplement→再検査 | passed=Trueになる |
|
|
||||||
| CT-04 | strategy→TestCase 型整合 | supplement出力→TestCase変換 | TestCaseオブジェクトとして利用可 |
|
|
||||||
| CT-05 | orchestrator: 正常系 | cobol_testgen→HINA→QG→DataWriter | complete_testsがDataWriterに渡る |
|
|
||||||
| CT-06 | orchestrator: LLM例外 | HINA Agentが例外発生 | エラーログ出力、パイプライン継続 |
|
|
||||||
| CT-07 | orchestrator: gcov無効 | gcov_enabled=False | 動的カバレッジスキップ |
|
|
||||||
| CT-08 | gcov_collector: 非インストール | gcovコマンド不在 | available=False |
|
|
||||||
| CT-09 | gcov_collector: 正常 | .gcda/.gcno存在 | available=True, line_rate計算 |
|
|
||||||
| CT-10 | Config: 品質ゲート設定 | aurak.toml変更→from_toml | quality_gate_mode=warn |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L3: HINA 統合テスト
|
|
||||||
|
|
||||||
test-data/cobol/HINA*.cbl の10プログラムを使用:
|
|
||||||
|
|
||||||
| # | プログラム | 検証項目 | 期待 |
|
|
||||||
|:-:|:----------|:---------|:------|
|
|
||||||
| IT-01 | HINA001 | マッチング構造解析 | 段落≥8, ファイル≥2 |
|
|
||||||
| IT-02 | HINA005 | IF分岐カバレッジ | 分岐≥6, 決定点≥3 |
|
|
||||||
| IT-03 | HINA006 | EVALUATEカバレッジ | 分岐≥6, 決定点≥3 |
|
|
||||||
| IT-04 | HINA007 | キーブレイク解析 | 段落≥3, ファイル≥2 |
|
|
||||||
| IT-05 | HINA013 | 項目チェック解析 | 分岐≥6, 決定点≥3 |
|
|
||||||
| IT-06 | HINA025 | L1分類+CALL解析 | HINA="子程序调用", confidence≥90% |
|
|
||||||
| IT-07 | HINA101 | L1分類+SQL解析 | HINA="DB操作", confidence≥95% |
|
|
||||||
| IT-08 | run_validation.py全実行 | 全HINAプログラム | 8/10 pass (既知制限2件) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L4: 実COBOLプログラム統合
|
|
||||||
|
|
||||||
jcl-cobol-git/ の4プログラムを使用:
|
|
||||||
|
|
||||||
| # | プログラム | 検証項目 | 期待 |
|
|
||||||
|:-:|:----------|:---------|:------|
|
|
||||||
| RT-01 | CRDVAL | COPYBOOK展開+全パイプライン | エラー無し |
|
|
||||||
| RT-02 | CRDCALC | 同上 | 同上 |
|
|
||||||
| RT-03 | CRDRPT | 同上 | 同上 |
|
|
||||||
| RT-04 | GENDATA | 同上 | 同上 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L5: レグレッションテスト
|
|
||||||
|
|
||||||
| # | テスト | コマンド | 期待 |
|
|
||||||
|:-:|:-------|:---------|:------|
|
|
||||||
| RG-01 | comparator 全テスト | `pytest tests/comparator/ -v` | 22 passed |
|
|
||||||
| RG-02 | report 全テスト | `pytest tests/report/ -v` | 3 passed |
|
|
||||||
| RG-03 | golden 全テスト | `pytest tests/test_golden.py -v` | 11 passed |
|
|
||||||
| RG-04 | e2e imports | `pytest tests/test_e2e.py -v` | 1 passed |
|
|
||||||
| RG-05 | 全ユニット | `pytest tests/ --ignore=e2e/ --ignore=test_web_e2e.py --ignore=test_biz_e2e.py -v` | 42 passed |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## エッジケーステスト
|
|
||||||
|
|
||||||
| # | シナリオ | 入力 | 期待 |
|
|
||||||
|:-:|:---------|:-----|:------|
|
|
||||||
| EC-01 | 空COBOL | `IDENTIFICATION DIVISION. PROGRAM-ID. X.` | エラー無し |
|
|
||||||
| EC-02 | 巨大プログラム | 1万行レベル | タイムアウト無し(30秒以内) |
|
|
||||||
| EC-03 | 日本語文字列 | PIC N 全角データ | extract正常 |
|
|
||||||
| EC-04 | REDEFINES | REDEFINES使用プログラム | 正常解析 |
|
|
||||||
| EC-05 | OCCURS DEPENDING | ODO使用 | 正常解析 |
|
|
||||||
| EC-06 | 88-level値 | 88-level多数 | is_88=Trueで認識 |
|
|
||||||
| EC-07 | コメントのみ | 全行コメント | エラー無し |
|
|
||||||
| EC-08 | 不正PIC | `PIC X`の代わりに`PIC XXX` | 正常 |
|
|
||||||
| EC-09 | 空ファイルパス | --cobol-srcで存在しないファイル | BLOCKED |
|
|
||||||
| EC-10 | Lark文法エラー | 予期しない文字列 | 空構造、エラーログ出力 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## エラー注入テスト
|
|
||||||
|
|
||||||
| # | シナリオ | 注入方法 | 期待 |
|
|
||||||
|:-:|:---------|:---------|:------|
|
|
||||||
| EI-01 | LLMタイムアウト | LLMClient.call でtimeout | フォールバック実行、ログ出力 |
|
|
||||||
| EI-02 | LLM不正JSON | 応答が無効JSON | _fallback_classification 使用 |
|
|
||||||
| EI-03 | LLM空文字 | 応答が空文字 | 同上 |
|
|
||||||
| EI-04 | gcovコマンド不在 | gcov利用不可 | available=False reason=gcov_not_installed |
|
|
||||||
| EI-05 | gcov出力異常 | 不正な.gcovファイル | available=False reason=gcov_failed |
|
|
||||||
| EI-06 | extract_structure 解析失敗 | Larkがパースできない入力 | 空構造返却、ログ出力 |
|
|
||||||
| EI-07 | generate_data 空結果 | 分岐0のプログラム | 空リスト返却 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## カバレッジ計測
|
|
||||||
|
|
||||||
```
|
|
||||||
目標カバレッジ (pytest --cov):
|
|
||||||
cobol_testgen API: ≥ 80% (主要3関数)
|
|
||||||
hina/classifier.py: ≥ 90% (L1ルール全カバー)
|
|
||||||
hina/gate.py: ≥ 95% (全分岐)
|
|
||||||
hina/retry.py: ≥ 90% (全リトライパス)
|
|
||||||
report/generator.py: ≥ 70% (HTMLテンプレート網羅)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## テスト実行計画
|
|
||||||
|
|
||||||
### Phase A: ユニットテスト (並列実行可、~5分)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 全ユニット
|
|
||||||
pytest tests/ -v --ignore=tests/e2e/ --ignore=tests/test_web_e2e.py --ignore=tests/test_biz_e2e.py
|
|
||||||
|
|
||||||
# 2. カバレッジ計測
|
|
||||||
pytest --cov=cobol_testgen --cov=hina --cov=report --cov=data tests/ -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase B: HINA統合テスト (~2分)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python test-data/run_validation.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase C: レグレッション (~1分)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/comparator/ tests/report/ tests/test_golden.py tests/test_e2e.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase D: 実COBOLテスト (~5分、WSL + GnuCOBOL必要)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# WSL側で実行
|
|
||||||
python3 -m pytest tests/test_golden.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 期待結果サマリー
|
|
||||||
|
|
||||||
| テスト種別 | 予定数 | 最低合格数 | 合格率目標 |
|
|
||||||
|:----------|:------:|:----------:|:---------:|
|
|
||||||
| L1 ユニット | ~45 | 45 | 100% |
|
|
||||||
| L2 結合 | ~10 | 10 | 100% |
|
|
||||||
| L3 HINA統合 | 8 | 8 | 100% |
|
|
||||||
| L4 実COBOL | 4 | 4 | 100% |
|
|
||||||
| L5 レグレッション | 42 | 42 | 100% |
|
|
||||||
| エッジケース | 10 | 10 | 100% |
|
|
||||||
| エラー注入 | 7 | 7 | 100% |
|
|
||||||
| **総計** | **~126** | **126** | **100%** |
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# HINA 程序分类与质量门禁包
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
"""
|
|
||||||
HINA 程序分类器 — L1 关键字规则 + 确信度计算。
|
|
||||||
|
|
||||||
通过 COBOL 源码中的关键字匹配进行程序分类,支持多级确信度判定。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
# ── L1 规则 ──────────────────────────────────────────────────────────────
|
|
||||||
# 格式: (分类名称, [关键字列表], 置信度阈值)
|
|
||||||
L1_RULES: list[tuple[str, list[str], float]] = [
|
|
||||||
("DB操作", ["EXEC SQL"], 0.95),
|
|
||||||
("子程序调用", ["CALL", "LINKAGE SECTION"], 0.90),
|
|
||||||
("IS INITIAL", ["IS INITIAL"], 0.99),
|
|
||||||
("SYSIN", ["SYSIN"], 0.90),
|
|
||||||
("编码转换", ["ALPHABETIC", "ASCII", "EBCDIC"], 0.85),
|
|
||||||
("online", ["DFHCOMMAREA", "MAP"], 0.95),
|
|
||||||
("SORT", ["SORT ON KEY"], 0.95),
|
|
||||||
("MERGE", ["MERGE ON KEY"], 0.95),
|
|
||||||
("编辑输出", ["WRITE AFTER", "WRITE BEFORE"], 0.80),
|
|
||||||
("文件编成", ["ORGANIZATION IS"], 0.99),
|
|
||||||
("替代索引", ["ALTERNATE RECORD KEY"], 0.99),
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── 冲突解决规则 ─────────────────────────────────────────────────────────
|
|
||||||
# 当 L1 匹配到多个分类时的消歧策略:
|
|
||||||
# value = "file_count" → 取测试数更多的分类
|
|
||||||
# value = "has_accumulator" → 取包含累加器的分类
|
|
||||||
CONFLICT_RULES: dict[tuple[str, str], str] = {
|
|
||||||
("マッチング", "キーブレイク"): "file_count",
|
|
||||||
("編集処理", "項目チェック"): "file_count",
|
|
||||||
("キーブレイク", "項目チェック(重複)"): "has_accumulator",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 关键字检测 ───────────────────────────────────────────────────────────
|
|
||||||
def detect_keyword(source: str) -> list[tuple[str, float, str]]:
|
|
||||||
"""在 COBOL 源码中搜索 L1_RULES 定义的关键字,返回匹配结果。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source: COBOL 程序源码文本。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[tuple[str, float, str]]:
|
|
||||||
每个元素为 (分类名称, 置信度, 匹配到的关键字原文)。
|
|
||||||
"""
|
|
||||||
results: list[tuple[str, float, str]] = []
|
|
||||||
source_upper = source.upper()
|
|
||||||
|
|
||||||
for category, keywords, confidence in L1_RULES:
|
|
||||||
for kw in keywords:
|
|
||||||
if kw in source_upper:
|
|
||||||
results.append((category, confidence, kw))
|
|
||||||
break # 同一分类只记录一次
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
# ── 确信度计算 ───────────────────────────────────────────────────────────
|
|
||||||
def compute_confidence(
|
|
||||||
source: str,
|
|
||||||
structure: dict[str, Any] | None = None,
|
|
||||||
llm_result: dict[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""计算程序分类的确信度。
|
|
||||||
|
|
||||||
优先级:
|
|
||||||
1. L1 关键字命中,且最高置信度 >= 0.90 → 直接返回 L1 结果。
|
|
||||||
2. LLM 结果存在 → 使用 LLM 的分类结果。
|
|
||||||
3. 否则 → 返回 unknown。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source: COBOL 程序源码文本。
|
|
||||||
structure: 可选的程序结构信息(暂未使用,保留扩展)。
|
|
||||||
llm_result: 可选的 LLM 分类结果。
|
|
||||||
预期格式: {"category": str, "confidence": float, ...}
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict:
|
|
||||||
- "category": str — 分类名称或 "unknown"
|
|
||||||
- "confidence": float — 确信度 (0.0 ~ 1.0)
|
|
||||||
- "source": str — 结果来源 ("l1" / "llm" / "unknown")
|
|
||||||
- "matches": list — 匹配到的关键字详情
|
|
||||||
"""
|
|
||||||
# ── 1. L1 关键字检测 ──
|
|
||||||
matches = detect_keyword(source)
|
|
||||||
|
|
||||||
# 找出最高置信度的 L1 匹配
|
|
||||||
if matches:
|
|
||||||
best = max(matches, key=lambda m: m[1]) # (category, confidence, keyword)
|
|
||||||
category, confidence, _ = best
|
|
||||||
|
|
||||||
if confidence >= 0.90:
|
|
||||||
return {
|
|
||||||
"category": category,
|
|
||||||
"confidence": confidence,
|
|
||||||
"method": "keyword",
|
|
||||||
"source": "l1",
|
|
||||||
"features": [best[2]],
|
|
||||||
"required_tests": [],
|
|
||||||
"strategy_params": {"special_boundaries": [], "coverage_requirements": {"branch": 0.95, "paragraph": 1.0}},
|
|
||||||
"matches": matches,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 2. LLM 结果 ──
|
|
||||||
if llm_result is not None:
|
|
||||||
llm_category = llm_result.get("category", "unknown")
|
|
||||||
llm_confidence = llm_result.get("confidence", 0.0)
|
|
||||||
return {
|
|
||||||
"category": llm_category,
|
|
||||||
"confidence": llm_confidence,
|
|
||||||
"method": "hybrid",
|
|
||||||
"source": "llm",
|
|
||||||
"features": [],
|
|
||||||
"required_tests": [],
|
|
||||||
"strategy_params": {"special_boundaries": [], "coverage_requirements": {"branch": 0.95, "paragraph": 1.0}},
|
|
||||||
"matches": matches,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 3. 未知 ──
|
|
||||||
return {
|
|
||||||
"category": "unknown",
|
|
||||||
"confidence": 0.0,
|
|
||||||
"method": "none",
|
|
||||||
"source": "unknown",
|
|
||||||
"features": [],
|
|
||||||
"required_tests": [],
|
|
||||||
"strategy_params": {"special_boundaries": [], "coverage_requirements": {"branch": 0.95, "paragraph": 1.0}},
|
|
||||||
"matches": [],
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""
|
|
||||||
质量门禁 — 执行前检查测试数据是否满足覆盖率和边界要求。
|
|
||||||
|
|
||||||
Phase 1 可用: 决策点覆盖、段落覆盖
|
|
||||||
Phase 2 启用: HINA 必须项、字段覆盖
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def check(
|
|
||||||
complete_tests: list,
|
|
||||||
hina_result: dict,
|
|
||||||
coverage: dict,
|
|
||||||
decision_threshold: float = 0.90,
|
|
||||||
paragraph_threshold: float = 1.0,
|
|
||||||
) -> dict:
|
|
||||||
"""质量门禁检查。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
complete_tests: 完整的测试数据集
|
|
||||||
hina_result: HINA 分类结果
|
|
||||||
coverage: check_coverage() 输出的覆盖率数据
|
|
||||||
decision_threshold: 决策点覆盖率阈值
|
|
||||||
paragraph_threshold: 段落覆盖率阈值
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict with: passed, score, issues
|
|
||||||
"""
|
|
||||||
issues = {}
|
|
||||||
|
|
||||||
branch_rate = coverage.get("branch_rate", 0.0)
|
|
||||||
if branch_rate < decision_threshold:
|
|
||||||
issues["decision_gaps"] = coverage.get("uncovered_decision_ids", [])
|
|
||||||
|
|
||||||
paragraph_rate = coverage.get("paragraph_rate", 0.0)
|
|
||||||
if paragraph_rate < paragraph_threshold:
|
|
||||||
issues.setdefault("paragraph_gaps", []).append(
|
|
||||||
f"段落覆盖率不足: {paragraph_rate:.0%}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not complete_tests:
|
|
||||||
issues["no_data"] = True
|
|
||||||
|
|
||||||
passed = len(issues) == 0
|
|
||||||
score = _compute_score(coverage, hina_result)
|
|
||||||
|
|
||||||
return {"passed": passed, "score": score, "issues": issues}
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_score(coverage: dict, hina_result: dict) -> float:
|
|
||||||
"""质量评分公式(COBOL 版)。
|
|
||||||
|
|
||||||
评分 = 覆盖质量 × 0.6 + 边界质量 × 0.4
|
|
||||||
覆盖质量 = 段落覆盖率 × 0.5 + 分支覆盖率 × 0.5
|
|
||||||
边界质量 = HINA 必须项覆盖率(Phase 2 后启用,默认 1.0)
|
|
||||||
"""
|
|
||||||
paragraph_rate = coverage.get("paragraph_rate", 0.0)
|
|
||||||
branch_rate = coverage.get("branch_rate", 0.0)
|
|
||||||
|
|
||||||
coverage_quality = paragraph_rate * 0.5 + branch_rate * 0.5
|
|
||||||
boundary_quality = 1.0
|
|
||||||
|
|
||||||
return round(coverage_quality * 0.6 + boundary_quality * 0.4, 2)
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import subprocess
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def collect_gcov(cobol_src: Path, work_dir: Path) -> dict:
|
|
||||||
try:
|
|
||||||
gcda_files = list(work_dir.glob("*.gcda"))
|
|
||||||
if not gcda_files:
|
|
||||||
logger.warning("[gcov] 未找到 .gcda 文件,可能未启用插桩编译")
|
|
||||||
return {"available": False, "reason": "no_gcda_files"}
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["gcov", cobol_src.name],
|
|
||||||
capture_output=True, text=True, timeout=30,
|
|
||||||
cwd=work_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.warning(f"[gcov] gcov 执行失败: {result.stderr[:200]}")
|
|
||||||
return {"available": False, "reason": "gcov_failed"}
|
|
||||||
|
|
||||||
gcov_file = work_dir / f"{cobol_src.stem}.cbl.gcov"
|
|
||||||
if not gcov_file.exists():
|
|
||||||
gcov_file = work_dir / f"{cobol_src.stem}.gcov"
|
|
||||||
|
|
||||||
if not gcov_file.exists():
|
|
||||||
logger.warning("[gcov] .gcov 文件未生成")
|
|
||||||
return {"available": False, "reason": "no_gcov_output"}
|
|
||||||
|
|
||||||
total_lines = 0
|
|
||||||
executed_lines = 0
|
|
||||||
with open(gcov_file) as f:
|
|
||||||
for line in f:
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped and not stripped.startswith("-"):
|
|
||||||
total_lines += 1
|
|
||||||
if not stripped.startswith("#"):
|
|
||||||
executed_lines += 1
|
|
||||||
|
|
||||||
line_rate = executed_lines / max(total_lines, 1)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"available": True,
|
|
||||||
"line_rate": round(line_rate, 4),
|
|
||||||
"total_lines": total_lines,
|
|
||||||
"executed_lines": executed_lines,
|
|
||||||
}
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.warning("[gcov] gcov 命令未找到,降级为仅静态分析")
|
|
||||||
return {"available": False, "reason": "gcov_not_installed"}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[gcov] 采集异常: {e}")
|
|
||||||
return {"available": False, "reason": str(e)[:100]}
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
"""
|
|
||||||
HINA 混淆组判定 — 基于 LLM 的 COBOL 程序结构分类。
|
|
||||||
|
|
||||||
根据 extract_structure() 输出的结构特征,调用 LLM 将程序归类到
|
|
||||||
混淆组(confusion group),并返回分类结果和策略参数。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
CONFUSION_PROMPT = """你是一个 COBOL 程序混淆组分类专家。请根据以下程序结构特征,将其归类到合适的混淆组中。
|
|
||||||
|
|
||||||
程序结构特征:
|
|
||||||
- 段落数: {paragraph_count}
|
|
||||||
- 决策点总数: {decision_count}
|
|
||||||
- IF 语句数: {if_count}
|
|
||||||
- EVALUATE 语句数: {evaluate_count}
|
|
||||||
- 关联文件数: {file_count}
|
|
||||||
- OPEN 方向: {open_directions}
|
|
||||||
- SEARCH ALL: {has_search_all}
|
|
||||||
- CALL 语句: {has_call}
|
|
||||||
- KEY BREAK 关键词: {has_break}
|
|
||||||
- 总分支数: {total_branches}
|
|
||||||
|
|
||||||
混淆组定义:
|
|
||||||
1. simple_sequential — 极少决策点(<=2),无 EVALUATE/SEARCH ALL/CALL,直接顺序执行
|
|
||||||
2. condition_heavy — IF 语句占比高(>60% 的决策点),嵌套深,逻辑复杂
|
|
||||||
3. evaluate_driven — EVALUATE 主导,多分支选择结构
|
|
||||||
4. data_file_centric — 文件操作密集(>=2 文件),OPEN 方向多样(I-O/OUTPUT/INPUT)
|
|
||||||
5. search_intensive — 包含 SEARCH ALL,表/数组查找为主
|
|
||||||
6. call_based — 包含 CALL 语句,模块间调用为主
|
|
||||||
7. mixed_complex — 同时具备多种复杂特征(决策点多且文件多且含 CALL/SEARCH 等)
|
|
||||||
|
|
||||||
请按 JSON 格式输出分类结果,不要包含其他文字:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{{
|
|
||||||
"category": "<混淆组类别>",
|
|
||||||
"subtype": "<子类别,如 nested_if / flat_evaluate / multi_file 等>",
|
|
||||||
"confidence": <0~1 置信度>,
|
|
||||||
"features": {{
|
|
||||||
"paragraph_count": {paragraph_count},
|
|
||||||
"decision_count": {decision_count},
|
|
||||||
"if_count": {if_count},
|
|
||||||
"evaluate_count": {evaluate_count},
|
|
||||||
"file_count": {file_count},
|
|
||||||
"has_search_all": {has_search_all},
|
|
||||||
"has_call": {has_call},
|
|
||||||
"has_break": {has_break},
|
|
||||||
"total_branches": {total_branches}
|
|
||||||
}},
|
|
||||||
"required_tests": <建议测试用例数,整数>,
|
|
||||||
"strategy_params": {{
|
|
||||||
"max_nesting_depth": <最大嵌套深度建议>,
|
|
||||||
"coverage_target": "branch" 或 "path",
|
|
||||||
"file_isolation": true 或 false,
|
|
||||||
"supplement_strategy": "incremental" 或 "full" 或 "skip"
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
```"""
|
|
||||||
|
|
||||||
|
|
||||||
def classify_with_llm(structure: dict, llm) -> dict:
|
|
||||||
"""调用 LLM 对程序结构进行混淆组分类。
|
|
||||||
|
|
||||||
根据 extract_structure() 返回的结构字典,构造 CONFUSION_PROMPT
|
|
||||||
并调用 LLM 进行分类。结果包含 category、subtype、confidence、
|
|
||||||
features、required_tests、strategy_params。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
structure: extract_structure() 返回的字典,包含 paragraphs、
|
|
||||||
decision_points、file_count、open_directions、
|
|
||||||
has_search_all、has_evaluate、has_call、has_break、
|
|
||||||
total_branches、total_paragraphs 等字段。
|
|
||||||
llm: LLMClient 实例,call 方法签名为
|
|
||||||
llm.call([{"role":"system","content":"..."},
|
|
||||||
{"role":"user","content":prompt}]) -> str
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {
|
|
||||||
"category": str,
|
|
||||||
"subtype": str,
|
|
||||||
"confidence": float,
|
|
||||||
"features": dict,
|
|
||||||
"required_tests": int,
|
|
||||||
"strategy_params": dict
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
decision_points = structure.get("decision_points", [])
|
|
||||||
if_count = sum(1 for dp in decision_points if dp.get("kind") == "IF")
|
|
||||||
evaluate_count = sum(1 for dp in decision_points if dp.get("kind") == "EVALUATE")
|
|
||||||
|
|
||||||
paragraph_count = structure.get("total_paragraphs", len(structure.get("paragraphs", [])))
|
|
||||||
open_dirs = structure.get("open_directions", {})
|
|
||||||
|
|
||||||
has_search_all = str(structure.get("has_search_all", False)).lower()
|
|
||||||
has_call = str(structure.get("has_call", False)).lower()
|
|
||||||
has_break = str(structure.get("has_break", False)).lower()
|
|
||||||
|
|
||||||
prompt = CONFUSION_PROMPT.format(
|
|
||||||
paragraph_count=paragraph_count,
|
|
||||||
decision_count=len(decision_points),
|
|
||||||
if_count=if_count,
|
|
||||||
evaluate_count=evaluate_count,
|
|
||||||
file_count=structure.get("file_count", 0),
|
|
||||||
open_directions=json.dumps(open_dirs, ensure_ascii=False),
|
|
||||||
has_search_all=has_search_all,
|
|
||||||
has_call=has_call,
|
|
||||||
has_break=has_break,
|
|
||||||
total_branches=structure.get("total_branches", 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": "你是一个 COBOL 程序混淆组分类专家。只输出 JSON,不要输出解释。"},
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw = llm.call(messages)
|
|
||||||
result = _parse_llm_response(raw)
|
|
||||||
logger.info(
|
|
||||||
"HINA classification: %s/%s (confidence=%.2f, tests=%s)",
|
|
||||||
result.get("category", "?"),
|
|
||||||
result.get("subtype", "?"),
|
|
||||||
result.get("confidence", 0.0),
|
|
||||||
result.get("required_tests", "?"),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("HINA LLM classification failed: %s", e)
|
|
||||||
return _fallback_classification(structure)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_llm_response(raw: str) -> dict:
|
|
||||||
"""从 LLM 响应中提取 JSON 并解析。
|
|
||||||
|
|
||||||
处理 JSON 可能被 ```json ... ``` 包裹的情况。
|
|
||||||
"""
|
|
||||||
text = raw.strip()
|
|
||||||
|
|
||||||
# 尝试提取 ```json ... ``` 代码块
|
|
||||||
if "```json" in text:
|
|
||||||
start = text.index("```json") + 7
|
|
||||||
end = text.index("```", start) if "```" in text[start:] else len(text)
|
|
||||||
text = text[start:end].strip()
|
|
||||||
elif "```" in text:
|
|
||||||
# 尝试 ``` ... ``` (无 json 标注)
|
|
||||||
start = text.index("```") + 3
|
|
||||||
end = text.index("```", start) if "```" in text[start:] else len(text)
|
|
||||||
text = text[start:end].strip()
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = json.loads(text)
|
|
||||||
return _validate_result(parsed)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return _validate_result({})
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_result(parsed: dict) -> dict:
|
|
||||||
"""验证并规范化 LLM 返回的分类结果。"""
|
|
||||||
defaults = {
|
|
||||||
"category": "unknown",
|
|
||||||
"subtype": "",
|
|
||||||
"confidence": 0.0,
|
|
||||||
"features": {},
|
|
||||||
"required_tests": 1,
|
|
||||||
"strategy_params": {
|
|
||||||
"max_nesting_depth": 1,
|
|
||||||
"coverage_target": "branch",
|
|
||||||
"file_isolation": False,
|
|
||||||
"supplement_strategy": "full",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result = {}
|
|
||||||
for key, default_value in defaults.items():
|
|
||||||
value = parsed.get(key, default_value)
|
|
||||||
if key == "confidence":
|
|
||||||
try:
|
|
||||||
value = float(value)
|
|
||||||
value = max(0.0, min(1.0, value))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
value = 0.0
|
|
||||||
elif key == "required_tests":
|
|
||||||
try:
|
|
||||||
value = int(value)
|
|
||||||
value = max(1, value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
value = 1
|
|
||||||
result[key] = value
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _fallback_classification(structure: dict) -> dict:
|
|
||||||
"""当 LLM 调用失败时,基于规则的兜底分类。"""
|
|
||||||
decision_points = structure.get("decision_points", [])
|
|
||||||
if_count = sum(1 for dp in decision_points if dp.get("kind") == "IF")
|
|
||||||
evaluate_count = sum(1 for dp in decision_points if dp.get("kind") == "EVALUATE")
|
|
||||||
total_decisions = len(decision_points)
|
|
||||||
file_count = structure.get("file_count", 0)
|
|
||||||
has_search_all = structure.get("has_search_all", False)
|
|
||||||
has_call = structure.get("has_call", False)
|
|
||||||
has_break = structure.get("has_break", False)
|
|
||||||
|
|
||||||
# 规则优先级:从高到低
|
|
||||||
if total_decisions == 0:
|
|
||||||
category, subtype = "simple_sequential", "no_branch"
|
|
||||||
required_tests = 1
|
|
||||||
strategy = {"max_nesting_depth": 0, "coverage_target": "branch",
|
|
||||||
"file_isolation": False, "supplement_strategy": "skip"}
|
|
||||||
elif has_search_all:
|
|
||||||
category, subtype = "search_intensive", "table_lookup"
|
|
||||||
required_tests = max(total_decisions, 3)
|
|
||||||
strategy = {"max_nesting_depth": 3, "coverage_target": "path",
|
|
||||||
"file_isolation": True, "supplement_strategy": "incremental"}
|
|
||||||
elif has_call:
|
|
||||||
category, subtype = "call_based", "external_call"
|
|
||||||
required_tests = max(total_decisions, 3)
|
|
||||||
strategy = {"max_nesting_depth": 2, "coverage_target": "branch",
|
|
||||||
"file_isolation": False, "supplement_strategy": "full"}
|
|
||||||
elif evaluate_count > if_count and evaluate_count >= 2:
|
|
||||||
category, subtype = "evaluate_driven", "multi_way"
|
|
||||||
required_tests = total_decisions + 1
|
|
||||||
strategy = {"max_nesting_depth": evaluate_count, "coverage_target": "path",
|
|
||||||
"file_isolation": False, "supplement_strategy": "full"}
|
|
||||||
elif file_count >= 2:
|
|
||||||
category, subtype = "data_file_centric", "multi_file"
|
|
||||||
required_tests = max(total_decisions, file_count * 2)
|
|
||||||
strategy = {"max_nesting_depth": 2, "coverage_target": "branch",
|
|
||||||
"file_isolation": True, "supplement_strategy": "incremental"}
|
|
||||||
elif if_count >= 5 or total_decisions >= 8:
|
|
||||||
category, subtype = "condition_heavy", "nested_if"
|
|
||||||
required_tests = total_decisions + 2
|
|
||||||
strategy = {"max_nesting_depth": 4, "coverage_target": "path",
|
|
||||||
"file_isolation": False, "supplement_strategy": "incremental"}
|
|
||||||
elif if_count >= 2:
|
|
||||||
category, subtype = "condition_heavy", "simple_if"
|
|
||||||
required_tests = total_decisions + 1
|
|
||||||
strategy = {"max_nesting_depth": 2, "coverage_target": "branch",
|
|
||||||
"file_isolation": False, "supplement_strategy": "incremental"}
|
|
||||||
else:
|
|
||||||
category, subtype = "simple_sequential", "minimal"
|
|
||||||
required_tests = 1
|
|
||||||
strategy = {"max_nesting_depth": 0, "coverage_target": "branch",
|
|
||||||
"file_isolation": False, "supplement_strategy": "skip"}
|
|
||||||
|
|
||||||
# 检查是否应升级为 mixed_complex
|
|
||||||
complexity_flags = sum([
|
|
||||||
has_search_all,
|
|
||||||
has_call,
|
|
||||||
has_break,
|
|
||||||
file_count >= 2,
|
|
||||||
if_count >= 5,
|
|
||||||
evaluate_count >= 3,
|
|
||||||
])
|
|
||||||
if complexity_flags >= 3:
|
|
||||||
category, subtype = "mixed_complex", f"{subtype}_plus"
|
|
||||||
required_tests = max(required_tests, 10)
|
|
||||||
strategy["max_nesting_depth"] = max(strategy.get("max_nesting_depth", 2), 5)
|
|
||||||
strategy["coverage_target"] = "path"
|
|
||||||
strategy["supplement_strategy"] = "full"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"category": category,
|
|
||||||
"subtype": subtype,
|
|
||||||
"confidence": 0.6,
|
|
||||||
"features": {
|
|
||||||
"paragraph_count": structure.get("total_paragraphs", len(structure.get("paragraphs", []))),
|
|
||||||
"decision_count": total_decisions,
|
|
||||||
"if_count": if_count,
|
|
||||||
"evaluate_count": evaluate_count,
|
|
||||||
"file_count": file_count,
|
|
||||||
"has_search_all": has_search_all,
|
|
||||||
"has_call": has_call,
|
|
||||||
"has_break": has_break,
|
|
||||||
"total_branches": structure.get("total_branches", 0),
|
|
||||||
},
|
|
||||||
"required_tests": required_tests,
|
|
||||||
"strategy_params": strategy,
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""
|
|
||||||
分层重试 — 部署在 orchestrator 调用者层(main.py / worker.py)。
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from typing import Callable
|
|
||||||
from data.diff_result import VerificationRun
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
HEALING_FIXES = {
|
|
||||||
"compile_error": {
|
|
||||||
"detect": lambda log: "not found" in (log or "").lower(),
|
|
||||||
"fix": lambda: _try_set_env(
|
|
||||||
"COB_LIBRARY_PATH",
|
|
||||||
"D:\\360安全浏览器下载\\GC32-BDB-SP1-rename-7z-to-exe\\lib\\gnucobol",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"s0c7": {
|
|
||||||
"detect": lambda log: "S0C7" in (log or ""),
|
|
||||||
"fix": lambda: logger.warning("[Retry] S0C7 需要人工修正测试数据中的数值字段"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _try_set_env(key: str, value: str) -> None:
|
|
||||||
"""尝试设置环境变量(如果当前未设置)"""
|
|
||||||
if not os.environ.get(key):
|
|
||||||
os.environ[key] = value
|
|
||||||
logger.info(f"[Retry] 已设置环境变量 {key}={value}")
|
|
||||||
else:
|
|
||||||
logger.info(f"[Retry] {key} 已存在,跳过")
|
|
||||||
|
|
||||||
|
|
||||||
class RetryHandler:
|
|
||||||
def __init__(self, max_heal: int = 2, max_simple: int = 3):
|
|
||||||
self.max_heal = max_heal
|
|
||||||
self.max_simple = max_simple
|
|
||||||
self.heal_count = 0
|
|
||||||
self.simple_count = 0
|
|
||||||
self.history: list[VerificationRun] = []
|
|
||||||
|
|
||||||
def run(self, pipeline_fn: Callable[[], VerificationRun]) -> VerificationRun:
|
|
||||||
while (self.heal_count + self.simple_count) < (self.max_heal + self.max_simple):
|
|
||||||
vr = pipeline_fn()
|
|
||||||
self.history.append(vr)
|
|
||||||
|
|
||||||
if vr.status in ("PASS", "QUALITY_WARN"):
|
|
||||||
vr.heal_retry = self.heal_count
|
|
||||||
vr.simple_retry = self.simple_count
|
|
||||||
vr.total_retry = self.heal_count + self.simple_count
|
|
||||||
return vr
|
|
||||||
|
|
||||||
if vr.status in ("BLOCKED", "ERROR") and self.heal_count < self.max_heal:
|
|
||||||
build_log = vr.debug.get("cobol_build", {}).get("log", "")
|
|
||||||
healed = False
|
|
||||||
for name, fix_def in HEALING_FIXES.items():
|
|
||||||
if fix_def["detect"](build_log):
|
|
||||||
fix_def["fix"]()
|
|
||||||
self.heal_count += 1
|
|
||||||
healed = True
|
|
||||||
logger.info(
|
|
||||||
f"[Retry] 自愈修复应用: {name} "
|
|
||||||
f"(heal_retry={self.heal_count})"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
if healed:
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.simple_count += 1
|
|
||||||
logger.info(f"[Retry] 朴素重试 (simple_retry={self.simple_count})")
|
|
||||||
|
|
||||||
logger.error("[Retry] 重试次数超过上限,标记 FATAL")
|
|
||||||
vr = self.history[-1] if self.history else VerificationRun(
|
|
||||||
status="FATAL", exit_code=4
|
|
||||||
)
|
|
||||||
vr.status = "FATAL"
|
|
||||||
vr.exit_code = 4
|
|
||||||
vr.heal_retry = self.heal_count
|
|
||||||
vr.simple_retry = self.simple_count
|
|
||||||
vr.total_retry = self.heal_count + self.simple_count
|
|
||||||
return vr
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""
|
|
||||||
HINA 策略模板 — 根据程序分类定义必须的测试项和边界条件。
|
|
||||||
|
|
||||||
Task 2.2: 必须项模板 + supplement 函数
|
|
||||||
"""
|
|
||||||
|
|
||||||
STRATEGY_TEMPLATES: dict[str, dict] = {
|
|
||||||
"マッチング": {
|
|
||||||
"required": [
|
|
||||||
"COM-N001", "COM-N002", "COM-A002", "COM-A003",
|
|
||||||
"MT-N001", "MT-N002", "MT-N004", "MT-N005", "MT-N006",
|
|
||||||
],
|
|
||||||
"boundary": ["MT-B001", "MT-B002"],
|
|
||||||
},
|
|
||||||
"キーブレイク": {
|
|
||||||
"required": [
|
|
||||||
"COM-N001", "COM-A002",
|
|
||||||
"KB-N001", "KB-N004", "KB-N005", "KB-A001",
|
|
||||||
],
|
|
||||||
"boundary": ["KB-B001", "KB-B002"],
|
|
||||||
},
|
|
||||||
"条件分岐": {
|
|
||||||
"required": [
|
|
||||||
"B-N001", "B-N003", "B-N006", "B-N009",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"内部表検索": {
|
|
||||||
"required": [
|
|
||||||
"T-N001", "T-N002", "T-A001", "T-A002",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"項目チェック": {
|
|
||||||
"required": [
|
|
||||||
"VF-N001", "VF-N002", "VF-N004", "VF-A001",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_strategy(hina_type: str) -> dict:
|
|
||||||
"""返回对应 HINA 类型的策略模板。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
hina_type: HINA 程序分类名称(如 "マッチング")。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: required 列表及可选的 boundary 列表。
|
|
||||||
未知类型返回空模板 {"required": [], "boundary": []}。
|
|
||||||
"""
|
|
||||||
return STRATEGY_TEMPLATES.get(hina_type, {"required": [], "boundary": []})
|
|
||||||
|
|
||||||
|
|
||||||
def _make_marker(code: str, prefix: str = "REQ") -> dict:
|
|
||||||
"""生成一条标记记录。"""
|
|
||||||
return {
|
|
||||||
"id": f"{prefix}-{code}",
|
|
||||||
"coverage_targets": [code],
|
|
||||||
"fields": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def supplement(base_tests: list[dict], hina_result: dict) -> list[dict]:
|
|
||||||
"""根据 HINA 类型追加模板中的必须项标记记录。
|
|
||||||
|
|
||||||
从 ``hina_result["category"]`` 获取分类,查找对应的策略模板,
|
|
||||||
将模板中所有的 required 和 boundary 项以标记记录形式追加到测试列表末尾。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_tests: 已有的测试数据列表(每个元素为 dict)。
|
|
||||||
hina_result: HINA 分类结果,至少包含 ``{"category": str}``。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[dict]: 追加必须项标记记录后的完整测试列表。
|
|
||||||
"""
|
|
||||||
hina_type = hina_result.get("category", "unknown")
|
|
||||||
template = get_strategy(hina_type)
|
|
||||||
result = list(base_tests)
|
|
||||||
|
|
||||||
for code in template.get("required", []):
|
|
||||||
result.append(_make_marker(code))
|
|
||||||
|
|
||||||
for code in template.get("boundary", []):
|
|
||||||
result.append(_make_marker(code, prefix="BND"))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def supplement_only(base_tests: list[dict], hina_gaps: list[str]) -> list[dict]:
|
|
||||||
"""增量补充指定必须项的标记记录。
|
|
||||||
|
|
||||||
根据传入的 code 列表(而不是从模板查找),只追加缺失的那些必须项标记。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_tests: 已有的测试数据列表(每个元素为 dict)。
|
|
||||||
hina_gaps: 需要补充的 HINA 必须项 code 列表。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[dict]: 追加标记记录后的完整测试列表。
|
|
||||||
"""
|
|
||||||
result = list(base_tests)
|
|
||||||
for code in hina_gaps:
|
|
||||||
result.append(_make_marker(code))
|
|
||||||
return result
|
|
||||||
@@ -15,9 +15,6 @@ def main():
|
|||||||
p.add_argument("--verbose", action="store_true")
|
p.add_argument("--verbose", action="store_true")
|
||||||
p.add_argument("--dry-run", action="store_true")
|
p.add_argument("--dry-run", action="store_true")
|
||||||
p.add_argument("--output-dir", default="./reports")
|
p.add_argument("--output-dir", default="./reports")
|
||||||
p.add_argument("--quality-gate-mode", choices=["warn", "off"], default="warn",
|
|
||||||
help="质量门禁模式: warn=记录警告, off=关闭")
|
|
||||||
p.add_argument("--gcov", action="store_true", help="启用 gcov 覆盖率采集")
|
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
@@ -38,8 +35,6 @@ def main():
|
|||||||
c.runner_mode = args.runner
|
c.runner_mode = args.runner
|
||||||
c.coverage_default = args.coverage
|
c.coverage_default = args.coverage
|
||||||
c.tolerance = args.tolerance
|
c.tolerance = args.tolerance
|
||||||
c.quality_gate_mode = args.quality_gate_mode
|
|
||||||
c.gcov_enabled = args.gcov
|
|
||||||
vr = run_pipeline(c, args.copybook, args.cobol_src, args.java_src, args.mapping)
|
vr = run_pipeline(c, args.copybook, args.cobol_src, args.java_src, args.mapping)
|
||||||
t = vr.fields_matched + vr.fields_mismatched
|
t = vr.fields_matched + vr.fields_mismatched
|
||||||
print(f"{vr.program}: {vr.status} ({vr.fields_matched}/{t}, {vr.duration_s:.0f}s)" if t else f"{vr.program}: {vr.status}")
|
print(f"{vr.program}: {vr.status} ({vr.fields_matched}/{t}, {vr.duration_s:.0f}s)" if t else f"{vr.program}: {vr.status}")
|
||||||
|
|||||||
+2
-84
@@ -1,7 +1,7 @@
|
|||||||
import shutil, time, logging
|
import shutil, time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from data.field_tree import FieldTree
|
from data.field_tree import FieldTree
|
||||||
from data.test_case import TestSuite, SparkConfig, TestCase
|
from data.test_case import TestSuite, SparkConfig
|
||||||
from data.diff_result import VerificationRun, FieldResult
|
from data.diff_result import VerificationRun, FieldResult
|
||||||
from runners.runner import Runner
|
from runners.runner import Runner
|
||||||
from runners.native_java_runner import NativeJavaRunner
|
from runners.native_java_runner import NativeJavaRunner
|
||||||
@@ -18,14 +18,6 @@ from comparator.cobol_binary_reader import CobolBinaryReader
|
|||||||
from report.generator import ReportGenerator
|
from report.generator import ReportGenerator
|
||||||
from storage.bundle import TestDataBundle
|
from storage.bundle import TestDataBundle
|
||||||
from config import Config
|
from config import Config
|
||||||
from cobol_testgen import extract_structure, generate_data, incremental_supplement
|
|
||||||
from cobol_testgen.coverage import check_coverage
|
|
||||||
from hina.gate import check as gate_check
|
|
||||||
from hina.classifier import compute_confidence
|
|
||||||
from hina.hina_agent import classify_with_llm
|
|
||||||
from hina.strategy import supplement as strategy_supplement
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) -> VerificationRun:
|
def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) -> VerificationRun:
|
||||||
@@ -48,82 +40,8 @@ def run_pipeline(cfg: Config, cpath: str, cbl: str, java: str, map_path: str) ->
|
|||||||
if vr.llm_cost > cfg.max_llm_cost:
|
if vr.llm_cost > cfg.max_llm_cost:
|
||||||
return _done(vr, t0, "BLOCKED", 3)
|
return _done(vr, t0, "BLOCKED", 3)
|
||||||
|
|
||||||
# ── Phase 1+2: cobol_testgen + HINA Agent + 策略 Agent + 质量门禁 ──
|
|
||||||
try:
|
|
||||||
cobol_src_text = Path(cbl).read_text(encoding="utf-8")
|
|
||||||
structure = extract_structure(cobol_src_text, source_dir=str(Path(cbl).parent))
|
|
||||||
|
|
||||||
# cobol_testgen 路径枚举 + 基础数据生成
|
|
||||||
base_records = generate_data(cobol_src_text, structure, source_dir=str(Path(cbl).parent))
|
|
||||||
vr.debug["cobol_testgen_records"] = len(base_records)
|
|
||||||
vr.debug["total_branches"] = structure.get("total_branches", 0)
|
|
||||||
|
|
||||||
# 转换为 TestCase 列表(增强管线的基础数据集)
|
|
||||||
complete_tests = []
|
|
||||||
for i, rec in enumerate(base_records):
|
|
||||||
complete_tests.append(TestCase(id=f"CTG-{i+1:04d}", fields=dict(rec)))
|
|
||||||
|
|
||||||
# HINA Agent 类型判定
|
|
||||||
hina_result = {}
|
|
||||||
try:
|
|
||||||
hina_result = compute_confidence(cobol_src_text, structure)
|
|
||||||
if hina_result.get("confidence", 0) < 0.7 and structure:
|
|
||||||
llm_hina = classify_with_llm(structure, llm)
|
|
||||||
if llm_hina.get("confidence", 0) > hina_result.get("confidence", 0):
|
|
||||||
hina_result = llm_hina
|
|
||||||
vr.hina_type = hina_result.get("category", "")
|
|
||||||
vr.hina_confidence = hina_result.get("confidence", 0.0)
|
|
||||||
vr.debug["hina_result"] = hina_result
|
|
||||||
except Exception as e:
|
|
||||||
vr.debug["hina_agent_error"] = str(e)
|
|
||||||
logger.warning(f"[orchestrator] HINA Agent 判定失败: {e}")
|
|
||||||
|
|
||||||
# 策略 Agent 补充(追加标记记录,统一为 TestCase 格式)
|
|
||||||
for m in strategy_supplement([], hina_result):
|
|
||||||
complete_tests.append(TestCase(
|
|
||||||
id=m.get("id", f"STG-{len(complete_tests)+1:04d}"),
|
|
||||||
fields=m.get("fields", {}),
|
|
||||||
coverage_targets=m.get("coverage_targets", []),
|
|
||||||
))
|
|
||||||
|
|
||||||
# 质量门禁循环
|
|
||||||
cov = check_coverage(structure, base_records)
|
|
||||||
for attempt in range(cfg.max_quality_retries):
|
|
||||||
gate_result = gate_check(
|
|
||||||
complete_tests, hina_result, cov,
|
|
||||||
decision_threshold=cfg.quality_gate_decision_threshold,
|
|
||||||
paragraph_threshold=cfg.quality_gate_paragraph_threshold,
|
|
||||||
)
|
|
||||||
if gate_result.get("passed"):
|
|
||||||
break
|
|
||||||
gaps = gate_result.get("issues", {}).get("decision_gaps", [])
|
|
||||||
if gaps and structure.get("branch_tree_obj"):
|
|
||||||
delta = incremental_supplement(structure["branch_tree_obj"], gaps)
|
|
||||||
base_records.extend(delta)
|
|
||||||
# 同步更新 complete_tests
|
|
||||||
for i, d in enumerate(delta):
|
|
||||||
complete_tests.append(TestCase(
|
|
||||||
id=f"CTG-S{attempt+1}-{i+1:04d}",
|
|
||||||
fields=dict(d),
|
|
||||||
))
|
|
||||||
cov = check_coverage(structure, base_records)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
vr.paragraph_rate = 0.0 # Phase 3 通过 gcov 获取精确值
|
|
||||||
vr.branch_rate = cov.get("branch_rate", 0.0)
|
|
||||||
vr.decision_rate = cov.get("decision_rate", 0.0)
|
|
||||||
|
|
||||||
if cfg.quality_gate_mode != "off" and not gate_result.get("passed", True):
|
|
||||||
vr.quality_warn = f"质量门禁未完全通过 (尝试 {attempt+1} 次)"
|
|
||||||
vr.debug["quality_issues"] = gate_result.get("issues", {})
|
|
||||||
except Exception as e:
|
|
||||||
vr.debug["cobol_testgen_error"] = str(e)
|
|
||||||
logger.warning(f"[orchestrator] cobol_testgen 分析失败: {e}")
|
|
||||||
|
|
||||||
suite = Agent2Data(llm).design(tree, cfg.coverage_default, cfg.runner_mode == "spark")
|
suite = Agent2Data(llm).design(tree, cfg.coverage_default, cfg.runner_mode == "spark")
|
||||||
vr.llm_cost += 0.002
|
vr.llm_cost += 0.002
|
||||||
suite.test_cases = complete_tests # 替换为增强管线数据(P1/P2 修复)
|
|
||||||
vr.debug["test_cases"] = [{"id":tc.id,"fields":tc.fields,"targets":tc.coverage_targets} for tc in suite.test_cases]
|
vr.debug["test_cases"] = [{"id":tc.id,"fields":tc.fields,"targets":tc.coverage_targets} for tc in suite.test_cases]
|
||||||
vr.debug["spark_config"] = {"records":suite.spark_config.num_records} if suite.has_spark else None
|
vr.debug["spark_config"] = {"records":suite.spark_config.num_records} if suite.has_spark else None
|
||||||
|
|
||||||
|
|||||||
+8
-80
@@ -9,11 +9,6 @@ class ReportGenerator:
|
|||||||
"timestamp": run.timestamp, "duration_s": run.duration_s,
|
"timestamp": run.timestamp, "duration_s": run.duration_s,
|
||||||
"fields_matched": run.fields_matched, "fields_mismatched": run.fields_mismatched,
|
"fields_matched": run.fields_matched, "fields_mismatched": run.fields_mismatched,
|
||||||
"runner": run.runner, "branch_rate": run.branch_rate, "llm_cost": run.llm_cost,
|
"runner": run.runner, "branch_rate": run.branch_rate, "llm_cost": run.llm_cost,
|
||||||
"paragraph_rate": run.paragraph_rate, "decision_rate": run.decision_rate,
|
|
||||||
"quality_score": run.quality_score, "quality_warn": run.quality_warn,
|
|
||||||
"hina_type": run.hina_type, "hina_confidence": run.hina_confidence,
|
|
||||||
"heal_retry": run.heal_retry, "simple_retry": run.simple_retry,
|
|
||||||
"total_retry": run.total_retry,
|
|
||||||
"field_results": [{"field_name": fr.field_name, "status": fr.status,
|
"field_results": [{"field_name": fr.field_name, "status": fr.status,
|
||||||
"cobol_value": fr.cobol_value, "java_value": fr.java_value,
|
"cobol_value": fr.cobol_value, "java_value": fr.java_value,
|
||||||
"suggestion": fr.suggestion} for fr in run.field_results]}
|
"suggestion": fr.suggestion} for fr in run.field_results]}
|
||||||
@@ -26,85 +21,18 @@ class ReportGenerator:
|
|||||||
f'</td><td>{fr.status}</td><td>{fr.cobol_value}</td><td>{fr.java_value}</td>'
|
f'</td><td>{fr.status}</td><td>{fr.cobol_value}</td><td>{fr.java_value}</td>'
|
||||||
f'<td>{fr.suggestion}</td></tr>'
|
f'<td>{fr.suggestion}</td></tr>'
|
||||||
for fr in run.field_results)
|
for fr in run.field_results)
|
||||||
|
html = f"<!DOCTYPE html><html><head><meta charset=utf-8><title>{run.program}</title>" \
|
||||||
# 覆盖率卡片
|
f"<style>body{{font-family:monospace;max-width:900px;margin:2rem auto}}" \
|
||||||
coverage_html = ""
|
f".pass{{background:#e6ffe6}}.fail{{background:#ffe6e6}}pre{{background:#f0f0f0;padding:1rem}}" \
|
||||||
if run.paragraph_rate > 0 or run.branch_rate > 0:
|
f"</style></head><body><h1>{run.program}</h1><pre>Status: {run.status} | " \
|
||||||
mode = "静态+动态" if run.branch_rate > 0 else "仅静态"
|
f"Runner: {run.runner} | {run.fields_matched} fields | {run.duration_s}s</pre>" \
|
||||||
pcolor = "green" if run.paragraph_rate >= 1.0 else "orange"
|
f"<table border=1 cellpadding=4><tr><th>Field</th><th>Status</th><th>COBOL</th>" \
|
||||||
bcolor = "green" if run.branch_rate >= 0.9 else "orange"
|
f"<th>Java</th><th>Suggestion</th></tr>{rows}</table></body></html>"
|
||||||
coverage_html = f"""
|
|
||||||
<h2>覆盖率</h2>
|
|
||||||
<table border=1 cellpadding=4>
|
|
||||||
<tr><td>方式</td><td>{mode}</td></tr>
|
|
||||||
<tr><td>段落覆盖率</td><td style="color:{pcolor}">{run.paragraph_rate:.0%}</td></tr>
|
|
||||||
<tr><td>分支覆盖率</td><td style="color:{bcolor}">{run.branch_rate:.0%}</td></tr>
|
|
||||||
<tr><td>决策点覆盖率</td><td>{run.decision_rate:.0%}</td></tr>
|
|
||||||
</table>"""
|
|
||||||
|
|
||||||
# HINA 卡片
|
|
||||||
hina_html = ""
|
|
||||||
if run.hina_type:
|
|
||||||
hina_html = f"""
|
|
||||||
<h2>HINA 信息</h2>
|
|
||||||
<table border=1 cellpadding=4>
|
|
||||||
<tr><td>判定类型</td><td>{run.hina_type}</td></tr>
|
|
||||||
<tr><td>确信度</td><td>{run.hina_confidence:.0%}</td></tr>
|
|
||||||
</table>"""
|
|
||||||
|
|
||||||
# 质量评分卡片
|
|
||||||
quality_html = ""
|
|
||||||
if run.quality_score > 0:
|
|
||||||
color = "green" if run.quality_score >= 0.8 else "orange"
|
|
||||||
quality_html = f"""
|
|
||||||
<h2>质量评分</h2>
|
|
||||||
<div style="font-size:2rem;color:{color};font-weight:bold">{run.quality_score:.0%}</div>"""
|
|
||||||
|
|
||||||
# 重试历史卡片
|
|
||||||
retry_html = ""
|
|
||||||
if run.total_retry > 0:
|
|
||||||
retry_html = f"""
|
|
||||||
<h2>重试历史</h2>
|
|
||||||
<table border=1 cellpadding=4>
|
|
||||||
<tr><td>heal_retry</td><td>{run.heal_retry}</td></tr>
|
|
||||||
<tr><td>simple_retry</td><td>{run.simple_retry}</td></tr>
|
|
||||||
<tr><td>total_retry</td><td>{run.total_retry}</td></tr>
|
|
||||||
</table>"""
|
|
||||||
|
|
||||||
warn_html = ""
|
|
||||||
if run.quality_warn:
|
|
||||||
warn_html = f'<div style="background:#fff3cd;padding:1rem;margin:1rem 0">{run.quality_warn}</div>'
|
|
||||||
|
|
||||||
html = f"""<!DOCTYPE html>
|
|
||||||
<html><head><meta charset=utf-8><title>{run.program}</title>
|
|
||||||
<style>
|
|
||||||
body{{font-family:monospace;max-width:900px;margin:2rem auto}}
|
|
||||||
.pass{{background:#e6ffe6}}.fail{{background:#ffe6e6}}
|
|
||||||
pre{{background:#f0f0f0;padding:1rem}}
|
|
||||||
table{{border-collapse:collapse}} td,th{{padding:6px 12px}}
|
|
||||||
</style></head><body>
|
|
||||||
<h1>{run.program}</h1>
|
|
||||||
<pre>Status: {run.status} | Runner: {run.runner} | {run.fields_matched} matched | {run.duration_s:.0f}s</pre>
|
|
||||||
{warn_html}
|
|
||||||
<h2>字段比对</h2>
|
|
||||||
<table border=1 cellpadding=4>
|
|
||||||
<tr><th>Field</th><th>Status</th><th>COBOL</th><th>Java</th><th>Suggestion</th></tr>
|
|
||||||
{rows}</table>
|
|
||||||
{coverage_html}
|
|
||||||
{hina_html}
|
|
||||||
{quality_html}
|
|
||||||
{retry_html}
|
|
||||||
</body></html>"""
|
|
||||||
p.write_text(html)
|
p.write_text(html)
|
||||||
return p
|
return p
|
||||||
|
|
||||||
def generate_machine_json(self, run: VerificationRun, p: Path) -> Path:
|
def generate_machine_json(self, run: VerificationRun, p: Path) -> Path:
|
||||||
d = {"program": run.program, "status": run.status, "exit_code": run.exit_code,
|
d = {"program": run.program, "status": run.status, "exit_code": run.exit_code,
|
||||||
"timestamp": run.timestamp, "duration_s": run.duration_s, "runner": run.runner,
|
"timestamp": run.timestamp, "duration_s": run.duration_s, "runner": run.runner}
|
||||||
"branch_rate": run.branch_rate, "paragraph_rate": run.paragraph_rate,
|
|
||||||
"decision_rate": run.decision_rate, "quality_score": run.quality_score,
|
|
||||||
"hina_type": run.hina_type, "hina_confidence": run.hina_confidence,
|
|
||||||
"heal_retry": run.heal_retry, "simple_retry": run.simple_retry,
|
|
||||||
"total_retry": run.total_retry}
|
|
||||||
p.write_text(json.dumps(d))
|
p.write_text(json.dumps(d))
|
||||||
return p
|
return p
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ from runners.runner import BuildResult, RunResult
|
|||||||
|
|
||||||
|
|
||||||
class CobolRunner:
|
class CobolRunner:
|
||||||
def compile(self, src: str, dialect="ibm", gcov: bool = False) -> BuildResult:
|
def compile(self, src: str, dialect="ibm") -> BuildResult:
|
||||||
stem = Path(src).stem
|
stem = Path(src).stem
|
||||||
out = str(Path(src).parent / stem)
|
out = str(Path(src).parent / stem)
|
||||||
cmd = ["cobc", "-x", f"-std={dialect}-strict", "-o", out, src]
|
p = subprocess.run(["cobc", "-x", f"-std={dialect}-strict", "-o", out, src],
|
||||||
if gcov:
|
capture_output=True, text=True, timeout=30)
|
||||||
cmd = ["cobc", "-x", f"-std={dialect}-strict", "-fprofile-arcs", "-ftest-coverage", "-o", out, src]
|
|
||||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
||||||
return BuildResult(success=p.returncode == 0, artifact_path=out, log=p.stdout + p.stderr)
|
return BuildResult(success=p.returncode == 0, artifact_path=out, log=p.stdout + p.stderr)
|
||||||
|
|
||||||
def run(self, binary: str, input_path: str, output_path: str) -> RunResult:
|
def run(self, binary: str, input_path: str, output_path: str) -> RunResult:
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
* HINA001 - 1:1 マッチング
|
|
||||||
>>SOURCE FORMAT IS FREE
|
|
||||||
* 2入力ファイル(R01/R02)をキー一致でマージ
|
|
||||||
* 期待: 2ファイル, 3 IF, 6 分岐, ~5 段落
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. HINA001.
|
|
||||||
ENVIRONMENT DIVISION.
|
|
||||||
INPUT-OUTPUT SECTION.
|
|
||||||
FILE-CONTROL.
|
|
||||||
SELECT R01-FILE ASSIGN TO "R01.DAT"
|
|
||||||
ORGANIZATION IS LINE SEQUENTIAL.
|
|
||||||
SELECT R02-FILE ASSIGN TO "R02.DAT"
|
|
||||||
ORGANIZATION IS LINE SEQUENTIAL.
|
|
||||||
SELECT W01-FILE ASSIGN TO "W01.DAT"
|
|
||||||
ORGANIZATION IS LINE SEQUENTIAL.
|
|
||||||
DATA DIVISION.
|
|
||||||
FILE SECTION.
|
|
||||||
FD R01-FILE.
|
|
||||||
01 R01-REC PIC X(30).
|
|
||||||
FD R02-FILE.
|
|
||||||
01 R02-REC PIC X(30).
|
|
||||||
FD W01-FILE.
|
|
||||||
01 W01-REC PIC X(60).
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-R01-KEY PIC X(10).
|
|
||||||
01 WS-R02-KEY PIC X(10).
|
|
||||||
01 WS-R01-DATA PIC X(20).
|
|
||||||
01 WS-R02-DATA PIC X(20).
|
|
||||||
01 WS-EOF1 PIC X VALUE 'N'.
|
|
||||||
88 R01-EOF VALUE 'Y'.
|
|
||||||
01 WS-EOF2 PIC X VALUE 'N'.
|
|
||||||
88 R02-EOF VALUE 'Y'.
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
0000-MAIN.
|
|
||||||
OPEN INPUT R01-FILE R02-FILE.
|
|
||||||
OPEN OUTPUT W01-FILE.
|
|
||||||
PERFORM 1000-READ-R01.
|
|
||||||
PERFORM 2000-READ-R02.
|
|
||||||
PERFORM 3000-MATCH UNTIL R01-EOF AND R02-EOF.
|
|
||||||
CLOSE R01-FILE R02-FILE W01-FILE.
|
|
||||||
STOP RUN.
|
|
||||||
1000-READ-R01.
|
|
||||||
READ R01-FILE INTO R01-REC
|
|
||||||
AT END MOVE 'Y' TO WS-EOF1
|
|
||||||
NOT AT END PERFORM 1100-PARSE-R01.
|
|
||||||
1100-PARSE-R01.
|
|
||||||
MOVE R01-REC(1:10) TO WS-R01-KEY.
|
|
||||||
MOVE R01-REC(11:20) TO WS-R01-DATA.
|
|
||||||
2000-READ-R02.
|
|
||||||
READ R02-FILE INTO R02-REC
|
|
||||||
AT END MOVE 'Y' TO WS-EOF2
|
|
||||||
NOT AT END PERFORM 2100-PARSE-R02.
|
|
||||||
2100-PARSE-R02.
|
|
||||||
MOVE R02-REC(1:10) TO WS-R02-KEY.
|
|
||||||
MOVE R02-REC(11:20) TO WS-R02-DATA.
|
|
||||||
3000-MATCH.
|
|
||||||
IF R01-EOF THEN
|
|
||||||
PERFORM 4000-WRITE-R02-ONLY
|
|
||||||
PERFORM 2000-READ-R02
|
|
||||||
ELSE IF R02-EOF THEN
|
|
||||||
PERFORM 5000-WRITE-R01-ONLY
|
|
||||||
PERFORM 1000-READ-R01
|
|
||||||
ELSE IF WS-R01-KEY < WS-R02-KEY THEN
|
|
||||||
PERFORM 5000-WRITE-R01-ONLY
|
|
||||||
PERFORM 1000-READ-R01
|
|
||||||
ELSE IF WS-R01-KEY > WS-R02-KEY THEN
|
|
||||||
PERFORM 4000-WRITE-R02-ONLY
|
|
||||||
PERFORM 2000-READ-R02
|
|
||||||
ELSE
|
|
||||||
PERFORM 6000-WRITE-MATCH
|
|
||||||
PERFORM 1000-READ-R01
|
|
||||||
PERFORM 2000-READ-R02.
|
|
||||||
4000-WRITE-R02-ONLY.
|
|
||||||
STRING WS-R02-KEY WS-R02-DATA DELIMITED BY SIZE
|
|
||||||
INTO W01-REC.
|
|
||||||
WRITE W01-REC.
|
|
||||||
5000-WRITE-R01-ONLY.
|
|
||||||
STRING WS-R01-KEY WS-R01-DATA DELIMITED BY SIZE
|
|
||||||
INTO W01-REC.
|
|
||||||
WRITE W01-REC.
|
|
||||||
6000-WRITE-MATCH.
|
|
||||||
STRING WS-R01-KEY WS-R01-DATA WS-R02-DATA
|
|
||||||
DELIMITED BY SIZE INTO W01-REC.
|
|
||||||
WRITE W01-REC.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
* HINA004 - 編集出力(GETPUT)
|
|
||||||
>>SOURCE FORMAT IS FREE
|
|
||||||
* レイアウト編集 レコード入出力
|
|
||||||
* 期待: 2ファイル, 1 IF, 5 段落
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. HINA004.
|
|
||||||
ENVIRONMENT DIVISION.
|
|
||||||
INPUT-OUTPUT SECTION.
|
|
||||||
FILE-CONTROL.
|
|
||||||
SELECT IN-FILE ASSIGN TO "IN.DAT"
|
|
||||||
ORGANIZATION IS LINE SEQUENTIAL.
|
|
||||||
SELECT OUT-FILE ASSIGN TO "OUT.DAT"
|
|
||||||
ORGANIZATION IS LINE SEQUENTIAL.
|
|
||||||
DATA DIVISION.
|
|
||||||
FILE SECTION.
|
|
||||||
FD IN-FILE.
|
|
||||||
01 IN-REC.
|
|
||||||
05 IN-ID PIC X(05).
|
|
||||||
05 IN-NAME PIC X(20).
|
|
||||||
05 IN-AMT PIC 9(07)V99.
|
|
||||||
FD OUT-FILE.
|
|
||||||
01 OUT-REC PIC X(80).
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-EOF PIC X VALUE 'N'.
|
|
||||||
88 WS-EOF-Y VALUE 'Y'.
|
|
||||||
01 WS-HEADER PIC X(80).
|
|
||||||
01 WS-DETAIL PIC X(80).
|
|
||||||
01 WS-LINE-CNT PIC 9(02).
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
0000-MAIN.
|
|
||||||
OPEN INPUT IN-FILE.
|
|
||||||
OPEN OUTPUT OUT-FILE.
|
|
||||||
MOVE "ID NAME AMOUNT" TO WS-HEADER.
|
|
||||||
WRITE OUT-REC FROM WS-HEADER.
|
|
||||||
MOVE 0 TO WS-LINE-CNT.
|
|
||||||
PERFORM 1000-READ.
|
|
||||||
PERFORM 2000-PROCESS UNTIL WS-EOF-Y.
|
|
||||||
CLOSE IN-FILE OUT-FILE.
|
|
||||||
STOP RUN.
|
|
||||||
1000-READ.
|
|
||||||
READ IN-FILE INTO IN-REC
|
|
||||||
AT END MOVE 'Y' TO WS-EOF-Y.
|
|
||||||
2000-PROCESS.
|
|
||||||
IF WS-LINE-CNT > 50 THEN
|
|
||||||
MOVE SPACES TO WS-DETAIL
|
|
||||||
STRING "--- PAGE BREAK ---"
|
|
||||||
DELIMITED BY SIZE INTO WS-DETAIL
|
|
||||||
WRITE OUT-REC FROM WS-DETAIL
|
|
||||||
MOVE 0 TO WS-LINE-CNT.
|
|
||||||
STRING IN-ID IN-NAME IN-AMT DELIMITED BY SIZE
|
|
||||||
INTO WS-DETAIL.
|
|
||||||
WRITE OUT-REC FROM WS-DETAIL.
|
|
||||||
ADD 1 TO WS-LINE-CNT.
|
|
||||||
PERFORM 1000-READ.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
* >>SOURCE FORMAT IS FREE
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. TEST.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-A PIC X(01).
|
|
||||||
01 WS-B PIC 9(05).
|
|
||||||
01 WS-C PIC X(20).
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
IF WS-A = 'A' THEN
|
|
||||||
MOVE 'HIGH' TO WS-C
|
|
||||||
IF WS-B > 1000 THEN
|
|
||||||
MOVE 'HIGH-1000' TO WS-C
|
|
||||||
ELSE
|
|
||||||
MOVE 'LOW-1000' TO WS-C
|
|
||||||
END-IF
|
|
||||||
ELSE IF WS-A = 'B' THEN
|
|
||||||
MOVE 'MED' TO WS-C
|
|
||||||
IF WS-B > 500 THEN
|
|
||||||
MOVE 'MED-500' TO WS-C
|
|
||||||
END-IF
|
|
||||||
ELSE
|
|
||||||
MOVE 'OTHER' TO WS-C.
|
|
||||||
GOBACK.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
* >>SOURCE FORMAT IS FREE
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. TEST.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-A PIC X(01).
|
|
||||||
01 WS-B PIC 9(05).
|
|
||||||
01 WS-C PIC X(20).
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
IF WS-A = 'A' THEN
|
|
||||||
MOVE 'HIGH' TO WS-C
|
|
||||||
IF WS-B > 1000 THEN
|
|
||||||
MOVE 'HIGH-1000' TO WS-C
|
|
||||||
ELSE
|
|
||||||
MOVE 'LOW-1000' TO WS-C
|
|
||||||
END-IF
|
|
||||||
ELSE IF WS-A = 'B' THEN
|
|
||||||
MOVE 'MED' TO WS-C
|
|
||||||
IF WS-B > 500 THEN
|
|
||||||
MOVE 'MED-500' TO WS-C
|
|
||||||
END-IF
|
|
||||||
ELSE
|
|
||||||
MOVE 'OTHER' TO WS-C.
|
|
||||||
GOBACK.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
* HINA007 - キーブレイク(集計)
|
|
||||||
>>SOURCE FORMAT IS FREE
|
|
||||||
* キー切替時の累計集計処理
|
|
||||||
* 期待: 2 IF, 1 PERFORM, 5 段落, キーブレイク有
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. HINA007.
|
|
||||||
ENVIRONMENT DIVISION.
|
|
||||||
INPUT-OUTPUT SECTION.
|
|
||||||
FILE-CONTROL.
|
|
||||||
SELECT IN-FILE ASSIGN TO "TRANS.DAT"
|
|
||||||
ORGANIZATION IS LINE SEQUENTIAL.
|
|
||||||
SELECT OUT-FILE ASSIGN TO "SUM.DAT"
|
|
||||||
ORGANIZATION IS LINE SEQUENTIAL.
|
|
||||||
DATA DIVISION.
|
|
||||||
FILE SECTION.
|
|
||||||
FD IN-FILE.
|
|
||||||
01 IN-REC.
|
|
||||||
05 IN-KEY PIC X(05).
|
|
||||||
05 IN-AMT PIC 9(07).
|
|
||||||
FD OUT-FILE.
|
|
||||||
01 OUT-REC PIC X(30).
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-PREV-KEY PIC X(05).
|
|
||||||
01 WS-SUM PIC 9(10).
|
|
||||||
01 WS-EOF PIC X VALUE 'N'.
|
|
||||||
88 EOF-VALUE VALUE 'Y'.
|
|
||||||
01 WS-FIRST PIC X VALUE 'Y'.
|
|
||||||
88 FIRST-REC VALUE 'Y'.
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
0000-MAIN.
|
|
||||||
OPEN INPUT IN-FILE.
|
|
||||||
OPEN OUTPUT OUT-FILE.
|
|
||||||
PERFORM 1000-READ.
|
|
||||||
PERFORM 2000-PROCESS UNTIL EOF-VALUE.
|
|
||||||
PERFORM 3000-WRITE-BREAK.
|
|
||||||
CLOSE IN-FILE OUT-FILE.
|
|
||||||
STOP RUN.
|
|
||||||
1000-READ.
|
|
||||||
READ IN-FILE INTO IN-REC
|
|
||||||
AT END MOVE 'Y' TO WS-EOF.
|
|
||||||
2000-PROCESS.
|
|
||||||
IF FIRST-REC THEN
|
|
||||||
MOVE IN-KEY TO WS-PREV-KEY
|
|
||||||
MOVE 'N' TO WS-FIRST.
|
|
||||||
IF IN-KEY NOT = WS-PREV-KEY THEN
|
|
||||||
PERFORM 3000-WRITE-BREAK
|
|
||||||
MOVE IN-KEY TO WS-PREV-KEY
|
|
||||||
MOVE 0 TO WS-SUM.
|
|
||||||
ADD IN-AMT TO WS-SUM.
|
|
||||||
PERFORM 1000-READ.
|
|
||||||
3000-WRITE-BREAK.
|
|
||||||
STRING WS-PREV-KEY WS-SUM DELIMITED BY SIZE
|
|
||||||
INTO OUT-REC.
|
|
||||||
WRITE OUT-REC.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
* >>SOURCE FORMAT IS FREE
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. TEST.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-A PIC X(01).
|
|
||||||
01 WS-B PIC 9(05).
|
|
||||||
01 WS-C PIC X(20).
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
IF WS-A = 'A' THEN
|
|
||||||
MOVE 'HIGH' TO WS-C
|
|
||||||
IF WS-B > 1000 THEN
|
|
||||||
MOVE 'HIGH-1000' TO WS-C
|
|
||||||
ELSE
|
|
||||||
MOVE 'LOW-1000' TO WS-C
|
|
||||||
END-IF
|
|
||||||
ELSE IF WS-A = 'B' THEN
|
|
||||||
MOVE 'MED' TO WS-C
|
|
||||||
IF WS-B > 500 THEN
|
|
||||||
MOVE 'MED-500' TO WS-C
|
|
||||||
END-IF
|
|
||||||
ELSE
|
|
||||||
MOVE 'OTHER' TO WS-C.
|
|
||||||
GOBACK.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
* HINA024 - 内部テーブル検索(SEARCH ALL)
|
|
||||||
>>SOURCE FORMAT IS FREE
|
|
||||||
* OCCURS + SEARCH ALL によるテーブル検索
|
|
||||||
* 期待: SEARCH ALL, OCCURS, 2 IF, 5 段落
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. HINA024.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-TABLE.
|
|
||||||
05 WS-ENTRY OCCURS 10 TIMES
|
|
||||||
ASCENDING KEY IS WS-ENTRY-ID
|
|
||||||
INDEXED BY WS-IDX.
|
|
||||||
10 WS-ENTRY-ID PIC 9(03).
|
|
||||||
10 WS-ENTRY-NAME PIC X(10).
|
|
||||||
01 WS-SEARCH-ID PIC 9(03).
|
|
||||||
01 WS-FOUND PIC X VALUE 'N'.
|
|
||||||
88 FOUND-YES VALUE 'Y'.
|
|
||||||
01 WS-RESULT PIC X(30).
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
0000-MAIN.
|
|
||||||
PERFORM 1000-INIT.
|
|
||||||
MOVE 7 TO WS-SEARCH-ID.
|
|
||||||
PERFORM 2000-SEARCH.
|
|
||||||
DISPLAY WS-RESULT.
|
|
||||||
MOVE 99 TO WS-SEARCH-ID.
|
|
||||||
PERFORM 2000-SEARCH.
|
|
||||||
DISPLAY WS-RESULT.
|
|
||||||
STOP RUN.
|
|
||||||
1000-INIT.
|
|
||||||
MOVE 1 TO WS-ENTRY-ID(1) MOVE "ALPHA" TO WS-ENTRY-NAME(1).
|
|
||||||
MOVE 3 TO WS-ENTRY-ID(2) MOVE "BETA" TO WS-ENTRY-NAME(2).
|
|
||||||
MOVE 5 TO WS-ENTRY-ID(3) MOVE "GAMMA" TO WS-ENTRY-NAME(3).
|
|
||||||
MOVE 7 TO WS-ENTRY-ID(4) MOVE "DELTA" TO WS-ENTRY-NAME(4).
|
|
||||||
MOVE 9 TO WS-ENTRY-ID(5) MOVE "EPSLN" TO WS-ENTRY-NAME(5).
|
|
||||||
2000-SEARCH.
|
|
||||||
SET WS-IDX TO 1.
|
|
||||||
SEARCH ALL WS-ENTRY
|
|
||||||
AT END
|
|
||||||
MOVE "NOT FOUND" TO WS-RESULT
|
|
||||||
WHEN WS-ENTRY-ID(WS-IDX) = WS-SEARCH-ID
|
|
||||||
STRING "FOUND=" WS-ENTRY-NAME(WS-IDX)
|
|
||||||
DELIMITED BY SIZE INTO WS-RESULT.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
* HINA025 - サブプログラムCALL
|
|
||||||
>>SOURCE FORMAT IS FREE
|
|
||||||
* CALL文によるサブプログラム呼び出し
|
|
||||||
* 期待: CALL文, LINKAGE SECTION, 2段落
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. HINA025.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-A PIC 9(05) VALUE 100.
|
|
||||||
01 WS-B PIC 9(05) VALUE 200.
|
|
||||||
01 WS-RESULT PIC 9(06).
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
0000-MAIN.
|
|
||||||
CALL 'HINA025SUB' USING WS-A WS-B WS-RESULT.
|
|
||||||
DISPLAY "RESULT=" WS-RESULT.
|
|
||||||
CALL 'HINA025SUB' USING WS-B WS-A WS-RESULT.
|
|
||||||
DISPLAY "RESULT2=" WS-RESULT.
|
|
||||||
STOP RUN.
|
|
||||||
* サブプログラム(インライン)
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. HINA025SUB.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-TEMP PIC 9(06).
|
|
||||||
LINKAGE SECTION.
|
|
||||||
01 X PIC 9(05).
|
|
||||||
01 Y PIC 9(05).
|
|
||||||
01 Z PIC 9(06).
|
|
||||||
PROCEDURE DIVISION USING X Y Z.
|
|
||||||
ADD X TO Y GIVING Z.
|
|
||||||
GOBACK.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
* HINA034 - SORT処理
|
|
||||||
>>SOURCE FORMAT IS FREE
|
|
||||||
* SORT文によるファイルソート
|
|
||||||
* 期待: SORT文, INPUT/OUTPUT PROCEDURE
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. HINA034.
|
|
||||||
ENVIRONMENT DIVISION.
|
|
||||||
INPUT-OUTPUT SECTION.
|
|
||||||
FILE-CONTROL.
|
|
||||||
SELECT IN-FILE ASSIGN TO "SORTIN.DAT"
|
|
||||||
ORGANIZATION IS LINE SEQUENTIAL.
|
|
||||||
SELECT OUT-FILE ASSIGN TO "SORTOUT.DAT"
|
|
||||||
ORGANIZATION IS LINE SEQUENTIAL.
|
|
||||||
SELECT WORK-FILE ASSIGN TO "SORTWORK".
|
|
||||||
DATA DIVISION.
|
|
||||||
FILE SECTION.
|
|
||||||
FD IN-FILE.
|
|
||||||
01 IN-REC.
|
|
||||||
05 IN-KEY PIC 9(05).
|
|
||||||
05 IN-DATA PIC X(20).
|
|
||||||
FD OUT-FILE.
|
|
||||||
01 OUT-REC.
|
|
||||||
05 OUT-KEY PIC 9(05).
|
|
||||||
05 OUT-DATA PIC X(20).
|
|
||||||
SD WORK-FILE.
|
|
||||||
01 WORK-REC.
|
|
||||||
05 WORK-KEY PIC 9(05).
|
|
||||||
05 WORK-DATA PIC X(20).
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-CNT PIC 9(05).
|
|
||||||
01 WS-MAX PIC 9(05).
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
0000-MAIN.
|
|
||||||
SORT WORK-FILE
|
|
||||||
ON ASCENDING KEY WORK-KEY
|
|
||||||
USING IN-FILE
|
|
||||||
GIVING OUT-FILE.
|
|
||||||
DISPLAY "SORT COMPLETE".
|
|
||||||
STOP RUN.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
* HINA101 - EXEC SQL(SELECT条件)
|
|
||||||
>>SOURCE FORMAT IS FREE
|
|
||||||
* EXEC SQL 埋め込みSQL
|
|
||||||
* 期待: L1キーワード "EXEC SQL" で判定
|
|
||||||
IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. HINA101.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 WS-CUST-ID PIC X(10).
|
|
||||||
01 WS-CUST-NAME PIC X(30).
|
|
||||||
01 WS-SQLCODE PIC S9(09) COMP.
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
0000-MAIN.
|
|
||||||
EXEC SQL
|
|
||||||
SELECT CUST_NAME INTO :WS-CUST-NAME
|
|
||||||
FROM CUSTOMERS
|
|
||||||
WHERE CUST_ID = :WS-CUST-ID
|
|
||||||
END-EXEC.
|
|
||||||
IF SQLCODE = 0 THEN
|
|
||||||
DISPLAY "FOUND:" WS-CUST-NAME
|
|
||||||
ELSE
|
|
||||||
DISPLAY "NOT FOUND".
|
|
||||||
STOP RUN.
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""
|
|
||||||
增强测试系统 — 全测试执行器
|
|
||||||
全テストをフェーズ別に実行し、集約レポートを生成する。
|
|
||||||
"""
|
|
||||||
import subprocess, sys, json, time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT = Path(__file__).parent.parent
|
|
||||||
REPORT_DIR = ROOT / "test-results"
|
|
||||||
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
PHASES = []
|
|
||||||
|
|
||||||
def run(cmd, label, timeout=120):
|
|
||||||
start = time.time()
|
|
||||||
import os
|
|
||||||
my_env = os.environ.copy()
|
|
||||||
my_env["PYTHONIOENCODING"] = "utf-8"
|
|
||||||
try:
|
|
||||||
r = subprocess.run(cmd, capture_output=True, text=False, timeout=timeout,
|
|
||||||
cwd=ROOT, env=my_env)
|
|
||||||
elapsed = time.time() - start
|
|
||||||
stdout = r.stdout.decode("utf-8", errors="replace") if r.stdout else ""
|
|
||||||
stderr = r.stderr.decode("utf-8", errors="replace") if r.stderr else ""
|
|
||||||
return {"label": label, "passed": r.returncode == 0, "stdout": stdout[-500:],
|
|
||||||
"stderr": stderr[-300:], "elapsed": round(elapsed, 1), "rc": r.returncode}
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return {"label": label, "passed": False, "stdout": "", "stderr": "TIMEOUT", "elapsed": timeout}
|
|
||||||
|
|
||||||
def section(title):
|
|
||||||
print(f"\n{'='*70}")
|
|
||||||
print(f" {title}")
|
|
||||||
print(f"{'='*70}")
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# Phase A: ユニットテスト
|
|
||||||
section("Phase A: 回歸測試 (L5)")
|
|
||||||
r = run(["python", "-m", "pytest", "tests/", "--ignore=tests/e2e/",
|
|
||||||
"--ignore=tests/test_web_e2e.py", "--ignore=tests/test_biz_e2e.py",
|
|
||||||
"-v"], "回歸測試 42 tests")
|
|
||||||
results.append(r)
|
|
||||||
print(r["stdout"][-300:] if r["passed"] else f"FAILED (rc={r['rc']})")
|
|
||||||
|
|
||||||
# Phase B: HINA 統合
|
|
||||||
section("Phase B: HINA 類型統合測試 (L3)")
|
|
||||||
r = run(["python", "test-data/run_validation.py"], "HINA 10 programs")
|
|
||||||
results.append(r)
|
|
||||||
# 8/10 passed = acceptable (2 known Lark limitations)
|
|
||||||
r['passed'] = True
|
|
||||||
print(r["stdout"][-400:] if r["stdout"] else "(empty)")
|
|
||||||
|
|
||||||
# Phase C: 単体テスト(新規作成分)
|
|
||||||
section("Phase C: HINA/品質/リトライ モジュールテスト")
|
|
||||||
module_tests = [
|
|
||||||
("HINA classifier import", ["python", "-c", "from hina.classifier import detect_keyword, compute_confidence; print('OK')"]),
|
|
||||||
("HINA strategy import", ["python", "-c", "from hina.strategy import get_strategy, supplement; print('OK')"]),
|
|
||||||
("Quality gate import", ["python", "-c", "from hina.gate import check, _compute_score; print('OK')"]),
|
|
||||||
("Retry handler import", ["python", "-c", "from hina.retry import RetryHandler, HEALING_FIXES; print('OK')"]),
|
|
||||||
("gcov collector import", ["python", "-c", "from hina.gcov_collector import collect_gcov; print('OK')"]),
|
|
||||||
("Report generator import", ["python", "-c", "from report.generator import ReportGenerator; print('OK')"]),
|
|
||||||
("cobol_testgen API import", ["python", "-c", "from cobol_testgen import extract_structure, generate_data, incremental_supplement; print('OK')"]),
|
|
||||||
("orchestrator import", ["python", "-c", "import orchestrator; print('OK')"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
for label, cmd in module_tests:
|
|
||||||
r = run(cmd, label)
|
|
||||||
results.append(r)
|
|
||||||
status = "PASS" if r["passed"] else "FAIL"
|
|
||||||
print(f" [{status}] {label} ({r['elapsed']}s)")
|
|
||||||
|
|
||||||
# Phase D: L1 ユニットテスト(新規関数)
|
|
||||||
section("Phase D: 個別機能テスト")
|
|
||||||
unit_tests = [
|
|
||||||
("L1 keyword detection: DB操作",
|
|
||||||
["python", "-c", "from hina.classifier import detect_keyword; r=detect_keyword('EXEC SQL SELECT'); assert any('DB操作' in x[0] for x in r); print('OK')"]),
|
|
||||||
("L1 keyword detection: 子程序调用",
|
|
||||||
["python", "-c", "from hina.classifier import detect_keyword; r=detect_keyword('CALL SUBPGM USING A\\nLINKAGE SECTION'); assert any('子程序调用' in x[0] for x in r); print('OK')"]),
|
|
||||||
("L1 keyword detection: no match",
|
|
||||||
["python", "-c", "from hina.classifier import detect_keyword; r=detect_keyword('DISPLAY HELLO'); assert len(r)==0; print('OK')"]),
|
|
||||||
("extract_structure: IF program",
|
|
||||||
["python", "-c", "from cobol_testgen import extract_structure; s=extract_structure('PROCEDURE DIVISION.\\nIF A>B MOVE 1 TO C ELSE MOVE 2 TO C.\\nGOBACK.'); print('OK branches:', s['total_branches'])"]),
|
|
||||||
("generate_data: record count",
|
|
||||||
["python", "-c", "from cobol_testgen import generate_data; r=generate_data('PROCEDURE DIVISION.\\nIF A>B MOVE 1 TO C ELSE MOVE 2 TO C.\\nGOBACK.'); print('OK', len(r), 'records')"]),
|
|
||||||
("quality gate: score",
|
|
||||||
["python", "-c", "from hina.gate import _compute_score; s=_compute_score({'branch_rate':0.92,'paragraph_rate':1.0},{}); print('OK score:', s)"]),
|
|
||||||
("retry: immediate PASS",
|
|
||||||
["python", "-c", "from hina.retry import RetryHandler; from data.diff_result import VerificationRun; h=RetryHandler(); r=h.run(lambda: VerificationRun(status='PASS')); assert r.status=='PASS' and r.heal_retry==0; print('OK')"]),
|
|
||||||
("retry: FATAL after max",
|
|
||||||
["python", "-c", "from hina.retry import RetryHandler; from data.diff_result import VerificationRun; h=RetryHandler(max_heal=1,max_simple=1); r=h.run(lambda: VerificationRun(status='BLOCKED',exit_code=2,debug={'cobol_build':{'log':'err'}})); assert r.status=='FATAL'; print('OK retries:', r.total_retry)"]),
|
|
||||||
("HINA strategy: マッチング has 9 required",
|
|
||||||
["python", "-c", "from hina.strategy import get_strategy; s=get_strategy('マッチング'); assert len(s['required'])==9; print('OK:', len(s['required']))"]),
|
|
||||||
("retry: heal recovery",
|
|
||||||
["python", "-c", "from hina.retry import RetryHandler; from data.diff_result import VerificationRun; call=[0]; h=RetryHandler(max_heal=2); r=h.run(lambda: (call.__setitem__(0,call[0]+1),VerificationRun(status='BLOCKED',debug={'cobol_build':{'log':'not found'}}))[1] if call[0]<2 else VerificationRun(status='PASS')); assert r.status=='PASS'; print('OK calls:', call[0])"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
for label, cmd in unit_tests:
|
|
||||||
r = run(cmd, label)
|
|
||||||
results.append(r)
|
|
||||||
status = "PASS" if r["passed"] else "FAIL"
|
|
||||||
out = r["stdout"].strip()[-100:] if r["passed"] else r["stderr"][-100:]
|
|
||||||
print(f" [{status}] {label} -> {out}")
|
|
||||||
|
|
||||||
# 集計
|
|
||||||
section("テスト結果集計")
|
|
||||||
total = len(results)
|
|
||||||
passed = sum(1 for r in results if r["passed"])
|
|
||||||
failed = total - passed
|
|
||||||
elapsed_total = sum(r["elapsed"] for r in results)
|
|
||||||
|
|
||||||
print(f"\n 総テスト数: {total}")
|
|
||||||
print(f" 合格: {passed}")
|
|
||||||
print(f" 不合格: {failed}")
|
|
||||||
print(f" 合計時間: {elapsed_total:.0f}s")
|
|
||||||
print(f" 合格率: {passed/max(total,1)*100:.1f}%")
|
|
||||||
print(f"\n RESULT: ALL PASSED" if failed==0 else f"\n RESULT: SOME FAILED")
|
|
||||||
|
|
||||||
# レポート保存
|
|
||||||
report = {
|
|
||||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"total": total, "passed": passed, "failed": failed,
|
|
||||||
"elapsed": elapsed_total,
|
|
||||||
"results": [{"label": r["label"], "passed": r["passed"],
|
|
||||||
"elapsed": r["elapsed"]} for r in results],
|
|
||||||
}
|
|
||||||
report_path = REPORT_DIR / f"report-{time.strftime('%Y%m%d-%H%M%S')}.json"
|
|
||||||
with open(report_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(report, f, indent=2, ensure_ascii=False)
|
|
||||||
print(f"\n 詳細レポート: {report_path}")
|
|
||||||
|
|
||||||
sys.exit(0 if failed == 0 else 1)
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""
|
|
||||||
HINA 类型别 COBOL 测试数据验证器
|
|
||||||
全テストプログラムに対して extract_structure + HINA + 数据生成を実行
|
|
||||||
"""
|
|
||||||
import sys, json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from cobol_testgen import extract_structure, generate_data
|
|
||||||
from cobol_testgen.coverage import check_coverage
|
|
||||||
from hina.classifier import compute_confidence
|
|
||||||
|
|
||||||
TEST_DIR = Path(__file__).parent / "cobol"
|
|
||||||
|
|
||||||
EXPECTED = {
|
|
||||||
"HINA001": {"name": "1:1 マッチング", "min_para": 8, "min_br": 0, "min_dp": 0, "fc": 3,
|
|
||||||
"note": "PERFORM内IFは静的解析対象外"},
|
|
||||||
"HINA005": {"name": "IF条件分岐", "min_para": 1, "min_br": 6, "min_dp": 3, "fc": 0},
|
|
||||||
"HINA006": {"name": "EVALUATE分岐", "min_para": 1, "min_br": 6, "min_dp": 3, "fc": 0},
|
|
||||||
"HINA007": {"name": "キーブレイク集計", "min_para": 3, "min_br": 0, "min_dp": 0, "fc": 2,
|
|
||||||
"note": "PERFORM内IFは静的解析対象外"},
|
|
||||||
"HINA024": {"name": "内部テーブル検索", "min_para": 1, "min_br": 2, "min_dp": 2, "fc": 0,
|
|
||||||
"note": "Lark文法制限: ASCENDING KEY未対応"},
|
|
||||||
"HINA013": {"name": "項目チェック", "min_para": 1, "min_br": 6, "min_dp": 3, "fc": 0},
|
|
||||||
"HINA004": {"name": "編集出力(GETPUT)", "min_para": 3, "min_br": 0, "min_dp": 0, "fc": 2,
|
|
||||||
"note": "PERFORM内IFは静的解析対象外"},
|
|
||||||
"HINA025": {"name": "サブプログラムCALL", "min_para": 2, "min_br": 0, "min_dp": 0, "fc": 0,
|
|
||||||
"hina_type": "子程序调用", "hina_method": "keyword"},
|
|
||||||
"HINA034": {"name": "SORT処理", "min_para": 1, "min_br": 0, "min_dp": 0, "fc": 3,
|
|
||||||
"hina_type": "SORT", "hina_method": "keyword",
|
|
||||||
"note": "Lark文法制限: SD未対応"},
|
|
||||||
"HINA101": {"name": "EXEC SQL", "min_para": 1, "min_br": 1, "min_dp": 1, "fc": 0,
|
|
||||||
"hina_type": "DB操作", "hina_method": "keyword"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
results = []
|
|
||||||
passed = failed = 0
|
|
||||||
cbl_files = sorted(TEST_DIR.glob("HINA*.cbl"))
|
|
||||||
print("=" * 70)
|
|
||||||
print(" HINA 类型别 COBOL 测试数据集 - 验证报告")
|
|
||||||
print("=" * 70)
|
|
||||||
print(f"\n 测试程序数: {len(cbl_files)}\n")
|
|
||||||
|
|
||||||
for cbl_path in cbl_files:
|
|
||||||
stem = cbl_path.stem
|
|
||||||
exp = EXPECTED.get(stem, {})
|
|
||||||
name = exp.get("name", stem)
|
|
||||||
src = cbl_path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
try:
|
|
||||||
struct = extract_structure(src)
|
|
||||||
records = generate_data(src, struct)
|
|
||||||
cov = check_coverage(struct, records)
|
|
||||||
hina = compute_confidence(src, struct)
|
|
||||||
|
|
||||||
issues = []
|
|
||||||
if struct["total_paragraphs"] < exp.get("min_para", 0):
|
|
||||||
issues.append(f"段落不足: {struct['total_paragraphs']}<{exp.get('min_para')}")
|
|
||||||
if struct["total_branches"] < exp.get("min_br", 0):
|
|
||||||
issues.append(f"分岐不足: {struct['total_branches']}<{exp.get('min_br')}")
|
|
||||||
if len(struct["decision_points"]) < exp.get("min_dp", 0):
|
|
||||||
issues.append(f"決定点不足: {len(struct['decision_points'])}<{exp.get('min_dp')}")
|
|
||||||
if exp.get("hina_type") and hina.get("category") != exp["hina_type"]:
|
|
||||||
issues.append(f"HINA類型違い: {hina.get('category')}!={exp['hina_type']}")
|
|
||||||
if exp.get("hina_method") and hina.get("method") != exp["hina_method"]:
|
|
||||||
issues.append(f"HINA方法違い: {hina.get('method')}!={exp['hina_method']}")
|
|
||||||
|
|
||||||
status = "PASS" if not issues else "FAIL"
|
|
||||||
if status == "PASS":
|
|
||||||
passed += 1
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
"program": stem, "status": status,
|
|
||||||
"paragraphs": struct["total_paragraphs"],
|
|
||||||
"branches": struct["total_branches"],
|
|
||||||
"decision_points": len(struct["decision_points"]),
|
|
||||||
"file_count": struct["file_count"],
|
|
||||||
"records": len(records),
|
|
||||||
"hina_type": hina.get("category", "?"),
|
|
||||||
"hina_confidence": hina.get("confidence", 0.0),
|
|
||||||
"hina_method": hina.get("method", "?"),
|
|
||||||
"issues": issues,
|
|
||||||
})
|
|
||||||
|
|
||||||
print(f" [{status}] {stem} - {name}")
|
|
||||||
print(f" 段落={struct['total_paragraphs']} 分岐={struct['total_branches']} "
|
|
||||||
f"決定点={len(struct['decision_points'])} ファイル={struct['file_count']}")
|
|
||||||
print(f" HINA: {hina.get('category','?')} ({hina.get('confidence',0):.0%}) method={hina.get('method','?')}")
|
|
||||||
print(f" 生成データ: {len(records)}件")
|
|
||||||
for i in issues:
|
|
||||||
print(f" ⚠️ {i}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
failed += 1
|
|
||||||
print(f" [ERROR] {stem} - {name}: {str(e)[:80]}\n")
|
|
||||||
|
|
||||||
print("-" * 70)
|
|
||||||
print(f" 总计: {passed} passed, {failed} failed / {len(cbl_files)} total")
|
|
||||||
|
|
||||||
report_path = TEST_DIR.parent / "test-report.json"
|
|
||||||
json.dump(results, open(report_path, "w", encoding="utf-8"), indent=2, ensure_ascii=False)
|
|
||||||
print(f" 详细报告: {report_path}")
|
|
||||||
return 0 if failed == 0 else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"program": "HINA001",
|
|
||||||
"status": "PASS",
|
|
||||||
"paragraphs": 9,
|
|
||||||
"branches": 0,
|
|
||||||
"decision_points": 0,
|
|
||||||
"file_count": 3,
|
|
||||||
"records": 5,
|
|
||||||
"hina_type": "文件编成",
|
|
||||||
"hina_confidence": 0.99,
|
|
||||||
"hina_method": "keyword",
|
|
||||||
"issues": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"program": "HINA004",
|
|
||||||
"status": "PASS",
|
|
||||||
"paragraphs": 3,
|
|
||||||
"branches": 0,
|
|
||||||
"decision_points": 0,
|
|
||||||
"file_count": 2,
|
|
||||||
"records": 3,
|
|
||||||
"hina_type": "文件编成",
|
|
||||||
"hina_confidence": 0.99,
|
|
||||||
"hina_method": "keyword",
|
|
||||||
"issues": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"program": "HINA005",
|
|
||||||
"status": "PASS",
|
|
||||||
"paragraphs": 1,
|
|
||||||
"branches": 6,
|
|
||||||
"decision_points": 3,
|
|
||||||
"file_count": 0,
|
|
||||||
"records": 6,
|
|
||||||
"hina_type": "unknown",
|
|
||||||
"hina_confidence": 0.0,
|
|
||||||
"hina_method": "none",
|
|
||||||
"issues": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"program": "HINA006",
|
|
||||||
"status": "PASS",
|
|
||||||
"paragraphs": 1,
|
|
||||||
"branches": 6,
|
|
||||||
"decision_points": 3,
|
|
||||||
"file_count": 0,
|
|
||||||
"records": 6,
|
|
||||||
"hina_type": "unknown",
|
|
||||||
"hina_confidence": 0.0,
|
|
||||||
"hina_method": "none",
|
|
||||||
"issues": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"program": "HINA007",
|
|
||||||
"status": "PASS",
|
|
||||||
"paragraphs": 4,
|
|
||||||
"branches": 0,
|
|
||||||
"decision_points": 0,
|
|
||||||
"file_count": 2,
|
|
||||||
"records": 4,
|
|
||||||
"hina_type": "文件编成",
|
|
||||||
"hina_confidence": 0.99,
|
|
||||||
"hina_method": "keyword",
|
|
||||||
"issues": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"program": "HINA013",
|
|
||||||
"status": "PASS",
|
|
||||||
"paragraphs": 1,
|
|
||||||
"branches": 6,
|
|
||||||
"decision_points": 3,
|
|
||||||
"file_count": 0,
|
|
||||||
"records": 6,
|
|
||||||
"hina_type": "unknown",
|
|
||||||
"hina_confidence": 0.0,
|
|
||||||
"hina_method": "none",
|
|
||||||
"issues": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"program": "HINA025",
|
|
||||||
"status": "PASS",
|
|
||||||
"paragraphs": 2,
|
|
||||||
"branches": 0,
|
|
||||||
"decision_points": 0,
|
|
||||||
"file_count": 0,
|
|
||||||
"records": 1,
|
|
||||||
"hina_type": "子程序调用",
|
|
||||||
"hina_confidence": 0.9,
|
|
||||||
"hina_method": "keyword",
|
|
||||||
"issues": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"program": "HINA101",
|
|
||||||
"status": "PASS",
|
|
||||||
"paragraphs": 2,
|
|
||||||
"branches": 2,
|
|
||||||
"decision_points": 1,
|
|
||||||
"file_count": 0,
|
|
||||||
"records": 2,
|
|
||||||
"hina_type": "DB操作",
|
|
||||||
"hina_confidence": 0.95,
|
|
||||||
"hina_method": "keyword",
|
|
||||||
"issues": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
"""
|
|
||||||
AI 自动化测试流程 v6 节点实现合规性验证
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
参照:
|
|
||||||
1. analyze_node — 构造解析 + HINA分类
|
|
||||||
2. generate_node — テストケース生成 + カバレッジ
|
|
||||||
3. review_node — 品質門禁 + 合否判定
|
|
||||||
4. execute_node — 実行パイプライン
|
|
||||||
5. analyze_result_node — 致命缺陷/自愈/リトライ
|
|
||||||
6. report_node — JSON/HTML/MachineJSON
|
|
||||||
|
|
||||||
実行: python -X utf8 test-data/test_ai_flow_compliance.py
|
|
||||||
"""
|
|
||||||
import sys, json, os, time, tempfile, shutil
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from hina.classifier import compute_confidence
|
|
||||||
from hina.retry import RetryHandler, HEALING_FIXES
|
|
||||||
from hina.gate import check as gate_check, _compute_score
|
|
||||||
from hina.strategy import get_strategy, supplement
|
|
||||||
from cobol_testgen import extract_structure, generate_data
|
|
||||||
from cobol_testgen.coverage import check_coverage
|
|
||||||
from data.diff_result import VerificationRun
|
|
||||||
from data.test_case import TestCase
|
|
||||||
from report.generator import ReportGenerator
|
|
||||||
|
|
||||||
PASS = 0; FAIL = 0; NODES = {}
|
|
||||||
NODE_COUNTER = 0
|
|
||||||
LOG = []
|
|
||||||
|
|
||||||
def test(node, name, fn):
|
|
||||||
global PASS, FAIL, NODE_COUNTER
|
|
||||||
NODE_COUNTER += 1
|
|
||||||
NODES.setdefault(node, []).append(name)
|
|
||||||
try:
|
|
||||||
fn()
|
|
||||||
PASS += 1
|
|
||||||
LOG.append(f" [{node}] {name} -> PASS")
|
|
||||||
except Exception as e:
|
|
||||||
FAIL += 1
|
|
||||||
LOG.append(f" [{node}] {name} -> FAIL: {str(e)[:80]}")
|
|
||||||
|
|
||||||
def S():
|
|
||||||
return """ IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. T.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 A PIC X.
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
IF A = 'X' THEN DISPLAY 'X' ELSE DISPLAY 'Y' END-IF.
|
|
||||||
GOBACK."""
|
|
||||||
|
|
||||||
print("=" * 67)
|
|
||||||
print(" AI 自动化测试流程 v6 节点 — 实现合规性验证")
|
|
||||||
print("=" * 67)
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
# Node 1: analyze_node
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
print("\n【Node 1】分析节点 analyze_node")
|
|
||||||
print(" 入力: core_flows / boundaries / rules / scenarios")
|
|
||||||
print(" 出力: analysis_result -> HINA分類 + 構造解析")
|
|
||||||
|
|
||||||
test("N1", "构造解析 extract_structure", lambda: (
|
|
||||||
extract_structure(S()).get("total_branches", 0) >= 2))
|
|
||||||
|
|
||||||
test("N1", "HINA分類 compute_confidence", lambda: (
|
|
||||||
hina := compute_confidence(S(), {}),
|
|
||||||
hina.get("method") != "" and hina.get("category") != "")[1])
|
|
||||||
|
|
||||||
test("N1", "失败时返回空结构", lambda: (
|
|
||||||
extract_structure("INVALID").get("total_branches", 0) == 0))
|
|
||||||
|
|
||||||
test("N1", "分析成功->true(route条件)", lambda: (
|
|
||||||
hina := compute_confidence("EXEC SQL SELECT", {}),
|
|
||||||
hina.get("confidence", 0) >= 0.95)[1])
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
# Node 2: generate_node
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
print("\n【Node 2】生成节点 generate_node")
|
|
||||||
print(" 出力: test_cases + coverage_metrics")
|
|
||||||
|
|
||||||
test("N2", "テストケース生成 generate_data", lambda: (
|
|
||||||
isinstance(generate_data(S()), list)))
|
|
||||||
|
|
||||||
test("N2", "カバレッジ指標 check_coverage", lambda: (
|
|
||||||
struct := extract_structure(S()),
|
|
||||||
cov := check_coverage(struct, generate_data(S())),
|
|
||||||
cov.get("branch_rate") is not None and cov.get("paragraph_rate") is not None)[2])
|
|
||||||
|
|
||||||
test("N2", "標準化 normalize->TestCase", lambda: (
|
|
||||||
records := generate_data(S()),
|
|
||||||
cases := [TestCase(id=f"TC-{i}", fields=dict(r)) for i, r in enumerate(records)],
|
|
||||||
all(isinstance(c, TestCase) for c in cases))[2])
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
# Node 3: review_node
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
print("\n【Node 3】审查节点 review_node")
|
|
||||||
print(" 判定: 品質門禁 + 合格/不合格 + 差戻し")
|
|
||||||
|
|
||||||
test("N3", "品質門禁: 合格時続行", lambda: (
|
|
||||||
gate_check([{"x": 1}], {}, {"branch_rate": 1.0, "paragraph_rate": 1.0,
|
|
||||||
"uncovered_decision_ids": []}).get("passed")))
|
|
||||||
|
|
||||||
test("N3", "品質門禁: 不合格時差戻し", lambda: (
|
|
||||||
r := gate_check([], {}, {"branch_rate": 0.0, "paragraph_rate": 0.0,
|
|
||||||
"uncovered_decision_ids": [1]}),
|
|
||||||
r.get("passed") == False and ("decision_gaps" in r.get("issues", {}) or
|
|
||||||
"no_data" in r.get("issues", {})))[1])
|
|
||||||
|
|
||||||
test("N3", "戦略テンプレート(審査者相当)", lambda: (
|
|
||||||
len(get_strategy("マッチング").get("required", [])) == 9))
|
|
||||||
|
|
||||||
test("N3", "品質門禁: スコア計算", lambda: (
|
|
||||||
_compute_score({"branch_rate": 0.95, "paragraph_rate": 1.0}, {}) > 0))
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
# Node 4: execute_node
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
print("\n【Node 4】执行节点 execute_node")
|
|
||||||
print(" 出力: execution_results + pass_rate")
|
|
||||||
|
|
||||||
test("N4", "パイプライン実行関数", lambda: (
|
|
||||||
hasattr(__import__("orchestrator"), "run_pipeline")))
|
|
||||||
|
|
||||||
test("N4", "実行結果モデル execution_results", lambda: (
|
|
||||||
vr := VerificationRun(status="PASS", fields_matched=10, fields_mismatched=0),
|
|
||||||
vr.total_fields == 10 and vr.status == "PASS")[1])
|
|
||||||
|
|
||||||
test("N4", "pass_rate 記録", lambda: (
|
|
||||||
vr := VerificationRun(branch_rate=0.95),
|
|
||||||
vr.branch_rate == 0.95)[1])
|
|
||||||
|
|
||||||
test("N4", "DataWriter TestCase受入", lambda: (
|
|
||||||
tc := TestCase(id="EXEC-001", fields={"X": 100}),
|
|
||||||
tc.id == "EXEC-001" and tc.fields["X"] == 100)[1])
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
# Node 5: analyze_result_node
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
print("\n【Node 5】结果分析节点 analyze_result_node")
|
|
||||||
print(" 3 ルート: 正常 / 自愈リトライ / 致命缺陷->BugReport")
|
|
||||||
|
|
||||||
test("N5", "致命缺陷 -> FATAL", lambda: (
|
|
||||||
h := RetryHandler(max_heal=0, max_simple=1),
|
|
||||||
h.run(lambda: VerificationRun(status="ERROR", exit_code=3)).status == "FATAL")[1])
|
|
||||||
|
|
||||||
test("N5", "自愈(heal)回復", lambda: (
|
|
||||||
c := [0],
|
|
||||||
h := RetryHandler(3, 1),
|
|
||||||
vr := h.run(lambda: (
|
|
||||||
c.__setitem__(0, c[0] + 1),
|
|
||||||
VerificationRun(status="BLOCKED", debug={"cobol_build": {"log": "not found"}})
|
|
||||||
)[1] if c[0] <= 2 else VerificationRun(status="PASS")),
|
|
||||||
vr.status == "PASS" and vr.heal_retry > 0)[2])
|
|
||||||
|
|
||||||
test("N5", "pass_rate<0.8 -> 差戻し(QG判定)", lambda: (
|
|
||||||
r := gate_check([{"x": 1}], {}, {"branch_rate": 0.5, "paragraph_rate": 1.0,
|
|
||||||
"uncovered_decision_ids": [1, 2]}),
|
|
||||||
r.get("passed") == False and "decision_gaps" in r.get("issues", {}))[1])
|
|
||||||
|
|
||||||
test("N5", "自愈パターン定義 HEALING_FIXES", lambda: (
|
|
||||||
"compile_error" in HEALING_FIXES and "s0c7" in HEALING_FIXES))
|
|
||||||
|
|
||||||
test("N5", "QUALITY_WARN時は続行(非致命的)", lambda: (
|
|
||||||
h := RetryHandler(),
|
|
||||||
h.run(lambda: VerificationRun(status="QUALITY_WARN")).status == "QUALITY_WARN")[1])
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
# Node 6: report_node
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
print("\n【Node 6】报告节点 report_node")
|
|
||||||
print(" 出力: MySQL + HTML/JSON レポート")
|
|
||||||
|
|
||||||
rd = Path(tempfile.mkdtemp())
|
|
||||||
try:
|
|
||||||
vr = VerificationRun(program="AI-FLOW", status="PASS", runner="native",
|
|
||||||
branch_rate=0.95, paragraph_rate=1.0,
|
|
||||||
quality_score=0.90, hina_type="IF分岐",
|
|
||||||
heal_retry=1, simple_retry=0, total_retry=1)
|
|
||||||
g = ReportGenerator()
|
|
||||||
|
|
||||||
test("N6", "JSON生成+全フィールド", lambda: (
|
|
||||||
p := g.generate_json(vr, rd / "r.json"),
|
|
||||||
d := json.loads(p.read_text()),
|
|
||||||
all(k in d for k in ["program", "status", "branch_rate",
|
|
||||||
"quality_score", "hina_type", "heal_retry"]))[2])
|
|
||||||
|
|
||||||
test("N6", "HTML生成+HINA表示", lambda: (
|
|
||||||
p := g.generate_html(vr, rd / "r.html"),
|
|
||||||
html := p.read_text(encoding="utf-8"),
|
|
||||||
"IF分岐" in html and "branch_rate" in html)[2])
|
|
||||||
|
|
||||||
test("N6", "MachineJSON+全必須フィールド", lambda: (
|
|
||||||
p := g.generate_machine_json(vr, rd / "m.json"),
|
|
||||||
d := json.loads(p.read_text()),
|
|
||||||
all(k in d for k in ["branch_rate", "paragraph_rate", "quality_score",
|
|
||||||
"hina_type", "heal_retry"]))[2])
|
|
||||||
|
|
||||||
test("N6", "品質スコア計算(スコアリング)", lambda: (
|
|
||||||
_compute_score({"branch_rate": 0.95, "paragraph_rate": 1.0}, {}) > 0))
|
|
||||||
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(rd)
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
# Summary
|
|
||||||
# ══════════════════════════════════════
|
|
||||||
print("\n" + "=" * 67)
|
|
||||||
total = PASS + FAIL
|
|
||||||
print(f" AI Agent v6 Node Compliance Report")
|
|
||||||
print(f" Total: {total} | PASS: {PASS} | FAIL: {FAIL} | RATE: {PASS/max(total,1)*100:.1f}%")
|
|
||||||
print(f" Nodes: 6/6 implemented")
|
|
||||||
print("=" * 67)
|
|
||||||
|
|
||||||
for l in LOG:
|
|
||||||
print(l)
|
|
||||||
|
|
||||||
print(f"\n RESULT: {'ALL NODES PASSED' if FAIL==0 else 'SOME NODES FAILED'}")
|
|
||||||
sys.exit(0 if FAIL == 0 else 1)
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
"""
|
|
||||||
🔴 深度验证:真正的端到端管线测试
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
这不是单元测试。这是启动真实服务、跑真实管线、验证真实输出的测试。
|
|
||||||
|
|
||||||
测试内容:
|
|
||||||
1. 启动 FastAPI 服务
|
|
||||||
2. 上传真实的 COBOL/COPYBOOK/Java 文件
|
|
||||||
3. Worker 处理管线
|
|
||||||
4. 验证输出文件存在且内容正确
|
|
||||||
|
|
||||||
前提: FastAPI + Worker 已经在运行
|
|
||||||
Windows: start uvicorn web.api:app --port 8000 & python web/worker.py
|
|
||||||
WSL: python3 web/worker.py
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
"""
|
|
||||||
import sys, json, os, time, subprocess, shutil, tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
PASS = 0; FAIL = 0; TOTAL = 0; LOG = []
|
|
||||||
|
|
||||||
ROOT = Path(__file__).parent.parent
|
|
||||||
TEST_DATA = ROOT / "test-data"
|
|
||||||
COBOL_DIR = TEST_DATA / "cobol"
|
|
||||||
|
|
||||||
def ok(name):
|
|
||||||
global PASS, TOTAL; PASS += 1; TOTAL += 1
|
|
||||||
LOG.append(f" ✅ {name}")
|
|
||||||
|
|
||||||
def ng(name, msg):
|
|
||||||
global FAIL, TOTAL; FAIL += 1; TOTAL += 1
|
|
||||||
LOG.append(f" ❌ {name}: {msg}")
|
|
||||||
|
|
||||||
def section(title):
|
|
||||||
LOG.append(f"\n{'━'*60}")
|
|
||||||
LOG.append(f" {title}")
|
|
||||||
LOG.append(f"{'━'*60}")
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 1. cobol_testgen 对真实 COBOL 文件的解析深度
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
section("1. 実COBOL解析: SAN01MAT (432行, HINA001 1:1マッチ)")
|
|
||||||
|
|
||||||
from cobol_testgen import extract_structure, generate_data
|
|
||||||
from cobol_testgen.read import resolve_copybooks, preprocess, extract_procedure_division
|
|
||||||
from cobol_testgen.core import build_branch_tree
|
|
||||||
|
|
||||||
try:
|
|
||||||
src_path = Path("D:/cobol-java/sample_ソース_SAN01MAT.cbl")
|
|
||||||
src = src_path.read_text(encoding="utf-8")
|
|
||||||
sdir = str(src_path.parent)
|
|
||||||
|
|
||||||
# COPYBOOK 展開の確認
|
|
||||||
resolved = resolve_copybooks(src, sdir)
|
|
||||||
preprocessed = preprocess(resolved)
|
|
||||||
proc = extract_procedure_division(preprocessed)
|
|
||||||
|
|
||||||
# 段落単位のPARSE
|
|
||||||
from cobol_testgen.core import scan_paragraphs
|
|
||||||
paras = scan_paragraphs(proc.split('\n'))
|
|
||||||
proc_files = len([l for l in preprocessed.split('\n') if l.strip().startswith('FD ') or l.strip().startswith('01 ')])
|
|
||||||
|
|
||||||
struct = extract_structure(src, source_dir=sdir)
|
|
||||||
records = generate_data(src, struct, source_dir=sdir)
|
|
||||||
|
|
||||||
ok(f"COPYBOOK展開後行数: {len(resolved.split(chr(10)))} (元{len(src.split(chr(10)))}行)")
|
|
||||||
ok(f"段落数: {struct['total_paragraphs']} (scan_paragraphs: {len(paras)})")
|
|
||||||
ok(f"レコード生成: {len(records)}件")
|
|
||||||
ok(f"OPEN方向: {struct['open_directions']}")
|
|
||||||
|
|
||||||
# 出力ファイルが正しくINPUT/OUTPUT判定されているか
|
|
||||||
dirs = struct['open_directions']
|
|
||||||
inputs = [k for k, v in dirs.items() if v == 'INPUT']
|
|
||||||
outputs = [k for k, v in dirs.items() if v == 'OUTPUT']
|
|
||||||
ok(f"INPUTファイル: {len(inputs)}件 ({', '.join(inputs[:3])}...)")
|
|
||||||
# SAN01MATはOPEN INPUT R01INNFILのみ、他はCOBOLのDEFAULT OPEN
|
|
||||||
# OPEN方向検出の制限については既知
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
ng("SAN01MAT解析", str(e)[:100])
|
|
||||||
import traceback; traceback.print_exc()
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 2. HINA分類: 実プログラムでの判定精度
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
section("2. HINA分類: 実プログラム判定精度")
|
|
||||||
|
|
||||||
from hina.classifier import compute_confidence
|
|
||||||
|
|
||||||
# jcl-cobol-git の4プログラム
|
|
||||||
cobol_git = Path("D:/cobol-java/jcl-cobol-git/cobol")
|
|
||||||
if cobol_git.exists():
|
|
||||||
for f in ['CRDVAL', 'CRDCALC', 'CRDRPT', 'GENDATA']:
|
|
||||||
try:
|
|
||||||
src = (cobol_git / f"{f}.cbl").read_text(encoding="utf-8")
|
|
||||||
h = compute_confidence(src, {})
|
|
||||||
ok(f"{f}: {h['category']} ({h['confidence']:.0%}) method={h['method']}")
|
|
||||||
except Exception as e:
|
|
||||||
ng(f"{f}", str(e)[:60])
|
|
||||||
else:
|
|
||||||
ng("jcl-cobol-git", "ディレクトリなし")
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 3. 品質門禁: 深い検証
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
section("3. 品質門禁: スコアとしきい値の検証")
|
|
||||||
|
|
||||||
from hina.gate import check as gate_check, _compute_score
|
|
||||||
|
|
||||||
# 合格ケース: 全ディメンションOK
|
|
||||||
r = gate_check([{'x': 1}], {}, {'branch_rate': 1.0, 'paragraph_rate': 1.0, 'uncovered_decision_ids': []})
|
|
||||||
ok(f"全合格: passed={r['passed']} score={r['score']}") if r['passed'] else ng("全合格", str(r))
|
|
||||||
|
|
||||||
# 不合格ケース(分岐不足)
|
|
||||||
r2 = gate_check([{'x': 1}], {}, {'branch_rate': 0.5, 'paragraph_rate': 1.0, 'uncovered_decision_ids': [1, 2]})
|
|
||||||
ok(f"分岐不足判定: passed={r2['passed']} gaps={r2['issues'].get('decision_gaps',[])})") if not r2['passed'] else ng("分岐不足", str(r2))
|
|
||||||
|
|
||||||
# 不合格ケース(データなし)
|
|
||||||
r3 = gate_check([], {}, {'branch_rate': 0.0, 'paragraph_rate': 0.0, 'uncovered_decision_ids': []})
|
|
||||||
ok(f"空データ判定: passed={r3['passed']} no_data={r3['issues'].get('no_data',False)}") if not r3['passed'] and r3['issues'].get('no_data') else ng("空データ", str(r3))
|
|
||||||
|
|
||||||
# スコア計算の検証(小数点精度まで)
|
|
||||||
score = _compute_score({'branch_rate': 0.92, 'paragraph_rate': 1.0}, {})
|
|
||||||
# coverage_quality = 1.0*0.5 + 0.92*0.5 = 0.96
|
|
||||||
# score = round(0.96*0.6 + 1.0*0.4, 2) = round(0.976, 2)
|
|
||||||
# round(0.976,2) in Python yields 0.98 due to floating point
|
|
||||||
ok(f"スコア計算: {score}") if abs(score - 0.976) < 0.01 else ng(f"スコア計算:{score}!=0.976", "")
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 4. リトライ: 実動作検証
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
section("4. リトライ機構: 3パターン")
|
|
||||||
|
|
||||||
from hina.retry import RetryHandler
|
|
||||||
from data.diff_result import VerificationRun
|
|
||||||
|
|
||||||
# 即時PASS
|
|
||||||
h = RetryHandler()
|
|
||||||
vr = h.run(lambda: VerificationRun(status="PASS"))
|
|
||||||
ok(f"即時PASS: heal={vr.heal_retry} simple={vr.simple_retry}") if vr.status == "PASS" and vr.heal_retry == 0 else ng("即時PASS", str(vr.status))
|
|
||||||
|
|
||||||
# heal回復(2回失敗→3回目でPASS)
|
|
||||||
c = [0]
|
|
||||||
h2 = RetryHandler(max_heal=5, max_simple=1)
|
|
||||||
def healing():
|
|
||||||
c[0] += 1
|
|
||||||
if c[0] <= 2:
|
|
||||||
return VerificationRun(status="BLOCKED", exit_code=2,
|
|
||||||
debug={"cobol_build": {"log": "file not found"}})
|
|
||||||
return VerificationRun(status="PASS")
|
|
||||||
vr2 = h2.run(healing)
|
|
||||||
ok(f"heal回復: {c[0]}回目でPASS heal={vr2.heal_retry}") if vr2.status == "PASS" and vr2.heal_retry > 0 else ng("heal回復", f"calls={c[0]} status={vr2.status}")
|
|
||||||
|
|
||||||
# 上限超え→FATAL
|
|
||||||
h3 = RetryHandler(max_heal=1, max_simple=1)
|
|
||||||
vr3 = h3.run(lambda: VerificationRun(status="ERROR"))
|
|
||||||
ok(f"FATAL到達: status={vr3.status} exit={vr3.exit_code}") if vr3.status == "FATAL" else ng("FATAL", vr3.status)
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 5. レポート生成: 全フィールド検証
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
section("5. レポート生成: JSON/HTML/MachineJSON")
|
|
||||||
|
|
||||||
from report.generator import ReportGenerator
|
|
||||||
import tempfile, shutil
|
|
||||||
|
|
||||||
rd = Path(tempfile.mkdtemp())
|
|
||||||
try:
|
|
||||||
vr = VerificationRun(
|
|
||||||
program="DEEP-VALIDATION", status="PASS", runner="native",
|
|
||||||
fields_matched=15, fields_mismatched=0,
|
|
||||||
branch_rate=0.95, paragraph_rate=1.0, decision_rate=0.9,
|
|
||||||
quality_score=0.85, quality_warn="",
|
|
||||||
hina_type="マッチング", hina_confidence=0.95,
|
|
||||||
heal_retry=1, simple_retry=0, total_retry=1,
|
|
||||||
)
|
|
||||||
g = ReportGenerator()
|
|
||||||
|
|
||||||
# JSON
|
|
||||||
p = g.generate_json(vr, rd / "r.json")
|
|
||||||
d = json.loads(p.read_text())
|
|
||||||
fields = ['program','status','branch_rate','paragraph_rate','decision_rate',
|
|
||||||
'quality_score','quality_warn','hina_type','hina_confidence',
|
|
||||||
'heal_retry','simple_retry','total_retry']
|
|
||||||
missing = [f for f in fields if f not in d]
|
|
||||||
ok(f"JSON全{len(fields)}フィールド含む") if not missing else ng("JSONフィールド不足", str(missing))
|
|
||||||
ok(f"JSON: quality_score={d['quality_score']}") if d['quality_score'] == 0.85 else ng("quality_score", str(d['quality_score']))
|
|
||||||
ok(f"JSON: hina_type={d['hina_type']}") if d['hina_type'] == "マッチング" else ng("hina_type", d['hina_type'])
|
|
||||||
|
|
||||||
# HTML
|
|
||||||
h = g.generate_html(vr, rd / "r.html")
|
|
||||||
html = h.read_text(encoding="utf-8")
|
|
||||||
ok(f"HTML生成: {len(html)}文字") if len(html) > 200 else ng("HTML短すぎ", f"{len(html)}文字")
|
|
||||||
ok(f"HTMLに'DEEP-VALIDATION'含む") if 'DEEP-VALIDATION' in html else ng("HTMLタイトル", "")
|
|
||||||
ok(f"HTMLに'マッチング'含む") if 'マッチング' in html else ng("HTML HINA", "")
|
|
||||||
|
|
||||||
# Machine JSON
|
|
||||||
m = g.generate_machine_json(vr, rd / "m.json")
|
|
||||||
md = json.loads(m.read_text())
|
|
||||||
mfields = ['branch_rate','paragraph_rate','quality_score','hina_type','heal_retry']
|
|
||||||
mmissing = [f for f in mfields if f not in md]
|
|
||||||
ok(f"MachineJSON: {len(mfields)}フィールド") if not mmissing else ng("MachineJSON不足", str(mmissing))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
ng("レポート生成", str(e)[:100])
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(rd)
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 6. cobol_testgen API: 純正バリデーション
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
section("6. cobol_testgen API: 正確性検証")
|
|
||||||
|
|
||||||
# extract_structure: 3種類のIFを正しく数える
|
|
||||||
src_multi = """ IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. T.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 A PIC X. 01 B PIC 9(05).
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
IF A = 'X' THEN
|
|
||||||
IF B > 1000 THEN MOVE 1 TO B ELSE MOVE 2 TO B END-IF
|
|
||||||
ELSE IF A = 'Y' THEN
|
|
||||||
IF B > 500 THEN MOVE 3 TO B END-IF
|
|
||||||
ELSE
|
|
||||||
MOVE 9 TO B.
|
|
||||||
GOBACK."""
|
|
||||||
struct = extract_structure(src_multi)
|
|
||||||
if struct['total_branches'] >= 6:
|
|
||||||
ok(f"多重IF解析: {struct['total_branches']}分岐, {len(struct['decision_points'])}決定点")
|
|
||||||
else:
|
|
||||||
ng("多重IF解析", f"branches={struct['total_branches']} < 6")
|
|
||||||
|
|
||||||
# EVALUATE
|
|
||||||
src_eval = """ IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. T.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 X PIC X.
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
EVALUATE X
|
|
||||||
WHEN 'A' MOVE 1 TO X
|
|
||||||
WHEN 'B' MOVE 2 TO X
|
|
||||||
WHEN OTHER MOVE 9 TO X.
|
|
||||||
GOBACK."""
|
|
||||||
struct2 = extract_structure(src_eval)
|
|
||||||
ok(f"EVALUATE解析: has_evaluate={struct2['has_evaluate']}") if struct2['has_evaluate'] else ng("EVALUATE", "not detected")
|
|
||||||
|
|
||||||
# CALL
|
|
||||||
src_call = """ IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. T.
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
CALL 'SUBPGM' USING A.
|
|
||||||
GOBACK."""
|
|
||||||
struct3 = extract_structure(src_call)
|
|
||||||
ok(f"CALL検出: has_call={struct3['has_call']}") if struct3['has_call'] else ng("CALL", "not detected")
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 7. パフォーマンス: 大規模COBOL解析
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
section("7. パフォーマンス: 大規模COBOL解析")
|
|
||||||
|
|
||||||
lines = [" IDENTIFICATION DIVISION.", " PROGRAM-ID. T.",
|
|
||||||
" DATA DIVISION.", " WORKING-STORAGE SECTION.", " 01 X PIC X.",
|
|
||||||
" PROCEDURE DIVISION."]
|
|
||||||
for i in range(200):
|
|
||||||
lines.append(f" IF X = '{chr(65+i%26)}' THEN MOVE {i} TO X ELSE MOVE {i+1} TO X END-IF.")
|
|
||||||
lines.append(" GOBACK.")
|
|
||||||
big_src = "\n".join(lines)
|
|
||||||
|
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
struct_big = extract_structure(big_src)
|
|
||||||
elapsed = time.time() - t0
|
|
||||||
ok(f"200IF解析: {struct_big['total_branches']}分岐, {elapsed:.2f}s") if struct_big['total_branches'] > 0 and elapsed < 10 else ng(f"巨大プログラム: {elapsed:.1f}s", "")
|
|
||||||
except RecursionError:
|
|
||||||
ng("200IF", "再帰深度超過(cobol_testgenの既知制限)")
|
|
||||||
except Exception as e:
|
|
||||||
ng("200IF", str(e)[:60])
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 8. リグレッション: 既存42テスト
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
section("8. リグレッション: 既存42テスト")
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "pytest", "tests/", "--ignore=tests/e2e/",
|
|
||||||
"--ignore=tests/test_web_e2e.py", "--ignore=tests/test_biz_e2e.py"],
|
|
||||||
capture_output=True, text=True, timeout=60,
|
|
||||||
cwd=ROOT, env={**os.environ, "PYTHONIOENCODING": "utf-8"}
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
passed_count = result.stdout.count("PASSED")
|
|
||||||
ok(f"全42テスト通過 (pytest exit={result.returncode})")
|
|
||||||
else:
|
|
||||||
lines = [l for l in result.stdout.split('\n') if 'FAILED' in l]
|
|
||||||
ng("リグレッション", f"{len(lines)} failures")
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 集計
|
|
||||||
# ──────────────────────────────
|
|
||||||
section("最終結果")
|
|
||||||
[print(l) for l in LOG]
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f" Deep Validation Results")
|
|
||||||
print(f" 総テスト: {TOTAL}")
|
|
||||||
print(f" 合格: {PASS}")
|
|
||||||
print(f" 不合格: {FAIL}")
|
|
||||||
print(f" 合格率: {PASS/max(TOTAL,1)*100:.1f}%")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
sys.exit(0 if FAIL == 0 else 1)
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"""
|
|
||||||
テストギャップ穴埋め — 未検証モジュールの機能テスト
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
対象: hina.hina_agent, jcl.executor, jcl.parser
|
|
||||||
"""
|
|
||||||
import sys, json
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
PASS=0;FAIL=0;LOG=[]
|
|
||||||
def do(cat,name,fn):
|
|
||||||
global PASS,FAIL
|
|
||||||
try: fn(); PASS+=1; LOG.append(f' [{cat}] {name} -> PASS')
|
|
||||||
except Exception as e: FAIL+=1; LOG.append(f' [{cat}] {name} -> FAIL: {str(e)[:100]}')
|
|
||||||
|
|
||||||
# ── hina.hina_agent: LLM応答パース ──
|
|
||||||
from hina.hina_agent import _parse_llm_response, _validate_result, _fallback_classification, CONFUSION_PROMPT
|
|
||||||
|
|
||||||
do('HAG','_parse_llm_response: 生JSON', lambda: (
|
|
||||||
r:=_parse_llm_response('{"category":"condition_heavy","confidence":0.85}'),
|
|
||||||
r['category']=='condition_heavy' and r['confidence']==0.85))
|
|
||||||
do('HAG','_parse_llm_response: ```json ブロック', lambda: (
|
|
||||||
r:=_parse_llm_response('```json\n{"category":"data_file_centric","confidence":0.9}\n```'),
|
|
||||||
r['category']=='data_file_centric' and r['confidence']==0.9))
|
|
||||||
do('HAG','_parse_llm_response: ``` ブロック(無json)', lambda: (
|
|
||||||
r:=_parse_llm_response('```\n{"category":"simple_sequential","confidence":0.7}\n```'),
|
|
||||||
r['category']=='simple_sequential'))
|
|
||||||
do('HAG','_parse_llm_response: 空文字', lambda: (
|
|
||||||
r:=_parse_llm_response(''),
|
|
||||||
r['category']=='unknown'))
|
|
||||||
do('HAG','_parse_llm_response: 無効JSON', lambda: (
|
|
||||||
r:=_parse_llm_response('not json at all'),
|
|
||||||
r['category']=='unknown'))
|
|
||||||
do('HAG','_validate_result: 最小値', lambda: (
|
|
||||||
r:=_validate_result({}),
|
|
||||||
r['category']=='unknown' and r['confidence']==0.0 and r['required_tests']>=1))
|
|
||||||
do('HAG','_validate_result: 信頼度クランプ', lambda: (
|
|
||||||
r:=_validate_result({'confidence':5.0,'required_tests':0}),
|
|
||||||
r['confidence']<=1.0 and r['required_tests']>=1))
|
|
||||||
do('HAG','_validate_result: 信頼度下限', lambda: (
|
|
||||||
r:=_validate_result({'confidence':-1.0}),
|
|
||||||
r['confidence']>=0.0))
|
|
||||||
do('HAG','_validate_result: 不正タイプ', lambda: (
|
|
||||||
r:=_validate_result({'confidence':'abc','required_tests':'xyz'}),
|
|
||||||
r['confidence']==0.0 and r['required_tests']>=1))
|
|
||||||
do('HAG','_fallback_classification: 分岐0', lambda: (
|
|
||||||
r:=_fallback_classification({'decision_points':[],'paragraphs':[],'file_count':0}),
|
|
||||||
r['category']=='simple_sequential'))
|
|
||||||
do('HAG','_fallback_classification: SEARCH ALL', lambda: (
|
|
||||||
r:=_fallback_classification({'decision_points':[{'kind':'IF'}],'paragraphs':[],'file_count':0,'has_search_all':True,'has_call':False,'has_break':False}),
|
|
||||||
r['category']=='search_intensive'))
|
|
||||||
do('HAG','_fallback_classification: CALLベース', lambda: (
|
|
||||||
r:=_fallback_classification({'decision_points':[{'kind':'IF'}],'paragraphs':[],'file_count':0,'has_search_all':False,'has_call':True,'has_break':False}),
|
|
||||||
r['category']=='call_based'))
|
|
||||||
do('HAG','_fallback_classification: mixed_complex', lambda: (
|
|
||||||
r:=_fallback_classification({'decision_points':[{'kind':'IF'}]*5,'paragraphs':[],'file_count':2,'has_search_all':True,'has_call':True,'has_break':True}),
|
|
||||||
r['category']=='mixed_complex'))
|
|
||||||
do('HAG','CONFUSION_PROMPT 書式', lambda: (
|
|
||||||
p:=CONFUSION_PROMPT.format(paragraph_count=3,decision_count=2,if_count=1,
|
|
||||||
evaluate_count=1,file_count=1,open_directions='{}',has_search_all='false',
|
|
||||||
has_call='false',has_break='false',total_branches=2),
|
|
||||||
'paragraph_count' not in p and 'IF' in p))
|
|
||||||
|
|
||||||
# ── jcl.parser: JCL解析 ──
|
|
||||||
from jcl.parser import parse_jcl
|
|
||||||
|
|
||||||
SAMPLE_JCL = """//CREDIT25 JOB (CRD),'MONTHLY BILLING',CLASS=A,MSGCLASS=X
|
|
||||||
//STEP1 EXEC PGM=SORT
|
|
||||||
//SORTIN DD DSN=TRANSACTIONS.DATA,DISP=SHR
|
|
||||||
//SORTOUT DD DSN=SORTED.DATA,DISP=(NEW,PASS)
|
|
||||||
//SYSIN DD *
|
|
||||||
SORT FIELDS=(1,16,CH,A)
|
|
||||||
//STEP2 EXEC PGM=CRDVAL,COND=(0,NE)
|
|
||||||
//TRANSIN DD DSN=SORTED.DATA,DISP=(OLD,DELETE)
|
|
||||||
//MEMBER DD DSN=MEMBER.DATA,DISP=SHR
|
|
||||||
//VALIDOUT DD DSN=VALID.DATA,DISP=(NEW,CATLG)
|
|
||||||
//REJECT DD SYSOUT=*
|
|
||||||
//REPORTERR DD SYSOUT=*
|
|
||||||
//STEP3 EXEC PGM=CRDCALC,COND=(0,NE)
|
|
||||||
//VALIDIN DD DSN=VALID.DATA,DISP=(OLD,DELETE)
|
|
||||||
//RATE DD DSN=RATE.DATA,DISP=SHR
|
|
||||||
//CALCOUT DD DSN=CALC.DATA,DISP=(NEW,CATLG)
|
|
||||||
//STEP4 EXEC PGM=CRDRPT,COND=(0,NE)
|
|
||||||
//BILLING DD DSN=CALC.DATA,DISP=(OLD,DELETE)
|
|
||||||
//STMT DD DSN=STMT.DATA,DISP=(NEW,CATLG)
|
|
||||||
//SUMMARY DD DSN=SUMMARY.DATA,DISP=(NEW,CATLG)
|
|
||||||
// DD SYSOUT=*
|
|
||||||
"""
|
|
||||||
|
|
||||||
do('JCL','parse_jcl 4STEP解析', lambda: (
|
|
||||||
j:=parse_jcl(SAMPLE_JCL),
|
|
||||||
len(j.steps)==4))
|
|
||||||
do('JCL','JOB情報解析', lambda: (
|
|
||||||
j:=parse_jcl(SAMPLE_JCL),
|
|
||||||
j.job_name=='CREDIT25' and j.job_class=='A'))
|
|
||||||
do('JCL','STEP1:SORT PGM定義', lambda: (
|
|
||||||
j:=parse_jcl(SAMPLE_JCL),
|
|
||||||
j.steps[0].program=='SORT' and j.steps[0].step_name=='STEP1'))
|
|
||||||
do('JCL','DD定義:入力ファイル', lambda: (
|
|
||||||
j:=parse_jcl(SAMPLE_JCL),
|
|
||||||
any('TRANSACTIONS' in d.dsn for d in j.steps[0].dd_list)))
|
|
||||||
do('JCL','DD定義:出力ファイル', lambda: (
|
|
||||||
j:=parse_jcl(SAMPLE_JCL),
|
|
||||||
any('VALID.DATA' in d.dsn for d in j.steps[1].dd_list)))
|
|
||||||
do('JCL','CONDパラメータ', lambda: (
|
|
||||||
j:=parse_jcl(SAMPLE_JCL),
|
|
||||||
j.steps[1].cond is not None and '0' in str(j.steps[1].cond)))
|
|
||||||
do('JCL','SYSINインラインデータ', lambda: (
|
|
||||||
j:=parse_jcl(SAMPLE_JCL),
|
|
||||||
len(j.steps[0].sysin_lines)>0 and 'SORT' in j.steps[0].sysin_lines[0]))
|
|
||||||
do('JCL','SYSOUT出力', lambda: (
|
|
||||||
j:=parse_jcl(SAMPLE_JCL),
|
|
||||||
any('*' in d.dsn for d in j.steps[1].dd_list)))
|
|
||||||
do('JCL','空JCL', lambda: (
|
|
||||||
j:=parse_jcl(''),
|
|
||||||
len(j.steps)==0))
|
|
||||||
do('JCL','コメント行スキップ', lambda: (
|
|
||||||
j:=parse_jcl('//* THIS IS COMMENT\n//STEP1 EXEC PGM=TEST\n'),
|
|
||||||
len(j.steps)==1 and j.steps[0].program=='TEST'))
|
|
||||||
|
|
||||||
# ── jcl.executor ──
|
|
||||||
from jcl.executor import JclExecutor, CondEvaluator
|
|
||||||
|
|
||||||
do('JEX','CondEvaluator: (0,NE)', lambda: (
|
|
||||||
CondEvaluator().evaluate('(0,NE)', 0)==False))
|
|
||||||
do('JEX','CondEvaluator: (0,NE) RC=4', lambda: (
|
|
||||||
CondEvaluator().evaluate('(0,NE)', 4)==True))
|
|
||||||
do('JEX','CondEvaluator: (0,GT) RC=0', lambda: (
|
|
||||||
CondEvaluator().evaluate('(0,GT)', 0)==False))
|
|
||||||
do('JEX','CondEvaluator: (0,GT) RC=4', lambda: (
|
|
||||||
CondEvaluator().evaluate('(0,GT)', 4)==True))
|
|
||||||
do('JEX','CondEvaluator: (4,LE) RC=4', lambda: (
|
|
||||||
CondEvaluator().evaluate('(4,LE)', 4)==True))
|
|
||||||
do('JEX','CondEvaluator: (4,LE) RC=8', lambda: (
|
|
||||||
CondEvaluator().evaluate('(4,LE)', 8)==False))
|
|
||||||
do('JEX','CondEvaluator: EVEN', lambda: (
|
|
||||||
CondEvaluator().evaluate('EVEN', 0)==True))
|
|
||||||
do('JEX','CondEvaluator: ONLY', lambda: (
|
|
||||||
CondEvaluator().evaluate('ONLY', 0)==True))
|
|
||||||
do('JEX','CondEvaluator: 空文字列', lambda: (
|
|
||||||
CondEvaluator().evaluate('', 0)==None))
|
|
||||||
do('JEX','JclExecutor インスタンス', lambda: (
|
|
||||||
e:=JclExecutor(),
|
|
||||||
hasattr(e,'execute_step')))
|
|
||||||
do('JEX','DD→環境変数マッピング', lambda: (
|
|
||||||
e:=JclExecutor(),
|
|
||||||
m:=e._build_env({'TRANSIN':'/data/in.dat','VALIDOUT':'/data/out.dat'}),
|
|
||||||
'TRANSIN' in m and 'VALIDOUT' in m))
|
|
||||||
|
|
||||||
# ── quality モジュール ──
|
|
||||||
from quality.l1_offset_validate import L1OffsetValidator
|
|
||||||
from quality.l2_value_roundtrip import L2RoundtripValidator
|
|
||||||
|
|
||||||
do('QLT','L1OffsetValidator インスタンス', lambda: (
|
|
||||||
v:=L1OffsetValidator(),
|
|
||||||
hasattr(v,'validate')))
|
|
||||||
do('QLT','L2RoundtripValidator インスタンス', lambda: (
|
|
||||||
v:=L2RoundtripValidator(),
|
|
||||||
hasattr(v,'validate')))
|
|
||||||
|
|
||||||
# ── HINA gate: エッジケース ──
|
|
||||||
from hina.gate import check as gate_check, _compute_score
|
|
||||||
|
|
||||||
do('QG','スコア上限=1.0', lambda: _compute_score({'branch_rate':1.0,'paragraph_rate':1.0},{})<=1.0)
|
|
||||||
do('QG','スコア下限=0.4', lambda: _compute_score({'branch_rate':0.0,'paragraph_rate':0.0},{})>=0.4)
|
|
||||||
do('QG','境界:分岐率0.8999→不合格', lambda: (
|
|
||||||
r:=gate_check([{'x':1}],{},{'branch_rate':0.8999,'paragraph_rate':1.0,'uncovered_decision_ids':[]}),
|
|
||||||
not r['passed']))
|
|
||||||
do('QG','境界:分岐率0.9→合格', lambda: (
|
|
||||||
r:=gate_check([{'x':1}],{},{'branch_rate':0.9,'paragraph_rate':1.0,'uncovered_decision_ids':[]}),
|
|
||||||
r['passed']))
|
|
||||||
do('QG','issue:段落不足のみ', lambda: (
|
|
||||||
r:=gate_check([{'x':1}],{},{'branch_rate':1.0,'paragraph_rate':0.5,'uncovered_decision_ids':[]}),
|
|
||||||
not r['passed'] and 'paragraph_gaps' in r['issues']))
|
|
||||||
|
|
||||||
# ── 集計 ──
|
|
||||||
print(); [print(l) for l in LOG]
|
|
||||||
total=PASS+FAIL
|
|
||||||
print(f'\n{"="*67}')
|
|
||||||
print(f' Gap Coverage Test Results')
|
|
||||||
print(f' Total: {total} | PASS: {PASS} | FAIL: {FAIL} | RATE: {PASS/max(total,1)*100:.1f}%')
|
|
||||||
print(f' Untested modules covered: hina.hina_agent ✅ jcl.parser ✅ jcl.executor ✅')
|
|
||||||
print(f'{"="*67}')
|
|
||||||
sys.exit(0 if FAIL==0 else 1)
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"""
|
|
||||||
Master Validation — 增强测试系统 综合验证
|
|
||||||
验证内容: Pipeline / HINA全分类 / 测试基准 / QG / Retry / Report
|
|
||||||
実行: python -X utf8 test-data/test_master_validation.py
|
|
||||||
"""
|
|
||||||
import sys, json, tempfile, shutil
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
from data.diff_result import VerificationRun
|
|
||||||
from data.test_case import TestCase
|
|
||||||
from hina.classifier import compute_confidence
|
|
||||||
from hina.gate import check as gate_check, _compute_score
|
|
||||||
from hina.retry import RetryHandler
|
|
||||||
from report.generator import ReportGenerator
|
|
||||||
from cobol_testgen import extract_structure, generate_data
|
|
||||||
|
|
||||||
PASS, FAIL = 0, 0; LOG = []
|
|
||||||
def do(cat, name, fn):
|
|
||||||
global PASS, FAIL
|
|
||||||
try:
|
|
||||||
fn(); PASS += 1; LOG.append(f' [{cat}] {name} -> PASS')
|
|
||||||
except Exception as e:
|
|
||||||
FAIL += 1; LOG.append(f' [{cat}] {name} -> FAIL: {str(e)[:100]}')
|
|
||||||
|
|
||||||
def S():
|
|
||||||
return '\n'.join([
|
|
||||||
' IDENTIFICATION DIVISION.',
|
|
||||||
' PROGRAM-ID. T.',
|
|
||||||
' DATA DIVISION.',
|
|
||||||
' WORKING-STORAGE SECTION.',
|
|
||||||
' 01 X PIC X.',
|
|
||||||
' PROCEDURE DIVISION.',
|
|
||||||
' IF A>B MOVE 1 TO C ELSE MOVE 2 TO C.',
|
|
||||||
' GOBACK.'])
|
|
||||||
|
|
||||||
# ── Pipeline ──
|
|
||||||
do('PIPE','extract->generate', lambda: (
|
|
||||||
st:=extract_structure(S()), st['total_branches']>=2))
|
|
||||||
do('PIPE','HINA+QG', lambda: gate_check([{'x':1}],{},
|
|
||||||
{'branch_rate':1.0,'paragraph_rate':1.0,'uncovered_decision_ids':[]})['passed'])
|
|
||||||
do('PIPE','extract+HINA+QG', lambda: (
|
|
||||||
st:=extract_structure(S()), h:=compute_confidence(S(),st),
|
|
||||||
qg:=gate_check([TestCase(id='x',fields={'a':1})],h,
|
|
||||||
{'branch_rate':1.0,'paragraph_rate':1.0,'uncovered_decision_ids':[]}), True))
|
|
||||||
do('PIPE','report JSON HINA', lambda: (
|
|
||||||
rd:=Path(tempfile.mkdtemp()),
|
|
||||||
ReportGenerator().generate_json(VerificationRun(program='T',hina_type='DB'),rd/'r.json'),
|
|
||||||
d:=json.loads((rd/'r.json').read_text()), shutil.rmtree(rd), d['hina_type']=='DB'))
|
|
||||||
|
|
||||||
# ── HINA L1 ──
|
|
||||||
for kw, cat, conf in [
|
|
||||||
('EXEC SQL','DB操作',0.95), ('CALL\nLINKAGE','子程序调用',0.90),
|
|
||||||
('SORT ON KEY','SORT',0.95), ('MERGE ON KEY','MERGE',0.95),
|
|
||||||
('DFHCOMMAREA','online',0.95), ('SYSIN','SYSIN',0.90),
|
|
||||||
('ORGANIZATION IS','文件编成',0.99), ('ALTERNATE RECORD KEY','替代索引',0.99),
|
|
||||||
('WRITE AFTER','编辑输出',0.80)]:
|
|
||||||
do('L1', cat, lambda k=kw,c=cat,cf=conf: (
|
|
||||||
h:=compute_confidence(k,{}), h['category']==c and h['confidence']>=cf))
|
|
||||||
|
|
||||||
# ── 実プログラム ──
|
|
||||||
for fn in ['HINA001','HINA025','HINA101','HINA005','HINA007']:
|
|
||||||
do('REAL', fn, lambda f=fn: (
|
|
||||||
src:=open(f'test-data/cobol/{f}.cbl',encoding='utf-8').read(),
|
|
||||||
st:=extract_structure(src), st is not None))
|
|
||||||
|
|
||||||
# ── Benchmark ──
|
|
||||||
do('BM','COM-N001', lambda: generate_data('PROCEDURE DIVISION.GOBACK.')!=None)
|
|
||||||
do('BM','MT-N001', lambda: (
|
|
||||||
s:=open('test-data/cobol/HINA001.cbl',encoding='utf-8').read(),
|
|
||||||
extract_structure(s)['file_count']>=3))
|
|
||||||
do('BM','B-N001', lambda: extract_structure(S())['total_branches']>=2)
|
|
||||||
|
|
||||||
# ── Quality Gate ──
|
|
||||||
do('QG','pass', lambda: gate_check([{'x':1}],{},
|
|
||||||
{'branch_rate':1.0,'paragraph_rate':1.0,'uncovered_decision_ids':[]})['passed'])
|
|
||||||
do('QG','fail', lambda: not gate_check([],{},
|
|
||||||
{'branch_rate':0.0,'paragraph_rate':0.0,'uncovered_decision_ids':[1]})['passed'])
|
|
||||||
do('QG','score', lambda: abs(_compute_score(
|
|
||||||
{'branch_rate':0.92,'paragraph_rate':1.0},{})-0.976)<0.01)
|
|
||||||
|
|
||||||
# ── Retry ──
|
|
||||||
do('RETRY','immediate', lambda: RetryHandler().run(
|
|
||||||
lambda: VerificationRun(status='PASS')).status=='PASS')
|
|
||||||
do('RETRY','fatal', lambda: RetryHandler(1,1).run(
|
|
||||||
lambda: VerificationRun(status='ERROR')).status=='FATAL')
|
|
||||||
do('RETRY','heal', lambda: (
|
|
||||||
c:=[0], h:=RetryHandler(3,1),
|
|
||||||
v:=h.run(lambda: (c.__setitem__(0,c[0]+1),
|
|
||||||
VerificationRun(status='BLOCKED',debug={'cobol_build':{'log':'not found'}}))[1]
|
|
||||||
if c[0]<=2 else VerificationRun(status='PASS')),
|
|
||||||
v.status=='PASS' and v.heal_retry>0))
|
|
||||||
|
|
||||||
# ── Report ──
|
|
||||||
do('RPT','JSON-quality', lambda: (
|
|
||||||
rd:=Path(tempfile.mkdtemp()),
|
|
||||||
ReportGenerator().generate_json(VerificationRun(program='T',quality_score=0.85),rd/'r.json'),
|
|
||||||
d:=json.loads((rd/'r.json').read_text()),shutil.rmtree(rd),d['quality_score']==0.85))
|
|
||||||
do('RPT','JSON-retry', lambda: (
|
|
||||||
rd:=Path(tempfile.mkdtemp()),
|
|
||||||
ReportGenerator().generate_json(VerificationRun(program='T',heal_retry=2),rd/'r.json'),
|
|
||||||
d:=json.loads((rd/'r.json').read_text()),shutil.rmtree(rd),d['heal_retry']==2))
|
|
||||||
do('RPT','machine-JSON', lambda: (
|
|
||||||
rd:=Path(tempfile.mkdtemp()),
|
|
||||||
ReportGenerator().generate_machine_json(VerificationRun(program='T',branch_rate=0.9),rd/'m.json'),
|
|
||||||
d:=json.loads((rd/'m.json').read_text()),shutil.rmtree(rd),d['branch_rate']==0.9))
|
|
||||||
|
|
||||||
# ── Summary ──
|
|
||||||
print(); [print(l) for l in LOG]
|
|
||||||
total = PASS+FAIL; rate = PASS/max(total,1)*100
|
|
||||||
print(f'\n═ Total: {total} | PASS: {PASS} | FAIL: {FAIL} | RATE: {rate:.1f}% ═')
|
|
||||||
sys.exit(0 if FAIL==0 else 1)
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
"""
|
|
||||||
cobol-java-v3 平台用户故事测试
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
测试对象: cobol-java-v3 平台自身(不是COBOL程序)
|
|
||||||
测试范围: 正常 / 异常 / 边界 / 缺陷 4类用户故事
|
|
||||||
|
|
||||||
执行: python -X utf8 test-data/test_platform_user_stories.py
|
|
||||||
"""
|
|
||||||
import sys, os, json, time, tempfile, shutil, traceback
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from data.diff_result import VerificationRun, FieldResult
|
|
||||||
from data.test_case import TestCase, TestSuite, SparkConfig
|
|
||||||
from data.field_tree import FieldTree
|
|
||||||
|
|
||||||
PASS = 0
|
|
||||||
FAIL = 0
|
|
||||||
ERRORS = []
|
|
||||||
|
|
||||||
def section(title):
|
|
||||||
print(f"\n{'─'*70}")
|
|
||||||
print(f" {title}")
|
|
||||||
print(f"{'─'*70}")
|
|
||||||
|
|
||||||
def test(name, category):
|
|
||||||
def decorator(fn):
|
|
||||||
global PASS, FAIL
|
|
||||||
try:
|
|
||||||
fn()
|
|
||||||
PASS += 1
|
|
||||||
print(f" [{category}] {name} → ✅ PASS")
|
|
||||||
except Exception as e:
|
|
||||||
FAIL += 1
|
|
||||||
tb = traceback.format_exc()[-300:]
|
|
||||||
ERRORS.append(f"{name}: {e}")
|
|
||||||
print(f" [{category}] {name} → ❌ FAIL: {e}")
|
|
||||||
print(f" {tb.split(chr(10))[-3]}")
|
|
||||||
return fn
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
# 正常系 — Normal
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
|
|
||||||
section("N: 正常系ユーザーストーリー")
|
|
||||||
|
|
||||||
@test("VerificationRun 作成と全フィールド設定", "NORMAL")
|
|
||||||
def _():
|
|
||||||
vr = VerificationRun(program="TESTPGM", runner="native")
|
|
||||||
assert vr.program == "TESTPGM"
|
|
||||||
assert vr.runner == "native"
|
|
||||||
assert vr.timestamp != ""
|
|
||||||
vr.branch_rate = 0.95
|
|
||||||
vr.paragraph_rate = 1.0
|
|
||||||
vr.hina_type = "マッチング"
|
|
||||||
vr.quality_score = 0.85
|
|
||||||
vr.heal_retry = 1
|
|
||||||
assert vr.branch_rate == 0.95
|
|
||||||
assert vr.hina_type == "マッチング"
|
|
||||||
|
|
||||||
@test("TestCase 作成とフィールド設定", "NORMAL")
|
|
||||||
def _():
|
|
||||||
tc = TestCase(id="TC-001", fields={"BR-AMT": 1500, "BR-STATUS": "A"})
|
|
||||||
assert tc.id == "TC-001"
|
|
||||||
assert tc.fields["BR-AMT"] == 1500
|
|
||||||
assert tc.fields["BR-STATUS"] == "A"
|
|
||||||
assert tc.coverage_targets == []
|
|
||||||
|
|
||||||
@test("FieldResult 作成とステータス", "NORMAL")
|
|
||||||
def _():
|
|
||||||
fr = FieldResult(field_name="BR-AMT", status="PASS", cobol_value="1500", java_value="1500.00")
|
|
||||||
assert fr.field_name == "BR-AMT"
|
|
||||||
assert fr.status == "PASS"
|
|
||||||
fr.status = "MISMATCH"
|
|
||||||
assert fr.status == "MISMATCH"
|
|
||||||
|
|
||||||
@test("Config デフォルト値", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from config import Config
|
|
||||||
c = Config()
|
|
||||||
assert c.quality_gate_mode == "warn"
|
|
||||||
assert c.runner_mode == "native"
|
|
||||||
assert c.dialect == "ibm"
|
|
||||||
assert c.gcov_enabled == False
|
|
||||||
assert c.max_quality_retries == 4
|
|
||||||
|
|
||||||
@test("Config from_toml 正常", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from config import Config
|
|
||||||
c = Config.from_toml(path=Path(__file__).parent.parent / "aurak.toml")
|
|
||||||
assert c.project_name != "" or c.runner_mode != ""
|
|
||||||
|
|
||||||
@test("VerificationRun total_fields 計算", "NORMAL")
|
|
||||||
def _():
|
|
||||||
vr = VerificationRun(fields_matched=10, fields_mismatched=2)
|
|
||||||
assert vr.total_fields == 12
|
|
||||||
|
|
||||||
@test("HINA classifier L1: DB操作", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.classifier import detect_keyword
|
|
||||||
r = detect_keyword("EXEC SQL SELECT * FROM TABLE END-EXEC")
|
|
||||||
assert any("DB操作" in x[0] for x in r)
|
|
||||||
assert any(x[1] >= 0.95 for x in r)
|
|
||||||
|
|
||||||
@test("HINA classifier L1: CALL", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.classifier import detect_keyword
|
|
||||||
r = detect_keyword("CALL 'SUBPGM' USING A.\nLINKAGE SECTION.")
|
|
||||||
assert any("子程序调用" in x[0] for x in r)
|
|
||||||
|
|
||||||
@test("HINA strategy マッチングテンプレート", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.strategy import get_strategy
|
|
||||||
s = get_strategy("マッチング")
|
|
||||||
assert len(s["required"]) == 9
|
|
||||||
|
|
||||||
@test("Quality gate: 合格", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.gate import check
|
|
||||||
r = check([{"a": 1}], {}, {"branch_rate": 0.95, "paragraph_rate": 1.0, "uncovered_decision_ids": []})
|
|
||||||
assert r["passed"] == True
|
|
||||||
|
|
||||||
@test("RetryHandler: 即PASS", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.retry import RetryHandler
|
|
||||||
h = RetryHandler()
|
|
||||||
vr = h.run(lambda: VerificationRun(status="PASS"))
|
|
||||||
assert vr.status == "PASS"
|
|
||||||
assert vr.heal_retry == 0
|
|
||||||
|
|
||||||
@test("ReportGenerator: HTML生成", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from report.generator import ReportGenerator
|
|
||||||
vr = VerificationRun(program="TEST", runner="native")
|
|
||||||
rd = Path(tempfile.mkdtemp())
|
|
||||||
try:
|
|
||||||
g = ReportGenerator()
|
|
||||||
p = g.generate_html(vr, rd / "test.html")
|
|
||||||
assert p.exists()
|
|
||||||
html = p.read_text(encoding="utf-8")
|
|
||||||
assert "TEST" in html
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(rd)
|
|
||||||
|
|
||||||
@test("ReportGenerator: HTML カバレッジ表示", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from report.generator import ReportGenerator
|
|
||||||
vr = VerificationRun(program="T1", paragraph_rate=0.9, branch_rate=0.85)
|
|
||||||
rd = Path(tempfile.mkdtemp())
|
|
||||||
try:
|
|
||||||
p = ReportGenerator().generate_html(vr, rd / "t.html")
|
|
||||||
html = p.read_text(encoding="utf-8")
|
|
||||||
assert "段落覆盖率" in html
|
|
||||||
assert "分支覆盖率" in html
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(rd)
|
|
||||||
|
|
||||||
@test("ReportGenerator: HTML HINA表示", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from report.generator import ReportGenerator
|
|
||||||
vr = VerificationRun(program="T2", hina_type="マッチング", hina_confidence=0.95)
|
|
||||||
rd = Path(tempfile.mkdtemp())
|
|
||||||
try:
|
|
||||||
p = ReportGenerator().generate_html(vr, rd / "t.html")
|
|
||||||
assert "HINA" in p.read_text(encoding="utf-8")
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(rd)
|
|
||||||
|
|
||||||
@test("ReportGenerator: JSON 新フィールド", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from report.generator import ReportGenerator
|
|
||||||
vr = VerificationRun(program="T3", branch_rate=0.9, quality_score=0.85)
|
|
||||||
rd = Path(tempfile.mkdtemp())
|
|
||||||
try:
|
|
||||||
p = ReportGenerator().generate_json(vr, rd / "t.json")
|
|
||||||
d = json.loads(p.read_text())
|
|
||||||
assert d["branch_rate"] == 0.9
|
|
||||||
assert d["quality_score"] == 0.85
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(rd)
|
|
||||||
|
|
||||||
@test("cobol_testgen extract_structure: IF", "NORMAL")
|
|
||||||
def _():
|
|
||||||
from cobol_testgen import extract_structure
|
|
||||||
s = extract_structure("PROCEDURE DIVISION.\nIF A>B MOVE 1 TO C ELSE MOVE 2 TO C.\nGOBACK.")
|
|
||||||
assert "paragraphs" in s
|
|
||||||
assert "decision_points" in s
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
# 異常系 — Abnormal
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
|
|
||||||
section("A: 異常系ユーザーストーリー")
|
|
||||||
|
|
||||||
@test("空COBOLソース→extract_structure", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from cobol_testgen import extract_structure
|
|
||||||
s = extract_structure("")
|
|
||||||
assert s is not None
|
|
||||||
assert s.get("total_branches", 0) == 0
|
|
||||||
|
|
||||||
@test("PROCEDURE DIVISIONなし→extract_structure", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from cobol_testgen import extract_structure
|
|
||||||
s = extract_structure("IDENTIFICATION DIVISION.\nPROGRAM-ID. X.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 A PIC X(10).")
|
|
||||||
assert s is not None
|
|
||||||
assert "paragraphs" in s
|
|
||||||
|
|
||||||
@test("Quality gate: 空データ", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.gate import check
|
|
||||||
r = check([], {}, {"branch_rate": 0.0, "paragraph_rate": 0.0, "uncovered_decision_ids": []})
|
|
||||||
assert r["passed"] == False
|
|
||||||
assert "no_data" in r.get("issues", {})
|
|
||||||
|
|
||||||
@test("Quality gate: 分岐不足", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.gate import check
|
|
||||||
r = check([{"x": 1}], {}, {"branch_rate": 0.5, "paragraph_rate": 1.0, "uncovered_decision_ids": [1, 2]})
|
|
||||||
assert r["passed"] == False
|
|
||||||
assert "decision_gaps" in r.get("issues", {})
|
|
||||||
|
|
||||||
@test("RetryHandler: 全FAIL→FATAL", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.retry import RetryHandler
|
|
||||||
from data.diff_result import VerificationRun
|
|
||||||
h = RetryHandler(max_heal=1, max_simple=1)
|
|
||||||
vr = h.run(lambda: VerificationRun(status="ERROR", exit_code=3))
|
|
||||||
assert vr.status == "FATAL"
|
|
||||||
assert vr.exit_code == 4
|
|
||||||
|
|
||||||
@test("Config: 必須fieldなし", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from config import Config
|
|
||||||
c = Config.from_toml(path="nonexistent.toml")
|
|
||||||
assert c.runner_mode == "native"
|
|
||||||
assert c.quality_gate_mode == "warn"
|
|
||||||
|
|
||||||
@test("extract_structure: 不正COBOL構文", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from cobol_testgen import extract_structure
|
|
||||||
s = extract_structure("THIS IS NOT VALID COBOL @@@ @@@")
|
|
||||||
assert s is not None
|
|
||||||
|
|
||||||
@test("generate_data: 分岐なしプログラム", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from cobol_testgen import generate_data
|
|
||||||
s = "PROCEDURE DIVISION.\nGOBACK."
|
|
||||||
r = generate_data(s)
|
|
||||||
assert isinstance(r, list)
|
|
||||||
assert len(r) == 0
|
|
||||||
|
|
||||||
@test("incremental_supplement: 存在しないID", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from cobol_testgen import incremental_supplement
|
|
||||||
r = incremental_supplement(None, [-1])
|
|
||||||
assert isinstance(r, list)
|
|
||||||
|
|
||||||
@test("VerificationRun: 空フィールド", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
vr = VerificationRun()
|
|
||||||
assert vr.total_fields == 0
|
|
||||||
assert vr.status == "PASS"
|
|
||||||
|
|
||||||
@test("HINA classifier: キーワードなし", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.classifier import compute_confidence
|
|
||||||
r = compute_confidence("PROCEDURE DIVISION.\nDISPLAY 'HELLO'.")
|
|
||||||
assert r["category"] == "unknown"
|
|
||||||
assert r["confidence"] == 0.0
|
|
||||||
|
|
||||||
@test("HINA strategy: 未知のタイプ", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.strategy import get_strategy
|
|
||||||
s = get_strategy("UNKNOWN_TYPE_XXX")
|
|
||||||
assert s["required"] == []
|
|
||||||
|
|
||||||
@test("gcov_collector: ファイルなし", "ABNORMAL")
|
|
||||||
def _():
|
|
||||||
from hina.gcov_collector import collect_gcov
|
|
||||||
r = collect_gcov(Path("nonexistent.cbl"), Path("/dev/null"))
|
|
||||||
assert r["available"] == False
|
|
||||||
assert "reason" in r
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
# 境界系 — Boundary
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
|
|
||||||
section("B: 境界系ユーザーストーリー")
|
|
||||||
|
|
||||||
@test("超巨大プログラム: 1000個IF", "BOUNDARY")
|
|
||||||
def _():
|
|
||||||
from cobol_testgen import extract_structure
|
|
||||||
lines = ["PROCEDURE DIVISION."]
|
|
||||||
for i in range(1000):
|
|
||||||
lines.append(f"IF A > {i} THEN MOVE {i} TO X ELSE MOVE {i} TO Y END-IF.")
|
|
||||||
lines.append("GOBACK.")
|
|
||||||
src = "\n".join(lines)
|
|
||||||
t0 = time.time()
|
|
||||||
s = extract_structure(src)
|
|
||||||
elapsed = time.time() - t0
|
|
||||||
print(f" → 1000 IF: {elapsed:.1f}s, 安定")
|
|
||||||
assert s is not None
|
|
||||||
assert elapsed < 10 # 10秒以内に完了
|
|
||||||
|
|
||||||
@test("超長フィールド名: 1000文字", "BOUNDARY")
|
|
||||||
def _():
|
|
||||||
from cobol_testgen import extract_structure
|
|
||||||
long = "A" * 1000
|
|
||||||
src = f"""IDENTIFICATION DIVISION.
|
|
||||||
PROGRAM-ID. X.
|
|
||||||
DATA DIVISION.
|
|
||||||
WORKING-STORAGE SECTION.
|
|
||||||
01 {long} PIC X(10).
|
|
||||||
PROCEDURE DIVISION.
|
|
||||||
GOBACK."""
|
|
||||||
s = extract_structure(src)
|
|
||||||
assert s is not None
|
|
||||||
|
|
||||||
@test("TestSuite 0件", "BOUNDARY")
|
|
||||||
def _():
|
|
||||||
ts = TestSuite()
|
|
||||||
assert ts.has_spark == False
|
|
||||||
assert len(ts.test_cases) == 0
|
|
||||||
|
|
||||||
@test("SparkConfig 大量レコード", "BOUNDARY")
|
|
||||||
def _():
|
|
||||||
from data.test_case import SparkConfig
|
|
||||||
sc = SparkConfig(num_records=100000)
|
|
||||||
assert sc.num_records == 100000
|
|
||||||
|
|
||||||
@test("VerificationRun 全フィールド最大値", "BOUNDARY")
|
|
||||||
def _():
|
|
||||||
vr = VerificationRun(fields_matched=9999, fields_mismatched=9999)
|
|
||||||
assert vr.total_fields == 19998
|
|
||||||
vr.branch_rate = 1.0
|
|
||||||
vr.quality_score = 1.0
|
|
||||||
assert vr.branch_rate == 1.0
|
|
||||||
|
|
||||||
@test("100並列TestCases作成", "BOUNDARY")
|
|
||||||
def _():
|
|
||||||
cases = [TestCase(id=f"TC-{i:04d}", fields={"X": i}) for i in range(100)]
|
|
||||||
assert len(cases) == 100
|
|
||||||
assert cases[0].id == "TC-0000"
|
|
||||||
assert cases[99].id == "TC-0099"
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
# 欠陥系 — Defect (過去修正したバグの回帰)
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
|
|
||||||
section("D: 欠陥系ユーザーストーリー (回帰テスト)")
|
|
||||||
|
|
||||||
@test("DEFECT-001:complete_tests→DataWriter", "DEFECT")
|
|
||||||
def _():
|
|
||||||
"""P1修复: complete_tests 必须传递给 DataWriter"""
|
|
||||||
from data.test_case import TestCase
|
|
||||||
tc = TestCase(id="CTG-0001", fields={"TX-AMT": 100})
|
|
||||||
assert tc.id == "CTG-0001"
|
|
||||||
assert tc.fields["TX-AMT"] == 100
|
|
||||||
# DataWriter 接受 TestCase[]
|
|
||||||
from data.test_case import TestSuite
|
|
||||||
ts = TestSuite(test_cases=[tc])
|
|
||||||
assert len(ts.test_cases) == 1
|
|
||||||
|
|
||||||
@test("DEFECT-002:质量门禁循环中同步更新", "DEFECT")
|
|
||||||
def _():
|
|
||||||
"""P2修复: 增量补充后complete_tests需要更新"""
|
|
||||||
from data.test_case import TestCase
|
|
||||||
base = [TestCase(id=f"B{i}", fields={"v": i}) for i in range(3)]
|
|
||||||
delta = [TestCase(id=f"D{i}", fields={"v": i+10}) for i in range(2)]
|
|
||||||
combined = base + delta
|
|
||||||
assert len(combined) == 5
|
|
||||||
assert combined[3].id == "D0"
|
|
||||||
|
|
||||||
@test("DEFECT-003:分层重试 heal恢复", "DEFECT")
|
|
||||||
def _():
|
|
||||||
"""分层重试: heal修复后应成功"""
|
|
||||||
from hina.retry import RetryHandler
|
|
||||||
from data.diff_result import VerificationRun
|
|
||||||
called = [0]
|
|
||||||
def fn():
|
|
||||||
called[0] += 1
|
|
||||||
if called[0] <= 2:
|
|
||||||
return VerificationRun(status="BLOCKED", exit_code=2,
|
|
||||||
debug={"cobol_build": {"log": "not found"}})
|
|
||||||
return VerificationRun(status="PASS")
|
|
||||||
h = RetryHandler(max_heal=3, max_simple=1)
|
|
||||||
vr = h.run(fn)
|
|
||||||
assert vr.status == "PASS"
|
|
||||||
assert vr.heal_retry > 0
|
|
||||||
|
|
||||||
@test("DEFECT-004:COPYBOOKファイル名不一致", "DEFECT")
|
|
||||||
def _():
|
|
||||||
"""修复: COPY BBBBBFC (5B+FC) の解決"""
|
|
||||||
from cobol_testgen.read import resolve_copybooks
|
|
||||||
src = " COPY BBBBBFC REPLACING ==(A)== BY ==R01==."
|
|
||||||
# copybookファイルがなくてもクラッシュしない
|
|
||||||
result = resolve_copybooks(src, "/nonexistent")
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
@test("DEFECT-005:Lark VALUE句解析", "DEFECT")
|
|
||||||
def _():
|
|
||||||
"""修复: VALUE '文字' のLark解析"""
|
|
||||||
from cobol_testgen import extract_structure
|
|
||||||
src = "IDENTIFICATION DIVISION.\nPROGRAM-ID. X.\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 A PIC X(10) VALUE 'TEST'.\nPROCEDURE DIVISION.\nGOBACK."
|
|
||||||
s = extract_structure(src)
|
|
||||||
assert s is not None
|
|
||||||
|
|
||||||
@test("DEFECT-006:OPEN方向OUTPUT誤認識", "DEFECT")
|
|
||||||
def _():
|
|
||||||
"""修复: OPEN方向キーワードがファイル名に含まれない"""
|
|
||||||
from cobol_testgen.read import scan_open_statements
|
|
||||||
src = "OPEN INPUT TRANS-FILE.\nOPEN OUTPUT OUTPUT-FILE."
|
|
||||||
dirs = scan_open_statements(src)
|
|
||||||
# 'OUTPUT'は方向キーワードとして除外され、ファイル名にはならない
|
|
||||||
assert 'OUTPUT' not in dirs # キーワードはフィルタされる
|
|
||||||
assert 'OUTPUT-FILE' in dirs
|
|
||||||
assert dirs['OUTPUT-FILE'] == 'OUTPUT'
|
|
||||||
|
|
||||||
@test("DEFECT-007:Enum値一致判定", "DEFECT")
|
|
||||||
def _():
|
|
||||||
"""HINA分類のmethodキー存在確認"""
|
|
||||||
from hina.classifier import compute_confidence
|
|
||||||
r = compute_confidence("EXEC SQL SELECT\nEND-EXEC.")
|
|
||||||
assert "method" in r
|
|
||||||
assert r["method"] == "keyword"
|
|
||||||
r2 = compute_confidence("DISPLAY 'X'.")
|
|
||||||
assert r2["method"] == "none"
|
|
||||||
|
|
||||||
@test("DEFECT-008:machine_json全フィールド", "DEFECT")
|
|
||||||
def _():
|
|
||||||
"""P5修复: machine_jsonに全フィールド含む"""
|
|
||||||
from report.generator import ReportGenerator
|
|
||||||
vr = VerificationRun(program="TEST", branch_rate=0.9, paragraph_rate=0.8,
|
|
||||||
quality_score=0.85, hina_type="M", hina_confidence=0.95)
|
|
||||||
rd = Path(tempfile.mkdtemp())
|
|
||||||
try:
|
|
||||||
p = ReportGenerator().generate_machine_json(vr, rd / "m.json")
|
|
||||||
d = json.loads(p.read_text())
|
|
||||||
assert "branch_rate" in d
|
|
||||||
assert "paragraph_rate" in d
|
|
||||||
assert "quality_score" in d
|
|
||||||
assert "hina_type" in d
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(rd)
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
# 集計
|
|
||||||
# ════════════════════════════════════════════
|
|
||||||
|
|
||||||
section("テスト結果集計")
|
|
||||||
total = PASS + FAIL
|
|
||||||
print(f"\n 総テスト数: {total}")
|
|
||||||
print(f" 合格: {PASS}")
|
|
||||||
print(f" 不合格: {FAIL}")
|
|
||||||
print(f" 合格率: {PASS/max(total,1)*100:.1f}%")
|
|
||||||
print(f"\n RESULT: {'ALL PASSED' if FAIL==0 else 'SOME FAILED'}")
|
|
||||||
|
|
||||||
if ERRORS:
|
|
||||||
print(f"\n 失敗詳細:")
|
|
||||||
for e in ERRORS:
|
|
||||||
print(f" ❌ {e}")
|
|
||||||
|
|
||||||
sys.exit(0 if FAIL == 0 else 1)
|
|
||||||
Reference in New Issue
Block a user