Files
llm/scripts/smoke_1c_saved_state_write_routes.py
T

381 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFAULT_FORM_FILE = ""
ROUTES: list[dict[str, Any]] = [
{
"name": "form_attribute_title",
"selector": {"element": "А"},
"value": "SMOKE_A_TITLE",
"expected_target_section": "items",
"expected_effective_section": "attributes",
"expected_source_kind": "data_path_form_attribute_title",
"expected_write_path": "3.3.4.2.1",
},
{
"name": "tabular_attribute_field_title",
"selector": {"element": "ТЗК1"},
"value": "SMOKE_K1_TITLE",
"expected_target_section": "items",
"expected_effective_section": "attribute_fields",
"expected_source_kind": "data_path_form_attribute_field_title",
"expected_write_path": "3.6.14.4.2.1",
},
{
"name": "command_title",
"selector": {"command": "КомандаПример1"},
"value": "SMOKE_COMMAND_TITLE",
"expected_target_section": "commands",
"expected_effective_section": "commands",
"expected_source_kind": "local",
"expected_write_path": "5.3.3.2.1",
},
]
def request_json(
method: str,
base_url: str,
path: str,
*,
payload: dict[str, Any] | None = None,
timeout: float = 30.0,
) -> tuple[int, dict[str, Any] | str, bool]:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
request = Request(
f"{base_url.rstrip('/')}{path}",
data=body,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8")
return response.status, json.loads(raw) if raw else {}, False
except HTTPError as exc:
raw = exc.read().decode("utf-8")
return exc.code, json.loads(raw) if raw else {}, True
except URLError as exc:
return 0, str(exc), True
def rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
status, response, is_error = request_json(
"POST",
base_url,
"/rpc",
payload={"method": method, "payload": payload},
timeout=timeout,
)
if is_error or status != 200 or not isinstance(response, dict):
raise AssertionError(f"{method} failed: status={status}, response={response}")
return response
def assert_equal(actual: Any, expected: Any, label: str) -> None:
if actual != expected:
raise AssertionError(f"{label}: expected {expected!r}, got {actual!r}")
def check_route(
*,
base_url: str,
base_id: str,
table: str,
file_name: str,
route: dict[str, Any],
timeout: float,
) -> dict[str, Any]:
property_name = str(route.get("property") or "Заголовок")
common = {
"base_id": base_id,
"table": table,
"file_name": file_name,
"property": property_name,
"include_storage": True,
"timeout_seconds": int(timeout),
}
resolve_payload = {
**common,
**route["selector"],
"value": route["value"],
}
resolved = rpc(base_url, "metadata.form.write_target.resolve", resolve_payload, timeout)
assert_equal(resolved.get("status"), "ok", f"{route['name']} resolve status")
if route.get("expected_target_section"):
assert_equal((resolved.get("target") or {}).get("section"), route["expected_target_section"], f"{route['name']} target section")
if route.get("expected_effective_section"):
assert_equal((resolved.get("effective_target") or {}).get("section"), route["expected_effective_section"], f"{route['name']} effective section")
source_kind = (resolved.get("effective_source") or {}).get("kind") or (resolved.get("display") or {}).get("source_kind")
if route.get("expected_source_kind"):
assert_equal(source_kind, route["expected_source_kind"], f"{route['name']} source kind")
if route.get("expected_write_path"):
assert_equal((resolved.get("write_target") or {}).get("path"), route["expected_write_path"], f"{route['name']} write path")
write_payload = {
"base_id": base_id,
"target": {
"kind": "form",
"table": table,
"file_name": file_name,
**route["selector"],
},
"mode": "apply_and_rollback",
"edits": [{"property": property_name, "value": route["value"]}],
"allow_sql_saved_state_apply": True,
"allow_sql_saved_state_rollback": True,
"timeout_seconds": int(timeout),
}
written = rpc(base_url, "metadata.write", write_payload, timeout)
assert_equal(written.get("status"), "verified_and_rolled_back", f"{route['name']} write status")
result = written.get("result") if isinstance(written.get("result"), dict) else {}
write_plan = result.get("write_plan") if isinstance(result.get("write_plan"), dict) else {}
assert_equal(write_plan.get("allowed"), True, f"{route['name']} write plan allowed")
assert_equal((write_plan.get("route") or {}).get("apply_method"), "metadata.form.element.write_apply", f"{route['name']} write plan apply method")
assert_equal(result.get("rolled_back"), True, f"{route['name']} rolled_back")
verification = ((result.get("apply_result") or {}).get("semantic_verification") or {})
assert_equal(verification.get("status"), "ok", f"{route['name']} semantic verification")
for check in verification.get("checks") or []:
assert_equal(check.get("ok"), True, f"{route['name']} semantic check")
after = rpc(base_url, "metadata.form.write_target.resolve", common | route["selector"], timeout)
assert_equal(after.get("status"), "ok", f"{route['name']} post-rollback resolve status")
if route.get("expected_write_path"):
assert_equal((after.get("write_target") or {}).get("path"), route["expected_write_path"], f"{route['name']} post-rollback write path")
return {
"name": route["name"],
"status": "ok",
"target_section": (resolved.get("target") or {}).get("section"),
"effective_section": (resolved.get("effective_target") or {}).get("section"),
"source_kind": source_kind,
"write_path": (resolved.get("write_target") or {}).get("path"),
"old": (resolved.get("write_target") or {}).get("old"),
"test_value": route["value"],
"post_rollback_old": (after.get("write_target") or {}).get("old"),
"property": property_name,
"write_plan": {
"status": write_plan.get("status"),
"allowed": write_plan.get("allowed"),
"apply_method": (write_plan.get("route") or {}).get("apply_method"),
"target_kind": (write_plan.get("target") or {}).get("target_kind"),
},
}
def route_from_matrix_entry(entry: dict[str, Any], index: int) -> dict[str, Any] | None:
prop = entry.get("property") if isinstance(entry.get("property"), dict) else {}
selector = entry.get("selector") if isinstance(entry.get("selector"), dict) else {}
test_value = prop.get("test_value")
if not selector or test_value is None:
return None
property_name = str(prop.get("presentation") or prop.get("property") or prop.get("canonical_property") or "").strip()
if not property_name:
return None
return {
"name": f"matrix_safe_{index}",
"selector": selector,
"property": property_name,
"value": test_value,
"expected_target_section": ((entry.get("requested_target") or {}) if isinstance(entry.get("requested_target"), dict) else {}).get("section"),
"expected_effective_section": ((entry.get("effective_target") or {}) if isinstance(entry.get("effective_target"), dict) else {}).get("section"),
"expected_source_kind": ((entry.get("effective_source") or {}) if isinstance(entry.get("effective_source"), dict) else {}).get("kind"),
"expected_write_path": prop.get("write_path"),
}
def discover_matrix_smoke_routes(
base_url: str,
base_id: str,
table: str,
file_name: str,
timeout: float,
*,
limit: int = 1,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
matrix = rpc(
base_url,
"metadata.form.write_matrix.build",
{
"base_id": base_id,
"table": table,
"file_name": file_name,
"timeout_seconds": int(timeout),
},
timeout,
)
if matrix.get("status") != "ok":
raise AssertionError(f"write matrix build failed: {matrix.get('status')}")
routes: list[dict[str, Any]] = []
for entry in matrix.get("entries") or []:
if not isinstance(entry, dict) or not entry.get("can_smoke"):
continue
route = route_from_matrix_entry(entry, len(routes) + 1)
if route is None:
continue
routes.append(route)
if len(routes) >= limit:
break
if not routes:
raise AssertionError("no write-matrix smoke-safe route candidates found")
return routes, {"status": matrix.get("status"), "counts": matrix.get("counts"), "source": "metadata.form.write_matrix.build"}
def discover_smoke_routes(base_url: str, base_id: str, table: str, timeout: float) -> tuple[str, list[dict[str, Any]], dict[str, Any]]:
saved_state = rpc(
base_url,
"metadata.saved_state.forms.search",
{
"base_id": base_id,
"tables": [table],
"limit": 20,
"scan_limit": 100,
"include_storage": True,
"timeout_seconds": int(timeout),
},
timeout,
)
if saved_state.get("status") != "ok":
raise AssertionError(f"saved-state forms search failed: {saved_state.get('status')}")
routes: list[dict[str, Any]] = []
selected_file_name = ""
for form in saved_state.get("forms") or []:
source = form.get("source") if isinstance(form.get("source"), dict) else {}
file_name = str(source.get("file_name") or form.get("file_name") or "").strip()
if not file_name:
continue
for match in form.get("matches") or []:
if not isinstance(match, dict):
continue
element_name = str(match.get("name") or match.get("title") or "").strip()
if not element_name:
continue
for prop in match.get("writable_properties") or []:
if not isinstance(prop, dict):
continue
prop_name = str(prop.get("property") or "").lower()
presentation = str(prop.get("presentation") or "").lower()
if prop_name != "title" and presentation != "заголовок":
continue
selected_file_name = file_name
routes.append(
{
"name": f"auto_title_{len(routes) + 1}",
"selector": {"element": element_name},
"value": f"SMOKE_AUTO_TITLE_{len(routes) + 1}",
"expected_write_path": prop.get("path"),
}
)
break
if routes:
break
if routes:
break
if not routes or not selected_file_name:
raise AssertionError("no saved-state form title write route candidates found")
return selected_file_name, routes, {"status": saved_state.get("status"), "counts": saved_state.get("counts")}
def main() -> int:
parser = argparse.ArgumentParser(description="Smoke test saved-state form write routing with apply_and_rollback.")
parser.add_argument("--base-url", default="http://docker-gpu.cin.su:8011", help="1C REST adapter URL.")
parser.add_argument("--base-id", default="upo_test", help="Configured adapter base id.")
parser.add_argument("--table", default="ConfigCASSave", help="Saved-state SQL table.")
parser.add_argument("--file-name", default=DEFAULT_FORM_FILE, help="Saved-state form file name. Omit to auto-select a safe saved-state form title route.")
parser.add_argument("--timeout", type=float, default=30.0, help="Request timeout in seconds.")
parser.add_argument("--report", type=Path, help="Optional JSON report path.")
parser.add_argument("--allow-empty-saved-state", action="store_true", help="Exit successfully with skipped status when the saved-state table has no forms.")
args = parser.parse_args()
report: dict[str, Any] = {
"schema": "onec_saved_state_write_routes_smoke.v1",
"base_url": args.base_url,
"base_id": args.base_id,
"table": args.table,
"file_name": args.file_name,
"routes": [],
"passed": False,
"status": "pending",
}
try:
status, health, is_error = request_json("GET", args.base_url, f"/health?base_id={args.base_id}", timeout=args.timeout)
if is_error or status != 200 or not isinstance(health, dict) or health.get("status") != "ok":
raise AssertionError(f"health failed: status={status}, response={health}")
report["health"] = {"status": health.get("status"), "live_sql": health.get("live_sql")}
if args.allow_empty_saved_state:
saved_state = rpc(
args.base_url,
"metadata.saved_state.forms.search",
{"base_id": args.base_id, "tables": [args.table], "limit": 1, "timeout_seconds": int(args.timeout)},
args.timeout,
)
report["saved_state_preflight"] = {
"status": saved_state.get("status"),
"counts": saved_state.get("counts"),
}
if saved_state.get("status") == "ok" and ((saved_state.get("counts") or {}).get("forms") or 0) == 0:
report["status"] = "skipped_no_saved_state"
report["skipped"] = True
report["passed"] = True
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
smoke_file_name = args.file_name
smoke_routes = ROUTES
if not smoke_file_name:
smoke_file_name, smoke_routes, discovery = discover_smoke_routes(args.base_url, args.base_id, args.table, args.timeout)
report["file_name"] = smoke_file_name
report["route_discovery"] = discovery
report["saved_state_preflight"] = discovery
else:
smoke_routes, discovery = discover_matrix_smoke_routes(args.base_url, args.base_id, args.table, smoke_file_name, args.timeout)
report["route_discovery"] = discovery
for route in smoke_routes:
report["routes"].append(
check_route(
base_url=args.base_url,
base_id=args.base_id,
table=args.table,
file_name=smoke_file_name,
route=route,
timeout=args.timeout,
)
)
report["passed"] = True
report["status"] = "verified_and_rolled_back"
except Exception as exc:
report["error"] = str(exc)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())