feat: config-driven multi-scenario + gcda accumulation fix + gcov merge fix

Config-driven architecture:
- coverage_dates: YAML-driven date replacement for LEAVE_RECORDS
- row_overrides: per-scenario field overrides (e.g. STATUS='9')
- delete_all_rows: per-scenario table emptying for empty-cursor tests

gcda accumulation fix (V3 bug #7):
- Delete .gcda before each scenario run to prevent GnuCOBOL accumulation
- Force-copy scenario gcda (skip mtime check)
- Copy scenario DB to CWD data/kin.db for CONNECT TO path resolution

Multi-run gcov merge fix:
- Always merge multi-run gcov data regardless of generate_coverage flag
- Fix v3_root UnboundLocalError in cleanup path

Other fixes:
- _make_key_unique: skip WHERE-constrained columns to avoid PK conflict
- incremental_supplement: support fields_dict for base record generation
- check_coverage: use structure coverage data if available
- orchestrator.py: filter _-prefixed fields in TestCase; merge Agent2Data cases
This commit is contained in:
hangshuo652
2026-07-18 08:42:55 +08:00
parent 327ede372f
commit 0203ead96b
7 changed files with 186 additions and 21 deletions
+86 -8
View File
@@ -234,7 +234,7 @@ class GixsqlOrchestrator:
self._init_database(db_path)
# DB 初期行投入(DELETE/UPDATE が作用する行、SELECT が返す行)
self._populate_database(db_path, src_text, recs)
self._populate_database(db_path, src_text, recs, scenario=scenario)
# P5: inject duplicate-PK rows (scenario で制御)
if scenario is None or scenario.inject_duplicate_pk:
self._inject_sql_error_rows(db_path, recs)
@@ -551,8 +551,23 @@ class GixsqlOrchestrator:
self.db_path.unlink()
shutil.copy2(str(db_path), str(self.db_path))
db_path = self.db_path
# CONNECT TO 'data/kin.db' のパス解釈に備え CWD にもコピー
if scenario is not None:
cwd_data = cwd / "data"
cwd_data.mkdir(parents=True, exist_ok=True)
cwd_db = cwd_data / "kin.db"
if cwd_db.exists():
cwd_db.unlink()
shutil.copy2(str(db_path), str(cwd_db))
# .gcda は CWD= run_dir)に書き出されるので、実行後に gcov/run_{id}/ に移動する
# 各シナリオ実行前に前回の .gcda を削除(GnuCOBOL は累積書込みを行うため)
exe_dir_for_gcda = self.work_dir / "bin"
for f in exe_dir_for_gcda.glob("*.gcda"):
try:
f.unlink()
except PermissionError:
pass
# Subprogram DLLs
cobol_bin = Path(self.cobol_src_dir).parent / "bin"
@@ -585,7 +600,7 @@ class GixsqlOrchestrator:
for f in sd.glob("*.gcda"):
if f.is_file() and f.stat().st_size > 0:
dst = gcda_dst_dir / f.name
if not dst.exists() or f.stat().st_mtime > dst.stat().st_mtime:
if scenario or not dst.exists() or f.stat().st_mtime > dst.stat().st_mtime:
try:
shutil.copy2(str(f), str(dst))
except PermissionError:
@@ -772,7 +787,8 @@ class GixsqlOrchestrator:
generate_coverage_index([cov_result], str(output_dir.parent))
# Clean up .gcno/.gcda from v3_root + CWD (avoid accumulation)
for clean_dir in (v3_root, Path.cwd()):
_v3_root = Path(__file__).parent
for clean_dir in (_v3_root, Path.cwd()):
if clean_dir == gcov_dir:
continue
for ext in (".gcno", ".gcda"):
@@ -967,12 +983,13 @@ class GixsqlOrchestrator:
elif step_num == 6:
vr = self.step6_verify()
# Always merge multi-run gcov data (needed by external coverage report)
if is_multi:
merged = self._merge_multi_run_gcov()
self._multi_run_gcov_data = merged
# Optional coverage report (non-blocking)
cv_flags = getattr(self.config, 'gixsql_compile_flags', '')
if '--coverage' in cv_flags and generate_coverage:
if is_multi:
merged = self._merge_multi_run_gcov()
self._multi_run_gcov_data = merged
self.generate_coverage_report()
vr = VerificationRun(
@@ -1061,7 +1078,8 @@ class GixsqlOrchestrator:
conn.close()
logger.info(f" DB initialized: {db_path}")
def _populate_database(self, db_path: Path, src_text: str, records: list[dict]):
def _populate_database(self, db_path: Path, src_text: str, records: list[dict],
scenario: ScenarioDef | None = None):
"""テストデータから DB 初期行を生成し挿入する。"""
from cobol_testgen.pipeline_bridge import build_branch_tree_fallback
from cobol_testgen.read import extract_procedure_division
@@ -1118,6 +1136,43 @@ class GixsqlOrchestrator:
logger.info(" No DB input rows generated")
return
# -- Coverage-driven data enrichment --
# build_db_input generates counter-value dates; replace with valid
# YYYYMMDD dates targeting specific uncovered decision branches.
# Configurations are from YAML coverage_dates (program-specific).
if 'LEAVE_RECORDS' in db_input:
lr_rows = db_input['LEAVE_RECORDS']
date_cfgs_raw = (self.schema.coverage_dates or {}).get('LEAVE_RECORDS', [])
date_cfgs = [
(d['start'], d['end'], d['emp'])
for d in date_cfgs_raw
]
for i, row in enumerate(lr_rows):
if i < len(date_cfgs):
sd, ed, eid = date_cfgs[i]
else:
sd, ed, eid = ('20260701', '20260703', f'{i+10:08d}')
row['START_DATE'] = sd
row['END_DATE'] = ed
row['EMP_ID'] = eid
row['APPLICATION_ID'] = str(i + 1)
if 'HOLIDAY_CALENDAR' in db_input:
hc_rows = db_input['HOLIDAY_CALENDAR']
holiday_overrides = ['20260701', '20260715', '20260801',
'20260101', '20260501', '20261001']
for i, row in enumerate(hc_rows):
if i < len(holiday_overrides):
row['HOLIDAY_DATE'] = holiday_overrides[i]
# -- Per-scenario row overrides (from YAML runs[].row_overrides) --
if scenario and scenario.row_overrides:
for table_name, overrides in scenario.row_overrides.items():
if table_name in db_input:
for row in db_input[table_name]:
for col, val in overrides.items():
row[col.upper()] = val
conn = sqlite3.connect(str(db_path))
for table_name, rows in db_input.items():
if not rows:
@@ -1127,12 +1182,14 @@ class GixsqlOrchestrator:
# Debug
logger.info(f" Table {table_name}: {len(rows)} rows, cols={list(rows[0].keys()) if rows else []}")
# Filter columns: only keep those that actually exist in the table
# Query DB column types for type-aware value conversion
col_types = {}
try:
pragma_cols = conn.execute(
f"PRAGMA table_info([{table_name}])"
).fetchall()
valid_cols = {r[1].upper() for r in pragma_cols}
col_types = {r[1].upper(): r[2].upper() for r in pragma_cols}
except Exception:
valid_cols = set()
@@ -1149,12 +1206,33 @@ class GixsqlOrchestrator:
logger.info(f" Table {table_name}: all rows filtered out, skipping")
continue
# Convert values to match DB column types
for row in rows:
for k in list(row.keys()):
ct = col_types.get(k.upper(), '')
v = row[k]
if ct.startswith('INTEGER') or ct in ('INT', 'SMALLINT', 'BIGINT', 'TINYINT'):
try:
row[k] = str(int(v)) if v and v.strip() else '0'
except (ValueError, TypeError):
row[k] = '0'
elif ct.startswith('DECIMAL') or ct.startswith('NUMERIC') or ct.startswith('FLOAT') or ct.startswith('REAL'):
try:
row[k] = str(float(v)) if v and v.strip() else '0'
except (ValueError, TypeError):
row[k] = '0'
col_names = list(rows[0].keys())
placeholders = ", ".join("?" for _ in col_names)
quoted_cols = ", ".join(f"[{c}]" for c in col_names)
sql = f"INSERT OR IGNORE INTO [{table_name}] ({quoted_cols}) VALUES ({placeholders})"
conn.executemany(sql, [tuple(r.get(c, "") for c in col_names) for r in rows])
logger.info(f" Table {table_name}: {len(rows)} initial rows inserted")
# -- Per-scenario row deletion (e.g. empty cursor scenario) --
if scenario and scenario.delete_all_rows:
for table_name in db_input.keys():
conn.execute(f"DELETE FROM [{table_name}]")
logger.info(f" Table {table_name}: all rows deleted (scenario={scenario.id})")
conn.commit()
conn.close()
logger.info(f" DB populated: {db_path}")