aps-agent/tests/golden/test_workbook_profiles.py

251 lines
12 KiB
Python
Raw Normal View History

"""The same canonical pipeline must work through independent JSON mappings."""
from __future__ import annotations
import copy
import io
import json
import openpyxl
import pytest
from server.aps_domain.importers import apply_import_commit, preview_file
from server.importers.workbook_profiles import (
BUILTIN_PROFILE_DIR,
builtin_compatibility_profile,
has_adoption_flow,
is_profile_supported,
load_profiles,
normalize_resource_kind,
)
from server.state.seed import empty_world
@pytest.fixture(scope="module")
def original_bytes():
from tests.workbook_acceptance import source_for_test
return source_for_test().read_bytes()
def _config():
profile = copy.deepcopy(builtin_compatibility_profile())
profile.pop("profileDigest")
return profile
def _write(directory, config, filename="adapter.json"):
directory.mkdir(exist_ok=True)
destination = directory / filename
destination.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8")
return destination
def _preview(raw):
return preview_file("any-name.xlsx", raw, empty_world())
def _alternative(raw, config):
"""Change every physical table/column plus vocabularies and business codes."""
profile = copy.deepcopy(config)
profile["id"] = "independent-factory-layout-v2"
profile["compatibilityDefault"] = False
profile["planning"]["timeZone"] = "Europe/Berlin"
profile["planning"]["skillLevelOrder"] = ["trainee", "qualified", "senior", "expert"]
workbook = openpyxl.load_workbook(io.BytesIO(raw))
code_map = {}
# All references are rewritten by exact source values, including sandbox
# metadata; no special customer order/equipment codes occur in this test.
for role, key in (("equipment", "code"), ("orders", "orderNo")):
spec = config["sheets"][role]
sheet = workbook[spec["name"]]
column = list(spec["columns"].keys()).index(key) + 1
for index, cells in enumerate(sheet.iter_rows(min_row=2), 1):
code_map[cells[column - 1].value] = f"{role}-ALT-{index}"
enum_raw_map = {}
for kind, aliases in profile["enums"].items():
transformed = {}
for index, (raw_value, canonical) in enumerate(aliases.items()):
alias = f"value_{kind}_{index}"
enum_raw_map[(kind, raw_value)] = alias
transformed[alias] = canonical
profile["enums"][kind] = transformed
enum_fields = {("factoryResources", "resourceKind"): "resourceKind", ("factoryResources", "status"): "resourceStatus", ("equipment", "status"): "equipmentStatus",
("products", "type"): "materialType", ("materials", "type"): "materialType",
("products", "sourcingType"): "sourcingType", ("materials", "sourcingType"): "sourcingType",
("bom", "isKey"): "yesNo", ("routing", "isExternal"): "yesNo", ("partners", "partnerType"): "partnerType",
("orders", "orderType"): "orderType", ("orders", "status"): "orderStatus", ("wip", "status"): "wipStatus"}
for role_index, (role, original_spec) in enumerate(config["sheets"].items()):
sheet = workbook[original_spec["name"]]
fields = list(original_spec["columns"])
metadata = profile["metadataKeys"].get(role)
for cells in sheet.iter_rows(min_row=2):
original_values = {field: cells[index].value for index, field in enumerate(fields)}
for index, field in enumerate(fields):
value = cells[index].value
if value in code_map:
cells[index].value = code_map[value]
enum = enum_fields.get((role, field))
if enum and (enum, value) in enum_raw_map:
cells[index].value = enum_raw_map[(enum, value)]
if field == "sourceText" and isinstance(value, str):
for marker in config["provenance"]["demoMarkers"]:
cells[index].value = value.replace(marker, "SYNTHETIC")
if role == "personnel" and field == "skillLevel":
cells[index].value = profile["planning"]["skillLevelOrder"][config["planning"]["skillLevelOrder"].index(value)]
if role == "calendar" and field == "eventCode" and ("calendarEvent", value) in enum_raw_map:
cells[index].value = enum_raw_map[("calendarEvent", value)]
if role == "calendar" and field == "statusOrReason" and ("enabled", value) in enum_raw_map:
cells[index].value = enum_raw_map[("enabled", value)]
if metadata and field == "key" and value in metadata:
cells[index].value = "meta_" + metadata[value]
if (role == "planningParameters" and field in ("delivery", "bottleneck")
and config["metadataKeys"][role].get(original_values["key"]) == "nightShiftEnabled"):
cells[index].value = enum_raw_map[("enabled", value)]
spec = profile["sheets"][role]
spec["name"] = f"Data_{role_index}"
sheet.title = spec["name"]
for index, field in enumerate(fields):
spec["columns"][field] = f"Column_{role_index}_{index}"
sheet.cell(1, index + 1).value = spec["columns"][field]
if metadata:
profile["metadataKeys"][role] = {"meta_" + canonical: canonical for canonical in metadata.values()}
profile["provenance"]["demoMarkers"] = ["SYNTHETIC"]
stream = io.BytesIO()
workbook.save(stream)
workbook.close()
return stream.getvalue(), profile, code_map
def test_alternative_mapping_changes_all_layout_names_vocabularies_and_identifiers(original_bytes, tmp_path, monkeypatch):
original = _preview(original_bytes)
raw, config, mapping = _alternative(original_bytes, _config())
_write(tmp_path, config)
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path))
preview = _preview(raw)
assert preview["profile"] == config["id"]
assert preview["canCommit"], preview["diagnostics"]
assert preview["entityCounts"] == original["entityCounts"]
assert {b["physicalSheet"] for b in preview["batches"]} == {s["name"] for s in config["sheets"].values()}
assert is_profile_supported(config["id"]) and has_adoption_flow(config["id"])
assert not is_profile_supported(original["profile"])
world = empty_world()
apply_import_commit(world, lambda kind: 1, preview["batches"])
assert len(world["flexMaterials"]) == 45 and len(world["flexRoutings"]) == 15
assert len(world["flexOrders"]) == 5 and len(world["flexSandboxOrders"]) == 1
assert all(row["orderNo"].startswith("orders-ALT-") for row in world["flexOrders"])
assert all(row["code"].startswith("equipment-ALT-") for row in world["flexEquipment"])
assert world["flexWip"][0]["equipmentCode"] in mapping.values()
assert sum(m.get("inTransit", 0) for m in world["flexMaterials"]) == 420
assert sum(row["isKey"] for row in world["flexBom"]) == 12
assert {r["stdTimeSource"] for r in world["flexRoutings"]} == {"demo"}
assert world["planningContext"]["timeZone"] == "Europe/Berlin"
assert world["planningContext"]["skillLevelOrder"] == config["planning"]["skillLevelOrder"]
assert world["planningContext"]["defaultSortMode"] == "ASC"
assert {r["resourceKind"] for r in world["flexFactoryResources"]} == {"FACTORY", "WORKSHOP", "ZONE", "OPERATION"}
assert world["intakeSources"][0]["profileDigest"] == preview["profileDigest"]
before = copy.deepcopy(world)
assert apply_import_commit(world, lambda kind: 1, preview["batches"])["unchanged"]
assert world == before
@pytest.mark.parametrize("mutate", [
lambda p: p["sheets"]["orders"]["columns"].pop("orderNo"),
lambda p: p["sheets"].__setitem__("unknown", {}),
lambda p: p["sheets"]["orders"].__setitem__("name", p["sheets"]["equipment"]["name"]),
lambda p: p["sheets"]["orders"]["columns"].__setitem__("orderNo", p["sheets"]["orders"]["columns"]["productCode"]),
lambda p: p["enums"]["orderType"].__setitem__("other", "AUTOMATIC_RELEASE"),
lambda p: p["planning"].__setitem__("trialOnly", False),
lambda p: p["planning"].__setitem__("timeZone", "Unknown/Location"),
lambda p: p["planning"].__setitem__("skillLevelOrder", ["same", "same"]),
lambda p: p["metadataKeys"]["sourceNotes"].__setitem__("duplicate", "dataDate"),
lambda p: p.__setitem__("loader", "arbitrary.module:function"),
lambda p: p["sheets"]["orders"]["requiredColumns"].append("unregisteredField"),
lambda p: p["sheets"]["orders"].__setitem__("requiredColumns", "orderNo"),
])
def test_invalid_configuration_is_rejected(mutate, tmp_path, monkeypatch):
config = _config()
mutate(config)
_write(tmp_path, config)
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path))
with pytest.raises(ValueError):
load_profiles()
def test_duplicate_json_keys_duplicate_ids_and_ambiguous_profiles_fail_closed(original_bytes, tmp_path, monkeypatch):
config = _config()
target = _write(tmp_path, config)
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path))
target.write_text('{"id":"one", "id":"two"}', encoding="utf-8")
with pytest.raises(ValueError, match="重复"):
load_profiles()
_write(tmp_path, config)
_write(tmp_path, config, "another.json")
with pytest.raises(ValueError, match="id重复"):
load_profiles()
config["id"] = "same-layout-another-id"
_write(tmp_path, config, "another.json")
with pytest.raises(ValueError, match="多个配置"):
_preview(original_bytes)
def test_config_version_is_frozen_and_adopted_identity_includes_mapping(original_bytes, tmp_path, monkeypatch):
config = _config()
_write(tmp_path, config)
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path))
preview = _preview(original_bytes)
world = empty_world()
apply_import_commit(world, lambda kind: 1, preview["batches"])
before = copy.deepcopy(world)
config["planning"]["timeZone"] = "Europe/Berlin"
_write(tmp_path, config)
with pytest.raises(ValueError, match="配置已变化"):
apply_import_commit(world, lambda kind: 1, preview["batches"])
assert world == before
fresh = _preview(original_bytes)
assert fresh["source"]["sha256"] == preview["source"]["sha256"]
assert fresh["profileDigest"] != preview["profileDigest"]
with pytest.raises(ValueError, match="映射版本|配置已变化"):
apply_import_commit(world, lambda kind: 1, fresh["batches"])
assert world == before
def test_unknown_profile_cannot_bypass_generic_commit_and_helpers_are_safe(original_bytes):
assert has_adoption_flow(None) is False and has_adoption_flow("unregistered") is False
assert is_profile_supported(None) is False
assert normalize_resource_kind("unknown", None) is None
assert normalize_resource_kind("unknown", "unregistered") is None
assert normalize_resource_kind("FACTORY", None) == "FACTORY"
preview = _preview(original_bytes)
for batch in preview["batches"]:
batch["sourceProfile"] = "unregistered"
world = empty_world()
before = copy.deepcopy(world)
with pytest.raises(ValueError, match="未注册"):
apply_import_commit(world, lambda kind: 1, preview["batches"])
assert world == before
def test_missing_profile_directory_is_not_silently_defaulted(tmp_path, monkeypatch):
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path / "absent"))
with pytest.raises(ValueError, match="目录不存在"):
load_profiles()
assert BUILTIN_PROFILE_DIR.is_dir()
def test_required_columns_are_optional_and_absent_means_all_optional(tmp_path, monkeypatch):
omitted = _config()
for spec in omitted["sheets"].values():
spec.pop("requiredColumns")
declared = _config()
for spec in declared["sheets"].values():
spec["requiredColumns"] = []
_write(tmp_path / "omitted", omitted)
_write(tmp_path / "declared", declared)
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path / "omitted"))
defaulted = load_profiles()[omitted["id"]]
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path / "declared"))
explicit = load_profiles()[declared["id"]]
assert all(spec["requiredColumns"] == [] for spec in defaulted["sheets"].values())
assert defaulted["profileDigest"] == explicit["profileDigest"]