diff --git a/plugins/1c/connector/README.md b/plugins/1c/connector/README.md index bc9d7f6..71f8729 100644 --- a/plugins/1c/connector/README.md +++ b/plugins/1c/connector/README.md @@ -262,8 +262,11 @@ Client, MCP, and agent code must not add conditions for concrete object names; the adapter owns generic selector normalization. Saved-state client calls use the same name-first selectors together with -`layer=base_saved_state|extension_saved_state`. SQL tables, file names, GUID -owners, and module handles are diagnostic continuations exposed only with +`layer=base_saved_state|extension_saved_state`. Public module search results +include a name-first `write_plan_target` (`ref`, form/module names, and a +1-based stream ordinal); `metadata.write.plan` resolves its physical handle +internally. SQL tables, file names, GUID owners, module handles, and payload +hashes remain diagnostic continuations exposed only with `include_storage=true`. Every public RPC follow-up is shaped as `{"method": "...", "params": {...}}`; `payload` is not the arguments field of `next_call` or `next_resolution`. diff --git a/plugins/1c/connector/adapter_1c_server.py b/plugins/1c/connector/adapter_1c_server.py index e24dbcc..75aafff 100644 --- a/plugins/1c/connector/adapter_1c_server.py +++ b/plugins/1c/connector/adapter_1c_server.py @@ -761,7 +761,7 @@ METHODS = [ {"name": "metadata.saved_state.diff", "transport": "POST /rpc", "description": "Agent-facing read-only comparison of a saved-state module with its active source. Prefer a 1C object ref or kind/name plus module_ordinal; generated module_ref and table/file_name remain accepted for follow-up tooling. Reports changed/unchanged, needs_prepare, hashes, and compact payload diff."}, {"name": "metadata.saved_state.changes.list", "transport": "POST /rpc", "description": "Read-only name-first list of pending saved-state changes. Filter by semantic layer; public rows resolve 1C object/form/module context and hide SQL coordinates; include_storage=true exposes per-file diff/write diagnostics."}, {"name": "metadata.saved_state.forms.search", "transport": "POST /rpc", "description": "Fast name-first saved-state form search by public owner ref, form, element, command, attribute, or text. Public rows expose 1C names and semantic selectors; include_storage=true opts into SQL files, GUIDs, brace paths, markers, and write diagnostics."}, - {"name": "metadata.saved_state.modules.search", "transport": "POST /rpc", "description": "Fast name-first saved-state BSL module search over ConfigSave/ConfigCASSave. Accepts public ref or kind/name selectors and resolves SQL routes internally. Public results expose 1C owner/form/module names and previews; include_storage=true opts into module_ref handles and low-level write targets."}, + {"name": "metadata.saved_state.modules.search", "transport": "POST /rpc", "description": "Fast name-first saved-state BSL module search over ConfigSave/ConfigCASSave. Accepts public ref or kind/name selectors and resolves SQL routes internally. Public results expose 1C owner/form/module names, previews, and name-first metadata.write.plan targets; include_storage=true additionally exposes module_ref handles and physical write guards."}, {"name": "metadata.form.write_matrix.build", "transport": "POST /rpc", "description": "Build a source-aware matrix of decoded saved-state form scalar properties and safe write-smoke candidates."}, {"name": "metadata.form.write_matrix.smoke", "transport": "POST /rpc", "description": "Run apply_and_rollback smoke writes for safe entries from metadata.form.write_matrix.build and report verified write routes."}, {"name": "metadata.form.element.write", "transport": "POST /rpc", "description": "Saved-state form element write planner. Resolves a decoded form element and builds a reviewable changes.propose payload for ConfigSave/ConfigCASSave. Requires allow_saved_state_write=true and does not write SQL."}, @@ -41818,7 +41818,7 @@ def public_saved_state_modules_search_row(row: dict[str, Any]) -> dict[str, Any] **({"qualified_name": qualified_name} if qualified_name else {}), } public_streams = [] - for stream in row.get("streams") or []: + for stream_ordinal, stream in enumerate(row.get("streams") or [], start=1): if not isinstance(stream, dict): continue match = stream.get("match") if isinstance(stream.get("match"), dict) else {} @@ -41832,6 +41832,16 @@ def public_saved_state_modules_search_row(row: dict[str, Any]) -> dict[str, Any] stream_owner = stream.get("owner") if isinstance(stream.get("owner"), dict) else owner stream_form = stream.get("form") if isinstance(stream.get("form"), dict) else form stream_module = stream.get("module") if isinstance(stream.get("module"), dict) else module + stream_owner_ref = object_selector_ref(stream_owner.get("kind"), stream_owner.get("name")) + stream_qualified_name = str(stream.get("qualified_name") or stream.get("display_name") or qualified_name).strip() + write_plan_target = { + "kind": "module", + **({"ref": stream_owner_ref} if stream_owner_ref else {}), + **({"form": stream_form.get("name")} if stream_form.get("name") else {}), + **({"module": stream_module.get("name")} if stream_module.get("name") else {}), + **({"qualified_name": stream_qualified_name} if stream_qualified_name else {}), + "stream_ordinal": stream_ordinal, + } public_stream = { "preview": stream.get("preview"), **({"encoding_repaired": True} if stream.get("encoding_repaired") else {}), @@ -41868,8 +41878,9 @@ def public_saved_state_modules_search_row(row: dict[str, Any]) -> dict[str, Any] if stream_module else {} ), - **({"qualified_name": qualified_name, "display_name": qualified_name} if qualified_name else {}), + **({"qualified_name": stream_qualified_name, "display_name": stream_qualified_name} if stream_qualified_name else {}), **({"selector": selector} if selector else {}), + "write_plan_target": write_plan_target, "match": {"query": query or None, "in_text": in_text, "in_name": in_name}, } public_streams.append(public_stream) @@ -46553,9 +46564,120 @@ def metadata_write_concrete_reference_problem(reference_info: dict[str, str], ta } +def metadata_write_plan_resolve_public_module_target( + payload: dict[str, Any], + target: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + """Resolve a public 1C module selector to an internal saved-state handle.""" + selector_ref = str(target.get("ref") or target.get("object_ref") or payload.get("ref") or payload.get("object_ref") or "").strip() + selector_kind = str(target.get("object_kind") or target.get("object_type") or payload.get("object_kind") or payload.get("object_type") or "").strip() + selector_name = str(target.get("object_name") or payload.get("object_name") or "").strip() + requested_form = str(target.get("form") or target.get("form_name") or payload.get("form") or payload.get("form_name") or "").strip() + requested_module = str(target.get("module") or target.get("module_name") or payload.get("module") or payload.get("module_name") or "").strip() + requested_qualified_name = str(target.get("qualified_name") or payload.get("qualified_name") or "").strip() + requested_ordinal_raw = target.get("stream_ordinal") if target.get("stream_ordinal") is not None else payload.get("stream_ordinal") + try: + requested_ordinal = int(requested_ordinal_raw) if requested_ordinal_raw is not None else None + except (TypeError, ValueError): + requested_ordinal = None + public_selector = { + **({"ref": selector_ref} if selector_ref else {}), + **({"kind": canonical_kind(selector_kind)} if selector_kind else {}), + **({"name": selector_name} if selector_name else {}), + **({"form": requested_form} if requested_form else {}), + **({"module": requested_module} if requested_module else {}), + **({"qualified_name": requested_qualified_name} if requested_qualified_name else {}), + **({"stream_ordinal": requested_ordinal} if requested_ordinal is not None else {}), + } + if not (selector_ref or (selector_kind and selector_name)): + return target, { + "status": "not_attempted", + "error": "public_object_selector_missing", + "selector": public_selector, + } + + search_payload: dict[str, Any] = { + "base_id": payload.get("base_id"), + "include_storage": True, + "limit": int(payload.get("search_limit") or 50), + "scan_limit": int(payload.get("scan_limit") or 1000), + "timeout_seconds": int(payload.get("timeout_seconds") or 60), + } + if selector_ref: + search_payload["ref"] = selector_ref + else: + search_payload["object_type"] = selector_kind + search_payload["object_name"] = selector_name + for key in ("extension", "layer", "tables"): + value = target.get(key) if target.get(key) is not None else payload.get(key) + if value is not None: + search_payload[key] = value + search = metadata_saved_state_modules_search(search_payload) + if search.get("status") != "ok": + return target, { + "status": "error", + "error": "saved_state_module_search_failed", + "selector": public_selector, + "search_status": search.get("status"), + } + + matches: list[tuple[dict[str, Any], dict[str, Any]]] = [] + for module_row in search.get("modules") or []: + if not isinstance(module_row, dict): + continue + row_owner = module_row.get("owner") if isinstance(module_row.get("owner"), dict) else {} + row_form = module_row.get("form") if isinstance(module_row.get("form"), dict) else {} + row_module = module_row.get("module") if isinstance(module_row.get("module"), dict) else {} + row_qualified_name = str(module_row.get("qualified_name") or module_row.get("display_name") or "").strip() + if requested_form and normalize(str(row_form.get("name") or "")) != normalize(requested_form): + continue + if requested_module and normalize(str(row_module.get("name") or "")) != normalize(requested_module): + continue + if requested_qualified_name and normalize(row_qualified_name) != normalize(requested_qualified_name): + continue + for stream in module_row.get("streams") or []: + if not isinstance(stream, dict) or not stream.get("module_ref"): + continue + stream_form = stream.get("form") if isinstance(stream.get("form"), dict) else row_form + stream_module = stream.get("module") if isinstance(stream.get("module"), dict) else row_module + stream_qualified_name = str(stream.get("qualified_name") or stream.get("display_name") or row_qualified_name).strip() + if requested_form and normalize(str(stream_form.get("name") or "")) != normalize(requested_form): + continue + if requested_module and normalize(str(stream_module.get("name") or "")) != normalize(requested_module): + continue + if requested_qualified_name and normalize(stream_qualified_name) != normalize(requested_qualified_name): + continue + matches.append((module_row, stream)) + + if requested_ordinal is not None: + matches = [matches[requested_ordinal - 1]] if 1 <= requested_ordinal <= len(matches) else [] + if len(matches) != 1: + return target, { + "status": "not_found" if not matches else "ambiguous", + "error": "public_module_target_not_resolved", + "selector": public_selector, + "counts": {"matches": len(matches)}, + } + + module_row, stream = matches[0] + payload_meta = module_row.get("payload") if isinstance(module_row.get("payload"), dict) else {} + resolved_target = { + **target, + "module_ref": stream.get("module_ref"), + **({"module_path": stream.get("module_path")} if stream.get("module_path") else {}), + **({"expected_sha1": payload_meta.get("sha1")} if payload_meta.get("sha1") else {}), + } + return resolved_target, { + "status": "resolved", + "method": SAVED_STATE_MODULES_SEARCH_METHOD, + "selector": public_selector, + "counts": {"matches": 1}, + } + + def metadata_write_plan(payload: dict[str, Any]) -> dict[str, Any]: method = METADATA_WRITE_PLAN_METHOD - target = payload.get("target") if isinstance(payload.get("target"), dict) else {} + target = dict(payload.get("target")) if isinstance(payload.get("target"), dict) else {} intent = payload.get("intent") if isinstance(payload.get("intent"), dict) else {} target_kind_raw = ( payload.get("target_kind") @@ -46565,6 +46687,19 @@ def metadata_write_plan(payload: dict[str, Any]) -> dict[str, Any]: or payload.get("area") or "metadata" ) + target_kind_hint = str(target_kind_raw or "").strip().casefold() + module_name_resolution: dict[str, Any] | None = None + has_public_module_selector = bool( + target.get("ref") + or target.get("object_ref") + or ((target.get("object_kind") or target.get("object_type")) and target.get("object_name")) + ) and bool(target.get("module") or target.get("module_name") or target.get("qualified_name")) + if ( + target_kind_hint in {"module", "модуль", "bsl"} + and not metadata_write_concrete_reference_info(payload, target) + and has_public_module_selector + ): + target, module_name_resolution = metadata_write_plan_resolve_public_module_target(payload, target) canonical_path = str( target.get("canonical_path") @@ -46608,6 +46743,14 @@ def metadata_write_plan(payload: dict[str, Any]) -> dict[str, Any]: provided_origin = provided_origin if isinstance(provided_origin, dict) else None problems = [] + if module_name_resolution and module_name_resolution.get("status") != "resolved": + problems.append( + { + "code": str(module_name_resolution.get("error") or "public_module_target_not_resolved"), + "message": "The public 1C module selector did not resolve to exactly one saved-state module stream.", + "resolution_status": module_name_resolution.get("status"), + } + ) if not path_resolution.get("is_full_path") and not concrete_reference: problems.append( { @@ -46682,6 +46825,8 @@ def metadata_write_plan(payload: dict[str, Any]) -> dict[str, Any]: "write_surface": "saved_state" if concrete_reference else "requires_origin_lookup", "apply_method": None, } + if module_name_resolution: + route["name_resolution"] = module_name_resolution if extension_action: route["extension_action"] = extension_action if operation_was_inferred_from_extension_action: diff --git a/scripts/check_1c_verify_reports.py b/scripts/check_1c_verify_reports.py index a223d75..9888a9a 100644 --- a/scripts/check_1c_verify_reports.py +++ b/scripts/check_1c_verify_reports.py @@ -821,9 +821,9 @@ def validate_code_write_saved_state( expected_transport=expected_transport, expected_endpoint_url=expected_endpoint_url, ) - if require_write and report.get("status") == "skipped_missing_target": + if require_write and report.get("status") in {"skipped_missing_target", "skipped_write_gate"}: failures.append({"code": "code_write_saved_state_required", "path": str(path), "status": report.get("status")}) - if report.get("status") not in {"ok", "skipped_missing_target"}: + if report.get("status") not in {"ok", "skipped_missing_target", "skipped_write_gate"}: failures.append({"code": "code_write_saved_state_status_unexpected", "path": str(path), "status": report.get("status")}) if report.get("failures"): failures.append({"code": "code_write_saved_state_failures_present", "path": str(path), "failures": report.get("failures")}) diff --git a/scripts/plan_1c_saved_state_copy.py b/scripts/plan_1c_saved_state_copy.py index 17b67bb..9649550 100644 --- a/scripts/plan_1c_saved_state_copy.py +++ b/scripts/plan_1c_saved_state_copy.py @@ -82,12 +82,25 @@ def discover_object(base_url: str, base_id: str, source_table: str, timeout: flo def resolve_object(base_url: str, selector: dict[str, Any], source_table: str, timeout: float) -> dict[str, Any]: if not any(selector.get(key) for key in ("ref", "kind", "name", "guid")): return discover_object(base_url, str(selector["base_id"]), source_table, timeout) + resolved = rpc(base_url, "metadata.object.get", selector | {"table": source_table}, timeout) + resolved_object = resolved.get("object") if isinstance(resolved.get("object"), dict) else {} + if resolved.get("status") == "ok" and resolved_object.get("guid"): + return resolved_object if selector.get("kind") or selector.get("name") or selector.get("guid"): listed = rpc(base_url, "metadata.objects.list", selector | {"limit": 5, "table": source_table}, timeout) objects = [item for item in listed.get("objects") or [] if isinstance(item, dict)] if objects: return objects[0] - found = rpc(base_url, "metadata.definition.find", selector | {"areas": ["metadata"], "include_storage": True}, timeout) + definition_query = str(selector.get("ref") or selector.get("guid") or selector.get("name") or "").strip() + found = rpc( + base_url, + "metadata.definition.find", + selector | {"query": definition_query, "areas": ["metadata"], "include_storage": True}, + timeout, + ) + found_object = found.get("object") if isinstance(found.get("object"), dict) else {} + if found_object.get("guid"): + return found_object matches = [item for item in found.get("matches") or found.get("items") or [] if isinstance(item, dict)] for item in matches: if item.get("guid") or (item.get("object") or {}).get("guid"): diff --git a/scripts/smoke_1c_code_write_saved_state.py b/scripts/smoke_1c_code_write_saved_state.py index 0903593..111214a 100644 --- a/scripts/smoke_1c_code_write_saved_state.py +++ b/scripts/smoke_1c_code_write_saved_state.py @@ -135,6 +135,13 @@ def first_unique_fragment(text: str) -> str: return text +def is_optional_write_gate_block(result: dict[str, Any]) -> bool: + return result.get("status") == "blocked" and result.get("error") in { + "needs_repository_lock", + "support_gate_blocked", + } + + def main() -> int: parser = argparse.ArgumentParser(description="Smoke-test agent-facing 1C code.write saved-state workflow.") parser.add_argument("--transport", choices=("rest", "mcp"), default="rest") @@ -146,6 +153,11 @@ def main() -> int: parser.add_argument("--object-name", default="t_Форма") parser.add_argument("--routine-name", default="ЗаменаДомена") parser.add_argument("--allow-missing-target", action="store_true") + parser.add_argument( + "--allow-blocked-write-gate", + action="store_true", + help="Skip successfully when the target exists but repository/support policy blocks the first write.", + ) parser.add_argument("--timeout", type=int, default=60) parser.add_argument("--report", type=str) parser.add_argument("--json", action="store_true") @@ -223,6 +235,33 @@ def main() -> int: "write_mode": write.get("write_mode"), } ) + if args.allow_blocked_write_gate and is_optional_write_gate_block(write): + report = { + "schema": "onec_code_write_saved_state_smoke.v1", + "status": "skipped_write_gate", + "skipped": True, + "endpoint_url": endpoint_url, + "transport": args.transport, + "base_id": args.base_id, + "target": {"object_type": args.object_type, "object_name": args.object_name, "routine_name": args.routine_name}, + "steps": steps, + "write_gate": { + "status": write.get("status"), + "error": write.get("error"), + "diagnostics": write.get("diagnostics"), + }, + "failures": [], + **({"mcp_initialize": initialize_result} if initialize_result and args.transport == "mcp" else {}), + } + if args.report: + os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True) + with open(args.report, "w", encoding="utf-8") as handle: + json.dump(report, handle, ensure_ascii=False, indent=2) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + print(f"1C code.write saved-state smoke: {report['status']}") + return 0 write_mode = write.get("write_mode") if isinstance(write.get("write_mode"), dict) else {} require_saved_state_write(write, "code.write routine apply", failures) diff --git a/scripts/smoke_1c_mcp_selector_chain.py b/scripts/smoke_1c_mcp_selector_chain.py index 470af8c..c057340 100644 --- a/scripts/smoke_1c_mcp_selector_chain.py +++ b/scripts/smoke_1c_mcp_selector_chain.py @@ -117,7 +117,11 @@ def first_write_plan_target_from_saved_state(result: dict[str, Any]) -> dict[str if not isinstance(stream, dict): continue target = stream.get("write_plan_target") - if isinstance(target, dict) and target.get("module_ref") and target.get("expected_sha1"): + if not isinstance(target, dict): + continue + has_internal_handle = bool(target.get("module_ref") and target.get("expected_sha1")) + has_public_selector = bool(target.get("ref") and target.get("module")) + if has_internal_handle or has_public_selector: return target return None @@ -791,10 +795,21 @@ def build_live_report( issues.append({"code": "live_composed_write_plan_apply_method_mismatch", "actual": route.get("apply_method")}) if hint.get("ready_for_apply_method") is not True: issues.append({"code": "live_composed_write_plan_hint_not_ready", "hint": hint}) - if hint_payload.get("module_ref") != write_plan_target.get("module_ref"): + if write_plan_target.get("module_ref") and hint_payload.get("module_ref") != write_plan_target.get("module_ref"): issues.append({"code": "live_composed_write_plan_module_ref_mismatch", "expected": write_plan_target.get("module_ref"), "actual": hint_payload.get("module_ref")}) - if hint_payload.get("expected_sha1") != write_plan_target.get("expected_sha1"): + if write_plan_target.get("expected_sha1") and hint_payload.get("expected_sha1") != write_plan_target.get("expected_sha1"): issues.append({"code": "live_composed_write_plan_expected_sha1_mismatch"}) + if write_plan_target.get("ref"): + name_resolution = route.get("name_resolution") if isinstance(route.get("name_resolution"), dict) else {} + resolution_selector = name_resolution.get("selector") if isinstance(name_resolution.get("selector"), dict) else {} + if resolution_selector.get("ref") != write_plan_target.get("ref"): + issues.append( + { + "code": "live_composed_write_plan_name_resolution_mismatch", + "expected": write_plan_target.get("ref"), + "actual": resolution_selector.get("ref"), + } + ) else: steps.append({"name": "metadata.write.plan", "status": "skipped_no_saved_state_target", "from_write_plan_target": False}) else: diff --git a/scripts/verify_1c_adapter_deployment.ps1 b/scripts/verify_1c_adapter_deployment.ps1 index 830a0c4..439e1f1 100644 --- a/scripts/verify_1c_adapter_deployment.ps1 +++ b/scripts/verify_1c_adapter_deployment.ps1 @@ -358,10 +358,10 @@ function Assert-CodeWriteSavedStateReport { if ($report.schema -ne "onec_code_write_saved_state_smoke.v1") { throw "$Label report has unexpected schema '$($report.schema)': $Path" } - if ($RequireWrite -and $report.status -eq "skipped_missing_target") { - throw "$Label report skipped missing target in strict mode: $Path" + if ($RequireWrite -and $report.status -in @("skipped_missing_target", "skipped_write_gate")) { + throw "$Label report skipped required write in strict mode: $Path" } - if ($report.status -notin @("ok", "skipped_missing_target")) { + if ($report.status -notin @("ok", "skipped_missing_target", "skipped_write_gate")) { throw "$Label report has unexpected status '$($report.status)': $Path" } if ($report.status -eq "ok") { @@ -683,7 +683,7 @@ try { "--json" ) if (-not $RequireCodeWriteSavedStateSmoke) { - $codeWriteCommand += "--allow-missing-target" + $codeWriteCommand += @("--allow-missing-target", "--allow-blocked-write-gate") } Invoke-CheckedCommand -Label "REST adapter code.write saved-state smoke ($currentBaseId)" -Command $codeWriteCommand Assert-CodeWriteSavedStateReport -Label "REST adapter code.write saved-state smoke ($currentBaseId)" -Path $codeWriteReport -RequireWrite:$RequireCodeWriteSavedStateSmoke @@ -855,7 +855,7 @@ try { "--json" ) if (-not $RequireCodeWriteSavedStateSmoke) { - $mcpCodeWriteCommand += "--allow-missing-target" + $mcpCodeWriteCommand += @("--allow-missing-target", "--allow-blocked-write-gate") } Invoke-CheckedCommand -Label "MCP proxy code.write saved-state smoke ($currentBaseId)" -Command $mcpCodeWriteCommand Assert-CodeWriteSavedStateReport -Label "MCP proxy code.write saved-state smoke ($currentBaseId)" -Path $mcpCodeWriteReport -RequireWrite:$RequireCodeWriteSavedStateSmoke diff --git a/tests/1c/test_payload_codec.py b/tests/1c/test_payload_codec.py index 45fd6aa..b44ec98 100644 --- a/tests/1c/test_payload_codec.py +++ b/tests/1c/test_payload_codec.py @@ -21449,6 +21449,63 @@ def test_metadata_write_plan_allows_concrete_module_saved_state_reference() -> N assert result["route"]["apply_payload_hint"]["ready_for_apply_method"] is True +def test_metadata_write_plan_resolves_public_module_selector_internally(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_search(payload: dict[str, Any]) -> dict[str, Any]: + seen.update(payload) + return { + "schema": "onec_saved_state_module_search.v1", + "status": "ok", + "modules": [ + { + "owner": {"kind": "Catalog", "name": "Контрагенты"}, + "module": {"kind": "manager_module", "name": "Модуль менеджера"}, + "qualified_name": "Контрагенты.Модуль менеджера", + "payload": {"sha1": "saved-payload-sha1"}, + "streams": [ + { + "module_ref": "ConfigSave:object-guid.3#stream:0", + "module": {"kind": "manager_module", "name": "Модуль менеджера"}, + "qualified_name": "Контрагенты.Модуль менеджера", + } + ], + } + ], + } + + monkeypatch.setattr(adapter_server, "metadata_saved_state_modules_search", fake_search) + + result = adapter_server.metadata_write_plan( + { + "base_id": "upo_test", + "target": { + "kind": "module", + "ref": "Catalog.Контрагенты", + "module": "Модуль менеджера", + "qualified_name": "Контрагенты.Модуль менеджера", + "stream_ordinal": 1, + }, + "intent": { + "operation": "replace_with_control", + "control_fragment": "Процедура Тест()", + "new": "Процедура Тест()\nКонецПроцедуры", + }, + "resolve_origin": False, + } + ) + + assert seen["ref"] == "Catalog.Контрагенты" + assert seen["include_storage"] is True + assert result["allowed"] is True + assert result["route"]["name_resolution"]["status"] == "resolved" + assert result["route"]["name_resolution"]["selector"]["ref"] == "Catalog.Контрагенты" + hint = result["route"]["apply_payload_hint"] + assert hint["ready_for_apply_method"] is True + assert hint["payload"]["module_ref"] == "ConfigSave:object-guid.3#stream:0" + assert hint["payload"]["expected_sha1"] == "saved-payload-sha1" + + def test_metadata_write_preflight_verifies_concrete_saved_state(monkeypatch: pytest.MonkeyPatch) -> None: def fake_diff(payload: dict[str, Any]) -> dict[str, Any]: assert payload["module_ref"] == "ConfigCASSave:object-guid__module-guid.0#stream:4" @@ -24393,6 +24450,14 @@ def test_public_saved_state_module_search_row_is_name_first() -> None: "qualified_name": "test2.t_Форма.Модуль формы", } assert public["streams"][0]["match"] == {"query": "Команда", "in_text": True, "in_name": False} + assert public["streams"][0]["write_plan_target"] == { + "kind": "module", + "ref": "Catalog.test2", + "form": "t_Форма", + "module": "Модуль формы", + "qualified_name": "test2.t_Форма.Модуль формы", + "stream_ordinal": 1, + } for storage_marker in ( "ConfigCASSave", "extension-guid", @@ -24400,7 +24465,6 @@ def test_public_saved_state_module_search_row_is_name_first() -> None: "owner-guid", "module_ref", "file_name", - "write_plan_target", "sha1", ): assert storage_marker not in str(public) diff --git a/tests/1c/test_plan_saved_state_copy.py b/tests/1c/test_plan_saved_state_copy.py new file mode 100644 index 0000000..004d59e --- /dev/null +++ b/tests/1c/test_plan_saved_state_copy.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import Any + +import scripts.plan_1c_saved_state_copy as copy_plan +import scripts.smoke_1c_code_write_saved_state as code_write_smoke + + +def test_resolve_object_uses_name_first_object_get(monkeypatch) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def fake_rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]: + calls.append((method, payload)) + assert method == "metadata.object.get" + return { + "status": "ok", + "object": { + "guid": "40045984-a54c-42bd-8ea8-1c10672f40ec", + "kind": "Catalog", + "name": "АвансовыйОтчетПрисоединенныеФайлы", + }, + } + + monkeypatch.setattr(copy_plan, "rpc", fake_rpc) + selector = { + "base_id": "upo_test", + "ref": "Catalog.АвансовыйОтчетПрисоединенныеФайлы", + "include_storage": True, + "table": "Config", + } + + result = copy_plan.resolve_object("http://adapter.example", selector, "Config", 1) + + assert result["kind"] == "Catalog" + assert result["name"] == "АвансовыйОтчетПрисоединенныеФайлы" + assert calls == [("metadata.object.get", selector)] + + +def test_optional_code_write_smoke_skips_only_policy_gate() -> None: + assert code_write_smoke.is_optional_write_gate_block( + {"status": "blocked", "error": "needs_repository_lock"} + ) + assert not code_write_smoke.is_optional_write_gate_block( + {"status": "blocked", "error": "write_plan_blocked"} + ) + assert not code_write_smoke.is_optional_write_gate_block( + {"status": "error", "error": "needs_repository_lock"} + )