Files
ecm_sync_system/scripts/auto_test_domains/base.py
T
2026-04-01 11:52:57 +08:00

600 lines
24 KiB
Python

from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import Any
import yaml
from auto_test_lib import (
copy_selected_files,
copy_tree_contents,
diff_record_fields,
enrich_supplier_display_fields,
latest_log_file,
merge_tree_contents,
normalize_contract_settlement_records,
normalize_construction_task_plan_nodes,
persistence_summary,
read_jsonl_records,
load_yaml_config,
run_command,
sqlite_rows,
)
def _tail_text(value: str | None, max_chars: int = 1000) -> str:
if not value:
return ""
if len(value) <= max_chars:
return value
return value[-max_chars:]
def collect_required_bindings(db_path: str, required_bindings: dict[str, list[str]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for node_type, local_ids in required_bindings.items():
result[node_type] = []
local_nodes = sqlite_rows(
db_path,
"SELECT node_id, data_id FROM nodes WHERE collection_id = 'local' AND node_type = ?",
(node_type,),
)
remote_nodes = sqlite_rows(
db_path,
"SELECT node_id, data_id FROM nodes WHERE collection_id = 'remote' AND node_type = ?",
(node_type,),
)
bindings = sqlite_rows(
db_path,
"SELECT local_id, remote_id FROM bindings WHERE node_type = ?",
(node_type,),
)
local_data_to_node = {item["data_id"]: item["node_id"] for item in local_nodes}
remote_node_to_data = {item["node_id"]: item["data_id"] for item in remote_nodes}
bound_map = {item["local_id"]: item.get("remote_id") for item in bindings}
for local_id in local_ids:
local_node_id = local_data_to_node.get(local_id)
remote_node_id = bound_map.get(local_node_id) if local_node_id else None
result[node_type].append(
{
"local_data_id": local_id,
"local_node_id": local_node_id,
"remote_node_id": remote_node_id,
"remote_data_id": remote_node_to_data.get(remote_node_id) if remote_node_id else None,
"bound": remote_node_id is not None,
}
)
return result
def collect_problem_nodes(db_path: str, node_types: list[str] | None = None) -> list[dict[str, Any]]:
problems: list[dict[str, Any]] = []
seen: set[tuple[str | None, str | None]] = set()
sql = """
SELECT
collection_id,
node_type,
node_id,
data_id,
bind_data_id,
action,
status,
binding_status,
error,
sync_log,
context
FROM nodes
"""
params: tuple[Any, ...] = ()
if node_types:
placeholders = ", ".join("?" for _ in node_types)
sql += f" WHERE node_type IN ({placeholders})"
params = tuple(node_types)
sql += " ORDER BY node_type, collection_id, node_id"
rows = sqlite_rows(db_path, sql, params)
for row in rows:
status = row.get("status")
binding_status = row.get("binding_status")
error = row.get("error")
has_problem = bool(error) or status == "FAILED" or (binding_status not in (None, "", "normal"))
if not has_problem:
continue
key = (row.get("collection_id"), row.get("node_id"))
if key in seen:
continue
seen.add(key)
problems.append(
{
"collection_id": row.get("collection_id"),
"node_type": row.get("node_type"),
"node_id": row.get("node_id"),
"data_id": row.get("data_id"),
"bind_data_id": row.get("bind_data_id"),
"action": row.get("action"),
"status": status,
"binding_status": binding_status,
"error": error,
"sync_log_tail": _tail_text(row.get("sync_log"), 1600),
"context": row.get("context"),
}
)
return problems
def collect_round_diffs(previous_dataset_dir: str | None, current_dataset_dir: str) -> dict[str, dict[str, list[str]]]:
if not previous_dataset_dir:
return {}
previous_root = Path(previous_dataset_dir)
current_root = Path(current_dataset_dir)
diffs: dict[str, dict[str, list[str]]] = {}
for after_file in sorted(current_root.glob("*.jsonl")):
before_file = previous_root / after_file.name
if not before_file.exists():
continue
node_type = _infer_node_type_from_jsonl(after_file)
before_records = read_jsonl_records(before_file)
after_records = read_jsonl_records(after_file)
file_diff: dict[str, list[str]] = {}
for record_id, after in after_records.items():
before = before_records.get(record_id)
if not before:
continue
changed = diff_record_fields(before, after)
if changed:
file_diff[record_id] = changed
if file_diff:
diffs[node_type] = file_diff
return diffs
def collect_round_diff(previous_dataset_dir: str | None, current_dataset_dir: str, domain_name: str) -> dict[str, list[str]]:
return collect_round_diffs(previous_dataset_dir, current_dataset_dir).get(domain_name, {})
def _resolve_field_path(data: Any, field_path: str) -> Any:
current = data
for part in field_path.split("."):
if isinstance(current, dict):
current = current.get(part)
continue
if isinstance(current, list) and part.isdigit():
index = int(part)
if 0 <= index < len(current):
current = current[index]
continue
return None
return current
def collect_node_data_by_data_ids(
db_path: str,
*,
collection_id: str,
node_type: str,
data_ids: list[str],
) -> dict[str, Any]:
if not data_ids:
return {}
placeholders = ", ".join("?" for _ in data_ids)
rows = sqlite_rows(
db_path,
(
"SELECT data_id, data FROM nodes "
"WHERE collection_id = ? AND node_type = ? AND data_id IN ("
f"{placeholders})"
),
(collection_id, node_type, *data_ids),
)
result: dict[str, Any] = {}
for row in rows:
raw = row.get("data")
data_id = row.get("data_id")
if data_id is None or not raw:
continue
try:
result[str(data_id)] = json.loads(raw)
except json.JSONDecodeError:
continue
return result
def make_local_data_id(node_type: str, remote_data_id: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"auto-test-local:{node_type}:{remote_data_id}"))
def _infer_node_type_from_jsonl(file_path: Path) -> str:
name = file_path.name
if name.endswith("_1.jsonl"):
return name[:-8]
if name.endswith(".jsonl"):
return name[:-6]
return file_path.stem
def remap_project_scope_local_ids(
dataset_root: str | Path,
*,
remote_project_id: str,
local_project_id: str,
remap_node_types: set[str],
) -> None:
root = Path(dataset_root)
for file_path in sorted(root.glob("*.jsonl")):
node_type = _infer_node_type_from_jsonl(file_path)
raw_lines = file_path.read_text(encoding="utf-8").splitlines()
rewritten: list[str] = []
changed = False
for line in raw_lines:
raw = line.strip()
if not raw:
continue
item = json.loads(raw)
original = json.dumps(item, ensure_ascii=False, sort_keys=True)
if item.get("project_id") == remote_project_id:
item["project_id"] = local_project_id
if node_type == "project" and item.get("_id") == remote_project_id:
item["_id"] = local_project_id
elif node_type in remap_node_types and item.get("project_id") == local_project_id and item.get("_id"):
item["_id"] = make_local_data_id(node_type, str(item["_id"]))
changed = changed or (json.dumps(item, ensure_ascii=False, sort_keys=True) != original)
rewritten.append(json.dumps(item, ensure_ascii=False))
if changed:
file_path.write_text("\n".join(rewritten) + "\n", encoding="utf-8")
def remap_project_scope_dataset_diffs(
dataset_diffs: dict[str, dict[str, list[str]]],
*,
remote_project_id: str,
local_project_id: str,
remap_node_types: set[str],
) -> dict[str, dict[str, list[str]]]:
remapped: dict[str, dict[str, list[str]]] = {}
for node_type, record_changes in dataset_diffs.items():
remapped[node_type] = {}
for record_id, changed_fields in record_changes.items():
new_record_id = record_id
if node_type == "project" and record_id == remote_project_id:
new_record_id = local_project_id
elif node_type in remap_node_types:
new_record_id = make_local_data_id(node_type, record_id)
remapped[node_type][new_record_id] = changed_fields
return remapped
def build_temp_run_profile_for_project_auto_bind(
*,
config: dict[str, Any],
report_root: str | Path,
profile_name: str,
local_project_id: str,
remote_project_id: str,
project_update_direction: str | None = None,
project_local_orphan_action: str | None = None,
project_remote_orphan_action: str | None = None,
project_detail_push_keys: set[str] | None = None,
) -> tuple[str, list[str]]:
profile = load_yaml_config(config["pipeline"]["run_profile"])
profile.setdefault("local_datasource", {}).setdefault("handler_configs", {}).setdefault("project", {})["data_id_filter"] = [local_project_id]
profile.setdefault("remote_datasource", {}).setdefault("handler_configs", {}).setdefault("project", {})["data_id_filter"] = [remote_project_id]
project_strategy = profile.setdefault("strategies", {}).setdefault("project", {}).setdefault("config", {})
project_strategy["auto_bind"] = False
project_strategy["auto_bind_fields"] = []
project_strategy["pre_bind_data_id"] = [
{
"local_data_id": local_project_id,
"remote_data_id": remote_project_id,
}
]
project_strategy["local_orphan_action"] = project_local_orphan_action or "none"
project_strategy["remote_orphan_action"] = project_remote_orphan_action or "none"
project_strategy["update_direction"] = project_update_direction or "none"
if project_detail_push_keys:
fixed_pull_keys = {"ensure_production", "climb_production", "challenge_production"}
if fixed_pull_keys.intersection(project_detail_push_keys):
project_detail_runtime = profile.setdefault("strategies", {}).setdefault("project_detail_data", {})
domain_option = project_detail_runtime.setdefault("domain_option", {})
domain_option["pull_group_production_plans"] = False
runtime_dir = Path(report_root) / "runtime_configs"
runtime_dir.mkdir(parents=True, exist_ok=True)
profile_path = runtime_dir / f"{profile_name}.yaml"
profile_path.write_text(yaml.safe_dump(profile, allow_unicode=True, sort_keys=False), encoding="utf-8")
command = list(config["pipeline"]["command"])
if "--config_path" in command:
index = command.index("--config_path")
if index + 1 < len(command):
command[index + 1] = str(profile_path)
else:
command.append(str(profile_path))
else:
command.extend(["--config_path", str(profile_path)])
return str(profile_path), command
def run_pipeline_round(
*,
config: dict[str, Any],
dataset_dir: str,
previous_dataset_dir: str | None,
base_dataset_dir: str | None = None,
base_dataset_files: list[str] | None = None,
domain_name: str,
round_name: str,
purpose: str,
expected_changes: dict[str, Any] | None = None,
required_bindings: dict[str, list[str]] | None = None,
expected_binding_pairs: dict[str, dict[str, str]] | None = None,
require_distinct_binding_ids: dict[str, list[str]] | None = None,
expected_bound_fields: dict[str, dict[str, dict[str, dict[str, Any]]]] | None = None,
project_id_remap: dict[str, Any] | None = None,
project_detail_push_keys: set[str] | None = None,
report_root: str | Path | None = None,
coverage: dict[str, Any] | None = None,
) -> dict[str, Any]:
pipeline = config["pipeline"]
paths = config["paths"]
if base_dataset_dir and base_dataset_files:
copy_selected_files(base_dataset_dir, paths["temp_data_dir"], base_dataset_files)
merge_tree_contents(dataset_dir, paths["temp_data_dir"])
else:
copy_tree_contents(dataset_dir, paths["temp_data_dir"])
common_dataset_dir = paths.get("common_dataset_dir")
if common_dataset_dir and Path(common_dataset_dir).exists():
merge_tree_contents(common_dataset_dir, paths["temp_data_dir"])
enrich_supplier_display_fields(
paths["temp_data_dir"],
f"{config['project_root']}/working_local_datasource/filtered/region_1.jsonl",
)
normalize_contract_settlement_records(paths["temp_data_dir"])
normalize_construction_task_plan_nodes(paths["temp_data_dir"])
command = pipeline["command"]
temp_run_profile_path = None
if project_id_remap:
local_project_id = str(project_id_remap["local_project_id"])
remote_project_id = str(project_id_remap["remote_project_id"])
remap_project_scope_local_ids(
paths["temp_data_dir"],
remote_project_id=remote_project_id,
local_project_id=local_project_id,
remap_node_types=set(project_id_remap.get("remap_node_types", set())),
)
if report_root is None:
raise ValueError("project_id_remap requires report_root")
temp_run_profile_path, command = build_temp_run_profile_for_project_auto_bind(
config=config,
report_root=report_root,
profile_name=f"{domain_name}_{round_name}",
local_project_id=local_project_id,
remote_project_id=remote_project_id,
project_update_direction=project_id_remap.get("project_update_direction"),
project_local_orphan_action=project_id_remap.get("project_local_orphan_action"),
project_remote_orphan_action=project_id_remap.get("project_remote_orphan_action"),
project_detail_push_keys=project_detail_push_keys,
)
result = run_command(command, pipeline["project_root"])
latest_log = latest_log_file(paths["pipeline_log_dir"])
dataset_diff = collect_round_diff(previous_dataset_dir, dataset_dir, domain_name)
dataset_diffs = collect_round_diffs(previous_dataset_dir, dataset_dir)
if project_id_remap:
dataset_diffs = remap_project_scope_dataset_diffs(
dataset_diffs,
remote_project_id=str(project_id_remap["remote_project_id"]),
local_project_id=str(project_id_remap["local_project_id"]),
remap_node_types=set(project_id_remap.get("remap_node_types", set())),
)
dataset_diff = dataset_diffs.get(domain_name, {})
binding_report = collect_required_bindings(paths["persistence_db"], required_bindings or {})
persist_report = persistence_summary(paths["persistence_db"], domain_name)
watched_problem_types = set(required_bindings or {})
watched_problem_types.update(expected_changes or {})
for collection_node_types in (expected_bound_fields or {}).values():
watched_problem_types.update(collection_node_types)
watched_problem_types.add(domain_name)
problem_nodes = collect_problem_nodes(
paths["persistence_db"],
sorted(watched_problem_types) if watched_problem_types else None,
)
issues: list[str] = []
if result.returncode != 0:
issues.append(f"pipeline 返回码不是 0: {result.returncode}")
for node_type, items in binding_report.items():
for item in items:
if not item["bound"]:
issues.append(f"{node_type} 未建立绑定: {item['local_data_id']}")
expected_binding_pairs = expected_binding_pairs or {}
for node_type, local_to_remote in expected_binding_pairs.items():
actual_items = {item["local_data_id"]: item for item in binding_report.get(node_type, [])}
for local_id, expected_remote_id in local_to_remote.items():
actual = actual_items.get(local_id)
if not actual:
issues.append(f"{node_type} 缺少绑定检查记录: {local_id}")
continue
if actual.get("remote_data_id") != expected_remote_id:
issues.append(
f"{node_type} 绑定目标不符合预期: local={local_id} remote={actual.get('remote_data_id')} expected={expected_remote_id}"
)
require_distinct_binding_ids = require_distinct_binding_ids or {}
for node_type, local_ids in require_distinct_binding_ids.items():
actual_items = {item["local_data_id"]: item for item in binding_report.get(node_type, [])}
for local_id in local_ids:
actual = actual_items.get(local_id)
if not actual or not actual.get("bound"):
continue
if actual.get("remote_data_id") == local_id:
issues.append(f"{node_type} 绑定后 local/remote data_id 不应相同: {local_id}")
expected_bound_fields = expected_bound_fields or {}
verified_bound_fields: list[dict[str, Any]] = []
for collection_id, node_types in expected_bound_fields.items():
for node_type, local_expectations in node_types.items():
binding_items = {item["local_data_id"]: item for item in binding_report.get(node_type, [])}
target_id_by_local: dict[str, str] = {}
for local_data_id in local_expectations:
if collection_id == "local":
target_id_by_local[local_data_id] = local_data_id
continue
remote_data_id = (binding_items.get(local_data_id) or {}).get("remote_data_id")
if remote_data_id:
target_id_by_local[local_data_id] = remote_data_id
records = collect_node_data_by_data_ids(
paths["persistence_db"],
collection_id=collection_id,
node_type=node_type,
data_ids=list(target_id_by_local.values()),
)
for local_data_id, expected_fields in local_expectations.items():
target_data_id = target_id_by_local.get(local_data_id)
record = records.get(target_data_id) if target_data_id else None
if not target_data_id or record is None:
issues.append(
f"{collection_id}.{node_type} 字段校验缺少目标记录: local={local_data_id} resolved={target_data_id}"
)
continue
actual_fields: dict[str, Any] = {}
mismatches: list[str] = []
for field_path, expected_value in expected_fields.items():
actual_value = _resolve_field_path(record, field_path)
actual_fields[field_path] = actual_value
if actual_value != expected_value:
mismatches.append(
f"{field_path}: actual={actual_value!r} expected={expected_value!r}"
)
verified_bound_fields.append(
{
"collection_id": collection_id,
"node_type": node_type,
"local_data_id": local_data_id,
"target_data_id": target_data_id,
"matched": not mismatches,
"expected_fields": expected_fields,
"actual_fields": actual_fields,
}
)
if mismatches:
issues.append(
f"{collection_id}.{node_type} 字段校验失败: local={local_data_id} target={target_data_id}; "
+ "; ".join(mismatches)
)
relevant_local_ids: set[str] = set()
for local_ids in (required_bindings or {}).values():
relevant_local_ids.update(local_ids)
relevant_remote_ids: set[str] = set()
for items in binding_report.values():
for item in items:
remote_data_id = item.get("remote_data_id")
if remote_data_id:
relevant_remote_ids.add(str(remote_data_id))
for remote_map in (expected_binding_pairs or {}).values():
relevant_remote_ids.update(str(remote_id) for remote_id in remote_map.values() if remote_id)
matched_verified_local_keys = {
(entry["node_type"], entry["local_data_id"])
for entry in verified_bound_fields
if entry.get("matched")
}
for item in problem_nodes:
item_data_id = item.get("data_id")
if item.get("collection_id") == "local":
if item_data_id not in relevant_local_ids:
continue
elif item.get("collection_id") == "remote":
if not item_data_id or item_data_id not in relevant_remote_ids:
continue
error_text = item.get("error") or ""
if item.get("status") == "FAILED":
issues.append(
f"{item['collection_id']}.{item['node_type']} 节点失败: data_id={item.get('data_id')} action={item.get('action')} error={error_text or '<empty>'}"
)
elif item.get("binding_status") not in (None, "", "normal"):
if (
item.get("collection_id") == "local"
and (item.get("node_type"), item.get("data_id")) in matched_verified_local_keys
):
continue
issues.append(
f"{item['collection_id']}.{item['node_type']} 绑定异常: data_id={item.get('data_id')} binding_status={item.get('binding_status')}"
)
elif error_text:
continue
expected_changes = expected_changes or {}
for node_type, record_changes in expected_changes.items():
node_diff = dataset_diffs.get(node_type, {})
for record_id, expected_fields in record_changes.items():
actual_changed_fields: list[str] = node_diff.get(record_id, [])
missing = [field for field in expected_fields if field not in actual_changed_fields]
if missing:
issues.append(f"数据变更字段不完整: {record_id} 缺少 {', '.join(missing)}")
return {
"name": round_name,
"purpose": purpose,
"dataset_dir": dataset_dir,
"returncode": result.returncode,
"passed": not issues,
"issues": issues,
"stdout_tail": result.stdout[-2000:],
"stderr_tail": result.stderr[-2000:],
"latest_log_file": latest_log,
"expected_changes": expected_changes,
"dataset_diff": dataset_diff,
"dataset_diffs": dataset_diffs,
"required_bindings": binding_report,
"expected_binding_pairs": expected_binding_pairs,
"require_distinct_binding_ids": require_distinct_binding_ids,
"verified_bound_fields": verified_bound_fields,
"problem_nodes": problem_nodes,
"persistence_summary": persist_report,
"temp_run_profile_path": temp_run_profile_path,
"coverage": coverage or {},
}
def build_domain_report(domain: str, description: str, rounds: list[dict[str, Any]]) -> dict[str, Any]:
issues: list[str] = []
for round_report in rounds:
for issue in round_report.get("issues", []):
issues.append(f"{round_report['name']}: {issue}")
return {
"domain": domain,
"description": description,
"passed": len(issues) == 0,
"issues": issues,
"rounds": rounds,
}