aps-agent/tests/golden/test_kangni_folder_schedule.py

294 lines
10 KiB
Python
Raw Normal View History

from __future__ import annotations
import copy
import hashlib
import json
from pathlib import Path
from typing import Any
import pytest
from server.agent_core import harness
from server.agent_core.approval_store import ApprovalStore
from server.aps_domain.folder_pack import folder_schedule_payload_digest
from server.auth.context import IdentityContext
from tests.golden.test_folder_schedule_security import (
_CheckpointStore,
_ProjectStore,
_Store,
_approve,
_stage,
)
KANGNI_DATA_DIR = Path(r"D:\ItemSpace\14.工业智核\康尼\数据")
KANGNI_WORKBOOKS = (
"订单.xlsx",
"工艺路线.xlsx",
"工时.xlsx",
"BOM.xlsx",
"设备.xlsx",
"模具.xlsx",
"物料.xlsx",
"设备能力映射模板.xlsx",
"模具适配映射模板.xlsx",
)
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _hashes(root: Path) -> dict[str, str]:
return {name: _sha256(root / name) for name in KANGNI_WORKBOOKS}
@pytest.fixture
def kangni_folder_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
missing = [name for name in KANGNI_WORKBOOKS if not (KANGNI_DATA_DIR / name).is_file()]
if missing:
pytest.skip(f"康尼现场只读数据不完整:{missing}")
source_hashes = _hashes(KANGNI_DATA_DIR)
upload_dir = tmp_path / "uploaded"
upload_dir.mkdir()
for name in KANGNI_WORKBOOKS:
(upload_dir / name).write_bytes((KANGNI_DATA_DIR / name).read_bytes())
tenant = "tenant-kangni-folder"
identity = IdentityContext(
user_id=2101,
username="planner",
fullname="Planner",
tenant_uuid=tenant,
roles=("planner", "approver", "admin"),
)
project_store = _ProjectStore(upload_dir)
checkpoints = _CheckpointStore()
store = _Store(tenant)
approval_store = ApprovalStore(str(tmp_path / "approvals-kangni.json"))
original_approval_store = harness._approval_store
harness.configure_approval_store(store=approval_store)
import server.aps_domain.workflow as workflow_module
from server.agent_core import plan_orchestration
monkeypatch.setattr("server.state.projects.get_project_store", lambda: project_store)
monkeypatch.setattr(workflow_module, "get_checkpoints", lambda: checkpoints)
monkeypatch.setattr(plan_orchestration, "stage_plan_node", lambda **_kwargs: None)
monkeypatch.setattr(plan_orchestration, "decide_plan_node", lambda **_kwargs: None)
try:
yield {
"store": store,
"identity": identity,
"approvals": approval_store,
"checkpoints": checkpoints,
"uploadDir": upload_dir,
"sourceHashes": source_hashes,
}
finally:
harness.configure_approval_store(store=original_approval_store)
def test_kangni_folder_stage_materializes_frozen_bytes_and_binds_payload(
kangni_folder_env: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
):
from server.aps_domain import kangni_intake
store = kangni_folder_env["store"]
identity = kangni_folder_env["identity"]
approvals = kangni_folder_env["approvals"]
upload_dir = kangni_folder_env["uploadDir"]
frozen = {name: (upload_dir / name).read_bytes() for name in KANGNI_WORKBOOKS}
observed: dict[str, Any] = {}
original_builder = kangni_intake.build_site_payload_from_data_dir
def inspect_builder(data_dir, **kwargs):
materialized = Path(data_dir)
observed["root"] = str(materialized)
observed["files"] = {
path.name: path.read_bytes()
for path in sorted(materialized.glob("*.xlsx"))
}
payload = original_builder(data_dir, **kwargs)
observed["payload"] = copy.deepcopy(payload)
return payload
monkeypatch.setattr(kangni_intake, "build_site_payload_from_data_dir", inspect_builder)
before = harness.world_fingerprint(store.data)
confirm_id, reply = _stage(store, identity)
assert harness.world_fingerprint(store.data) == before
assert observed["files"] == frozen
assert Path(observed["root"]).resolve() != upload_dir.resolve()
assert not Path(observed["root"]).exists()
params = approvals.pending[confirm_id]["params"]
assert params["kangniDetected"] is True
assert params["trialReady"] is True
assert params["productionReady"] is False
assert params["kangniPayload"] == observed["payload"]
assert params["kangniMeta"] == params["kangniPayload"]["meta"]
assert params["folderPayloadDigest"] == folder_schedule_payload_digest(params)
assert len(params["sourceManifest"]) == len(KANGNI_WORKBOOKS)
encoded = json.dumps(
params["kangniPayload"],
ensure_ascii=False,
sort_keys=True,
allow_nan=False,
)
assert str(upload_dir) not in encoded
assert observed["root"] not in encoded
assert not store.data.get("flexScheduleVersions")
folder_block = next(block for block in reply.blocks if block.type == "folder-pack")
assert folder_block.props["kangniDetected"] is True
assert folder_block.props["trialReady"] is True
assert "仅试排" in reply.text
assert "共享占位" in reply.text
assert "不得生产发布" in reply.text
assert _hashes(KANGNI_DATA_DIR) == kangni_folder_env["sourceHashes"]
assert _hashes(upload_dir) == kangni_folder_env["sourceHashes"]
def test_kangni_folder_payload_tamper_is_denied(kangni_folder_env: dict[str, Any]):
store = kangni_folder_env["store"]
identity = kangni_folder_env["identity"]
approvals = kangni_folder_env["approvals"]
confirm_id, _reply = _stage(store, identity)
before = harness.world_fingerprint(store.data)
payload = approvals.pending[confirm_id]["params"]["kangniPayload"]
payload["flex"]["flexOrders"][0]["quantity"] += 1
approvals.save()
message = _approve(store, identity, confirm_id)
assert "证据校验未通过" in message
assert harness.world_fingerprint(store.data) == before
assert not store.data.get("flexScheduleVersions")
def test_kangni_folder_approval_runs_trial_without_mes(kangni_folder_env: dict[str, Any], monkeypatch):
from server.aps_domain import mes
store = kangni_folder_env["store"]
identity = kangni_folder_env["identity"]
def fail_mes(*_args, **_kwargs):
raise AssertionError("folder.schedule must not dispatch MES")
monkeypatch.setattr(mes, "apply_dispatch", fail_mes)
confirm_id, _reply = _stage(store, identity)
message = _approve(store, identity, confirm_id)
assert len(store.data.get("salesOrders") or []) == 10
assert len(store.data.get("flexOrders") or []) == 10
assert len(store.data.get("flexRoutings") or []) == 72
assert len(store.data.get("flexEquipment") or []) == 41
assert len(store.data.get("flexMolds") or []) == 40
version = store.data["flexScheduleVersions"][-1]
version_id = version["id"]
assert len([row for row in store.data["flexVirtualLines"] if row["versionId"] == version_id]) == 10
assert len([row for row in store.data["flexWorkOrders"] if row["versionId"] == version_id]) == 72
assert version["trialOnly"] is True
assert version["productionReady"] is False
assert version["resourceQuality"]["sharedPlaceholder"] is True
assert "仅试排" in message
assert "共享占位" in message
assert "不得生产发布" in message
actions = {str(row.get("action") or "") for row in store.data.get("auditEvents") or []}
assert "mes.dispatch" not in actions
assert "schedule.publish" not in actions
assert _hashes(KANGNI_DATA_DIR) == kangni_folder_env["sourceHashes"]
def test_kangni_folder_commit_syncs_counters_for_followup_trial(
kangni_folder_env: dict[str, Any],
):
from server.aps_domain.flex import run_flex_schedule
store = kangni_folder_env["store"]
identity = kangni_folder_env["identity"]
confirm_id, _reply = _stage(store, identity)
message = _approve(store, identity, confirm_id)
assert "完成本地试排" in message
first_version_id = store.data["flexScheduleVersions"][-1]["id"]
second = run_flex_schedule(
store,
sort_mode="BOTTLENECK",
actor="planner",
trial=True,
)
assert second["versionId"] != first_version_id
assert second["versionId"] > first_version_id
for table in (
"flexScheduleVersions",
"flexVirtualLines",
"flexWorkOrders",
"flexConflicts",
):
ids = [row["id"] for row in store.data.get(table) or []]
assert len(ids) == len(set(ids)), f"{table} contains duplicate ids"
def test_kangni_folder_save_failure_restores_schedule_counters(
kangni_folder_env: dict[str, Any],
):
store = kangni_folder_env["store"]
identity = kangni_folder_env["identity"]
confirm_id, _reply = _stage(store, identity)
previous_ids = copy.deepcopy(store._ids)
before = harness.world_fingerprint(store.data)
store.fail_next_save = True
message = _approve(store, identity, confirm_id)
assert "落盘失败" in message and "恢复原业务状态" in message
assert harness.world_fingerprint(store.data) == before
for kind in (
"flexScheduleVersion",
"flexVirtualLine",
"flexWorkOrder",
"flexConflict",
):
assert store._ids.get(kind) == previous_ids.get(kind)
@pytest.mark.parametrize("failure", ["apply", "schedule", "save"])
def test_kangni_folder_execution_failures_discard_candidate(
kangni_folder_env: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
failure: str,
):
store = kangni_folder_env["store"]
identity = kangni_folder_env["identity"]
confirm_id, _reply = _stage(store, identity)
before = harness.world_fingerprint(store.data)
if failure == "apply":
def fail_apply(*_args, **_kwargs):
raise RuntimeError("injected Kangni payload apply failure")
monkeypatch.setattr(
"server.aps_domain.kangni_intake.apply_site_payload_to_world",
fail_apply,
)
elif failure == "schedule":
def fail_schedule(*_args, **_kwargs):
raise RuntimeError("injected Kangni trial schedule failure")
monkeypatch.setattr("server.aps_domain.flex.run_flex_schedule", fail_schedule)
else:
store.fail_next_save = True
message = _approve(store, identity, confirm_id)
assert "失败" in message and ("回滚" in message or "恢复" in message)
assert harness.world_fingerprint(store.data) == before
assert not store.data.get("flexScheduleVersions")
assert store.data["auditEvents"][-1]["result"] == "DENIED"
assert _hashes(KANGNI_DATA_DIR) == kangni_folder_env["sourceHashes"]