236 lines
15 KiB
Python
236 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import importlib.util
|
|
import hashlib
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "tools/provider-policy-compiler"))
|
|
from provider_policy import DELIVERY_FILES, build_policy, create_delivery_zip, export_package, fingerprint, validate_package, validate_policy
|
|
|
|
spec = importlib.util.spec_from_file_location("pipeline", ROOT / "tools/provider-pipeline/run_provider_pipeline.py")
|
|
pipeline = importlib.util.module_from_spec(spec)
|
|
assert spec and spec.loader
|
|
spec.loader.exec_module(pipeline)
|
|
|
|
|
|
class ProviderPipelineContractTests(unittest.TestCase):
|
|
def test_mapping_catalog_uses_declared_full_universe_and_fails_closed(self):
|
|
sample = {"visual_objects": [{"visual_object_id": "VO-a"}]}
|
|
full = {"visual_objects": [{"visual_object_id": "VO-a"}, {"visual_object_id": "VO-b"}]}
|
|
self.assertIs(pipeline.select_mapping_catalog(sample, None), sample)
|
|
self.assertIs(pipeline.select_mapping_catalog(sample, full), full)
|
|
with self.assertRaisesRegex(ValueError, "未覆盖样本"):
|
|
pipeline.select_mapping_catalog(sample, {"visual_objects": [{"visual_object_id": "VO-b"}]})
|
|
with self.assertRaisesRegex(ValueError, "无效或重复"):
|
|
pipeline.select_mapping_catalog(sample, {"visual_objects": [{"visual_object_id": "VO-a"}] * 2})
|
|
with self.assertRaisesRegex(ValueError, "缺少 visual_objects"):
|
|
pipeline.select_mapping_catalog(sample, {"visual_objects": None})
|
|
|
|
def test_cm95_full_mapping_covers_all_actual_objects_without_reclassifying_sample(self):
|
|
root = ROOT / "tools/visual-object-instantiation/output/cm95-current"
|
|
catalog_file = root / "actual-anonymous-catalog.json"
|
|
assignment_file = ROOT / "tools/style-visual-object/output/cm95-manual-review/assignment/cm95-layer-assignment-v1.json"
|
|
previous_file = ROOT / "dist/cm95/s52-full/1.1.0/mapping-layer-assignment.json"
|
|
if not all(path.exists() for path in (catalog_file, assignment_file, previous_file)):
|
|
self.skipTest("未提供 CM95 已验证的完整 Catalog / 人工审核 / 原 Package")
|
|
full = json.loads(catalog_file.read_text(encoding="utf-8"))
|
|
review = json.loads(assignment_file.read_text(encoding="utf-8"))
|
|
before = json.loads(previous_file.read_text(encoding="utf-8"))["assignments"]
|
|
sample = {"visual_objects": [{"visual_object_id": row["visualObjectId"]} for row in before]}
|
|
selected = pipeline.select_mapping_catalog(sample, full)
|
|
rows = pipeline.manual_assignment(selected, review)
|
|
self.assertEqual(len(rows), len(full["visual_objects"]))
|
|
self.assertEqual(len(rows), 475)
|
|
self.assertEqual(pipeline.attach_render_evidence(rows, full), [])
|
|
by_id = {row["visualObjectId"]: row for row in rows}
|
|
self.assertEqual(len(by_id), 475)
|
|
for row in before:
|
|
current = by_id[row["visualObjectId"]]
|
|
self.assertEqual((current["status"], current.get("standardLayer")),
|
|
(row["status"], row.get("standardLayer")))
|
|
self.assertEqual(sum(row["renderEvidence"]["occurrenceCount"] for row in rows), 976534)
|
|
self.assertTrue(any(row["status"] == "UNKNOWN" for row in rows))
|
|
|
|
def test_v2_full_catalog_evidence_and_fail_closed(self):
|
|
style = {"layers": [{"id": "layer-a", "source-layer": "points"}, {"id": "layer-b", "source-layer": "points"}]}
|
|
components = [
|
|
{"style_layer_id": "layer-a", "style_index": 0, "role": "icon", "resource_key": "ICON", "resource_visual_hash": "a" * 64},
|
|
{"style_layer_id": "layer-b", "style_index": 1, "role": "text", "resource_key": None, "resource_visual_hash": None},
|
|
]
|
|
catalog = {"visual_objects": [{"visual_object_id": "VO-a", "provenance": {"source_layer": "points"}, "geometry_role": "Point", "occurrence_count": 123, "components": components}]}
|
|
rows = [{"visualObjectId": "VO-a", "status": "LAYER_ONLY", "standardLayer": "navigation_marks"}, {"visualObjectId": "VO-missing", "status": "UNKNOWN", "standardLayer": None}]
|
|
self.assertEqual(pipeline.attach_render_evidence(rows, catalog), ["VO-missing"])
|
|
self.assertIsNone(rows[1]["renderEvidence"])
|
|
self.assertEqual([x["role"] for x in rows[0]["renderEvidence"]["components"]], ["icon", "text"])
|
|
self.assertIsNone(rows[0]["renderEvidence"]["components"][1]["resourceKey"])
|
|
self.assertEqual(rows[0]["renderEvidence"]["occurrenceCount"], 123)
|
|
from provider_policy import validate_mapping
|
|
mapping = {"schemaVersion": "navsea-standard-layer-mapping/v2", "renderEvidenceSource": {"schema": "actual-visual-object-catalog/v1"}, "assignments": rows}
|
|
self.assertEqual(validate_mapping(mapping, style), [])
|
|
for key, value, expected in (("styleLayerId", "missing", "styleLayerId:missing"), ("styleIndex", 9, "styleIndex:mismatch")):
|
|
rows[0]["renderEvidence"]["components"][0][key] = value
|
|
self.assertTrue(any(expected in x for x in validate_mapping(mapping, style)))
|
|
rows[0]["renderEvidence"]["components"][0][key] = components[0][{"styleLayerId": "style_layer_id", "styleIndex": "style_index"}[key]]
|
|
rows[0]["renderEvidence"]["sourceLayer"] = "wrong"
|
|
self.assertTrue(any("sourceLayer:mismatch" in x for x in validate_mapping(mapping, style)))
|
|
rows[0]["renderEvidence"]["sourceLayer"] = "points"
|
|
rows[0]["renderEvidence"]["geometryRole"] = "eval(1)"
|
|
self.assertTrue(any("script_forbidden" in x for x in validate_mapping(mapping, style)))
|
|
rows[0]["renderEvidence"]["geometryRole"] = "Point"
|
|
duplicate = json.loads(json.dumps(rows[0]))
|
|
duplicate["renderEvidence"]["occurrenceCount"] = 124
|
|
mapping["assignments"].append(duplicate)
|
|
self.assertTrue(any("nondeterministic_duplicate" in x for x in validate_mapping(mapping, style)))
|
|
|
|
def test_execution_catalog_style_hash_and_schema(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
base = Path(tmp)
|
|
style = base / "style.json"
|
|
style.write_bytes(b"{}")
|
|
catalog = base / "catalog.json"
|
|
catalog.write_text(json.dumps({"schema": "actual-visual-object-catalog/v1", "inputs": {"style": str(style)}, "summary": {}, "visual_objects": []}))
|
|
manifest = base / "manifest.json"
|
|
expected = hashlib.sha256(b"{}").hexdigest()
|
|
self.assertEqual(pipeline.load_execution_catalog(manifest, "catalog.json", expected)[1]["styleSha256"], expected)
|
|
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
|
pipeline.load_execution_catalog(manifest, "catalog.json", "0" * 64)
|
|
|
|
def test_cm95_existing_semantics_and_specific_visual_objects(self):
|
|
base = ROOT / "dist/cm95/s52-full/1.0.0/mapping-layer-assignment.json"
|
|
catalog_file = ROOT / "tools/visual-object-instantiation/output/cm95-current/actual-anonymous-catalog.json"
|
|
if not base.exists() or not catalog_file.exists():
|
|
self.skipTest("本机未提供 CM95 样本与全量 Catalog")
|
|
previous = json.loads(base.read_text())["assignments"]
|
|
rows = json.loads(json.dumps(previous))
|
|
catalog = json.loads(catalog_file.read_text())
|
|
self.assertEqual(len(rows), 102)
|
|
self.assertEqual(pipeline.attach_render_evidence(rows, catalog), [])
|
|
self.assertEqual([(x["visualObjectId"], x["status"], x["standardLayer"]) for x in rows], [(x["visualObjectId"], x["status"], x["standardLayer"]) for x in previous])
|
|
boy = next(x for x in rows if x["visualObjectId"] == "VO-2329594761200ffd")
|
|
self.assertEqual(boy["standardLayer"], "navigation_marks")
|
|
self.assertEqual(boy["renderEvidence"]["sourceLayer"], "s52_points")
|
|
self.assertTrue(any(c["styleLayerId"] == "s52-p08-sy-s52-points" and c["role"] == "icon" and c["resourceKey"] == "BOYCON61" for c in boy["renderEvidence"]["components"]))
|
|
self.assertTrue(any(x["standardLayer"] == "navigation_marks" and len(x["renderEvidence"]["components"]) > 1 and any((c["resourceKey"] or "").startswith("LIGHTS") or c["role"] == "text" for c in x["renderEvidence"]["components"]) for x in rows))
|
|
|
|
def test_generated_mapping_policy_package_and_zip_agree(self):
|
|
base = ROOT / "dist/cm95/s52-full/1.1.0"
|
|
if not base.exists():
|
|
self.skipTest("正式 CM95 1.1.0 Package 尚未生成")
|
|
mapping = json.loads((base / "mapping-layer-assignment.json").read_text())
|
|
policy = json.loads((base / "provider-policy.json").read_text())
|
|
package = json.loads((base / "provider-package.json").read_text())
|
|
self.assertEqual(mapping["schemaVersion"], "navsea-standard-layer-mapping/v2")
|
|
self.assertEqual({k: v for k, v in mapping.items() if k != "source"}, policy["mapping"])
|
|
self.assertEqual(policy["mapping"], package["standardLayerMapping"])
|
|
z = base.parent / "cm95-s52-full-1.1.0.navsea-provider.zip"
|
|
with zipfile.ZipFile(z) as archive:
|
|
self.assertEqual(len(archive.namelist()), 9)
|
|
self.assertIsNone(archive.testzip())
|
|
first = fingerprint(mapping)
|
|
second = fingerprint(json.loads((base / "mapping-layer-assignment.json").read_text()))
|
|
self.assertEqual(first, second)
|
|
|
|
def data(self, provider_id: str) -> dict:
|
|
return {
|
|
"provider": {"providerId": provider_id, "name": provider_id, "version": "1"},
|
|
"dataset": {"datasetId": "dataset-1"},
|
|
"package": {"packageId": f"{provider_id}-package", "version": "1.0.0"},
|
|
"standardCatalog": {"id": "navsea-frozen-standard", "fingerprint": "a" * 64},
|
|
"resources": {"style": {"uri": "https://example.invalid/style.json"}, "pbfSamples": [{"tile": "1/0/0", "sha256": "b" * 64}]},
|
|
"mapping": [{"visualObjectId": "VO-a", "status": "LAYER_ONLY", "standardLayer": "navigation_marks", "candidateStandardLayers": [], "basis": "review"}],
|
|
"display": {}, "pickup": {}, "planning": {}, "danger": {}, "capabilities": {},
|
|
"validationRequirements": {"requireStyle": True, "requirePbfSample": True},
|
|
"unresolved": [], "limitations": [], "provenance": {},
|
|
"fingerprints": {"source": "c" * 64, "style": "d" * 64, "mapping": "e" * 64},
|
|
}
|
|
|
|
def test_fingerprint_is_key_order_independent(self):
|
|
self.assertEqual(fingerprint({"a": 1, "b": 2}), fingerprint({"b": 2, "a": 1}))
|
|
self.assertEqual(fingerprint({"snapshot": "/one", "generatedAt": "now", "sha256": "abc"}), fingerprint({"snapshot": "/two", "generatedAt": "later", "sha256": "abc"}))
|
|
|
|
def test_policy_schema_same_for_navsea_and_cm95(self):
|
|
navsea = build_policy(self.data("navsea"))
|
|
cm95 = build_policy(self.data("cm95"))
|
|
self.assertEqual(navsea["schemaVersion"], cm95["schemaVersion"])
|
|
self.assertEqual(set(navsea), set(cm95))
|
|
self.assertEqual(validate_policy(navsea)["result"], "PASS")
|
|
self.assertEqual(validate_policy(cm95)["result"], "PASS")
|
|
|
|
def test_ambiguous_requires_multiple_candidates(self):
|
|
policy = build_policy(self.data("provider-a"))
|
|
policy["mapping"]["assignments"] = [{"status": "AMBIGUOUS", "standardLayer": None, "candidateStandardLayers": ["one"]}]
|
|
self.assertEqual(validate_policy(policy)["result"], "FAIL")
|
|
|
|
def test_fail_prevents_export(self):
|
|
data = self.data("provider-a")
|
|
policy = build_policy(data)
|
|
package = {"providerPolicy": policy}
|
|
invalid = validate_package(package)
|
|
self.assertEqual(invalid["result"], "FAIL")
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
with self.assertRaises(ValueError):
|
|
export_package(Path(tmp) / "forbidden", package, policy, invalid)
|
|
self.assertFalse((Path(tmp) / "forbidden").exists())
|
|
|
|
def test_export_accepts_only_two_non_fail_results(self):
|
|
data = self.data("provider-a")
|
|
policy = build_policy(data)
|
|
package = {"provider": data["provider"], "dataset": data["dataset"], "package": data["package"], "fingerprints": {}, "limitations": []}
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
with self.assertRaises(ValueError):
|
|
export_package(Path(tmp) / "unknown", package, policy, {"result": "NOT_FAIL"})
|
|
|
|
def test_both_provider_manifests_share_policy_shape(self):
|
|
manifests = [json.loads((ROOT / f"providers/{key}/manifest.json").read_text()) for key in ("navsea", "cm95")]
|
|
self.assertEqual({x["mapping"]["mode"] for x in manifests}, {"frozen-standard-visual", "manual-assignment"})
|
|
for manifest in manifests:
|
|
self.assertTrue(manifest["provider"]["providerId"])
|
|
self.assertIn("style", manifest["resources"])
|
|
self.assertIn("sprite", manifest["resources"])
|
|
|
|
def test_delivery_zip_contains_only_required_navsea_files(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
export_dir = Path(tmp) / "1.0.0"
|
|
export_dir.mkdir()
|
|
for name in DELIVERY_FILES:
|
|
path = export_dir / name
|
|
if name == "validation.json":
|
|
path.write_text(json.dumps({"result": "PASS_WITH_WARNINGS"}), encoding="utf-8")
|
|
elif name.endswith(".png"):
|
|
path.write_bytes(b"png")
|
|
else:
|
|
path.write_text(name, encoding="utf-8")
|
|
(export_dir / "not-for-navsea.tmp").write_text("exclude me", encoding="utf-8")
|
|
info = create_delivery_zip(export_dir, "provider-a", "dataset-1", "1.0.0")
|
|
self.assertEqual(info["validationResult"], "PASS_WITH_WARNINGS")
|
|
self.assertEqual(len(info["sha256"]), 64)
|
|
with zipfile.ZipFile(info["path"], "r") as archive:
|
|
expected = [f"provider-a-dataset-1-1.0.0/{name}" for name in DELIVERY_FILES]
|
|
self.assertEqual(archive.namelist(), expected)
|
|
self.assertNotIn("provider-a-dataset-1-1.0.0/not-for-navsea.tmp", archive.namelist())
|
|
|
|
def test_delivery_zip_fails_when_required_file_is_missing(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
export_dir = Path(tmp) / "1.0.0"
|
|
export_dir.mkdir()
|
|
for name in DELIVERY_FILES:
|
|
if name == "resources-sprite.png":
|
|
continue
|
|
path = export_dir / name
|
|
if name == "validation.json":
|
|
path.write_text(json.dumps({"result": "PASS"}), encoding="utf-8")
|
|
else:
|
|
path.write_text(name, encoding="utf-8")
|
|
with self.assertRaises(ValueError):
|
|
create_delivery_zip(export_dir, "provider-a", "dataset-1", "1.0.0")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|