182 lines
9.3 KiB
Python
182 lines
9.3 KiB
Python
"""Skill authoring tests use the actual catalog, XML validator and compiler."""
|
|
from copy import deepcopy
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPT = ROOT / "skills/system-simulation/scripts/project_builder.py"
|
|
SPEC = importlib.util.spec_from_file_location("skill_project_builder", SCRIPT)
|
|
builder = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(builder)
|
|
PLAN_PATH = ROOT / "skills/system-simulation/assets/closed-tank.plan.json"
|
|
|
|
|
|
class ProjectBuilderTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.plan = json.loads(PLAN_PATH.read_text(encoding="utf-8"))
|
|
|
|
def build(self):
|
|
return builder.build_project(self.plan)[0]
|
|
|
|
def test_current_catalog_v2_and_si_defaults(self):
|
|
original = deepcopy(self.plan)
|
|
project, defaults = builder.build_project(self.plan)
|
|
report, xml = builder.validate_project(project)
|
|
self.assertEqual(self.plan, original)
|
|
self.assertEqual(project["projectSchemaVersion"], 2)
|
|
self.assertEqual(report["componentCount"], 4)
|
|
self.assertEqual(report["compiledComponentCount"], 3)
|
|
self.assertEqual(report["checks"]["simulation"], "notRun")
|
|
self.assertEqual(report["checks"]["browserImport"], "notRun")
|
|
self.assertIn("cvol", defaults["tank"])
|
|
values = builder.semantic_signature(xml)["components"]["tank"]["parameters"]
|
|
self.assertEqual(values["p0"], 250000)
|
|
self.assertEqual(values["T0"], 293.15)
|
|
self.assertEqual(values["cvol"], 0.057)
|
|
self.assertEqual(project["nodes"][1]["data"]["parameters"]["p0"], 2.5)
|
|
project["nodes"][1]["position"]["x"] = 999
|
|
project["simulation"]["t_stop"] = 99
|
|
self.assertEqual(self.plan, original)
|
|
|
|
def test_signal_snapshot_omits_null_and_keeps_registered_version(self):
|
|
self.plan["components"] = [{"id": "step", "modelType": "amesim_step0"}]
|
|
self.plan["connections"] = []
|
|
project = self.build()
|
|
data = project["nodes"][0]["data"]
|
|
self.assertNotIn("positiveFlowDirection", data["ports"][0])
|
|
from app.simulation.registry import get_component_model_spec
|
|
self.assertEqual(data["modelVersion"], get_component_model_spec("amesim_step0").model_version)
|
|
with self.assertRaisesRegex(builder.InputError, "expected one connection"):
|
|
builder.validate_project(project)
|
|
|
|
def test_unknown_or_unit_only_parameters_are_rejected(self):
|
|
for field, value in (("parameters", {"typoPressure": 2}),
|
|
("parameterUnits", {"cvol": "L"})):
|
|
with self.subTest(field=field):
|
|
plan = deepcopy(self.plan)
|
|
plan["components"][1][field] = value
|
|
with self.assertRaises(builder.InputError):
|
|
builder.build_project(plan)
|
|
|
|
def test_invalid_physical_values_units_and_discrete_choices_fail(self):
|
|
for parameters, units in (({"p0": -1}, {}), ({"p0": 2}, {"p0": "kg"}),
|
|
({"gi": "=1"}, {})):
|
|
with self.subTest(parameters=parameters, units=units):
|
|
project = self.build()
|
|
project["nodes"][1]["data"]["parameters"].update(parameters)
|
|
project["nodes"][1]["data"]["parameterUnits"].update(units)
|
|
with self.assertRaises(builder.InputError):
|
|
builder.validate_project(project)
|
|
|
|
def test_missing_medium_reference_is_rejected(self):
|
|
self.plan["components"][1]["parameters"]["gi"] = 2
|
|
with self.assertRaises(builder.InputError):
|
|
builder.validate_project(self.build())
|
|
|
|
def test_duplicate_ids_unconnected_and_invalid_endpoints_fail(self):
|
|
cases = [lambda p: p["nodes"][1].update(id="air"),
|
|
lambda p: p["edges"].pop(),
|
|
lambda p: p["edges"][0].update(sourceHandle="port_99"),
|
|
lambda p: p["edges"][0].update(source="missing")]
|
|
for modify in cases:
|
|
with self.subTest(modify=modify):
|
|
project = self.build()
|
|
modify(project)
|
|
with self.assertRaises(builder.InputError):
|
|
builder.validate_project(project)
|
|
|
|
def test_browser_specific_fields_are_checked_before_backend_defaults(self):
|
|
cases = [lambda p: p["edges"][0]["data"].pop("isContactEdge"),
|
|
lambda p: p["nodes"][1]["data"]["ports"][0].update(positiveFlowDirection=None),
|
|
lambda p: p["nodes"][1].update(type="component"),
|
|
lambda p: p["nodes"][1]["position"].update(x=float("nan")),
|
|
lambda p: p["nodes"][1]["data"].update(mirrored="false")]
|
|
for modify in cases:
|
|
with self.subTest(modify=modify):
|
|
project = self.build()
|
|
modify(project)
|
|
with self.assertRaises(builder.InputError):
|
|
builder.validate_project(project)
|
|
|
|
def test_v1_si_and_v2_selected_units_have_equal_execution_inputs(self):
|
|
current = self.build()
|
|
legacy = deepcopy(current)
|
|
legacy["projectSchemaVersion"] = 1
|
|
legacy["nodes"][1]["data"]["parameters"].update(p0=250000, T0=293.15)
|
|
before = builder.semantic_signature(builder.validate_project(legacy)[1])
|
|
after = builder.semantic_signature(builder.validate_project(current)[1])
|
|
self.assertEqual(before, after)
|
|
# Relabelling v1 as v2 without conversion is a physical change.
|
|
legacy["projectSchemaVersion"] = 2
|
|
self.assertNotEqual(before, builder.semantic_signature(builder.validate_project(legacy)[1]))
|
|
|
|
def test_old_versions_warn_without_mutating_source(self):
|
|
project = self.build()
|
|
project["nodes"][1]["data"]["modelVersion"] = "0.0.0"
|
|
original = deepcopy(project)
|
|
report, _ = builder.validate_project(project)
|
|
self.assertEqual(report["warnings"][0]["code"], "COMPONENT_MODEL_VERSION_WARNING")
|
|
self.assertEqual(project, original)
|
|
|
|
def test_dynamic_ports_keep_full_snapshot_but_only_check_active_ports(self):
|
|
self.plan["components"] = [
|
|
{"id": "junction", "modelType": "amesim_lmechn1", "parameters": {"v1": 1}},
|
|
{"id": "mass", "modelType": "amesim_mecmas21"},
|
|
]
|
|
self.plan["connections"] = [
|
|
{"id": f"e{i}", "source": {"component": "junction", "port": f"port_{i}"},
|
|
"target": {"component": "mass", "port": f"port_{i}"}} for i in (1, 2)
|
|
]
|
|
project = self.build()
|
|
self.assertEqual(len(project["nodes"][0]["data"]["ports"]), 21)
|
|
report, _ = builder.validate_project(project)
|
|
self.assertEqual(report["checks"]["activePorts"], "passed")
|
|
self.assertEqual(report["checks"]["networkCompile"], "passed")
|
|
|
|
def test_compare_ignores_layout_but_detects_changed_physics(self):
|
|
project = self.build()
|
|
before = builder.semantic_signature(builder.validate_project(project)[1])
|
|
project["nodes"][1]["position"]["x"] = 900
|
|
project["nodes"][1]["data"]["label"] = "Different label"
|
|
self.assertEqual(before, builder.semantic_signature(builder.validate_project(project)[1]))
|
|
project["nodes"][1]["data"]["parameters"]["p0"] = 3
|
|
self.assertNotEqual(before, builder.semantic_signature(builder.validate_project(project)[1]))
|
|
|
|
def test_cli_writes_only_valid_new_files_and_compare_detects_changes(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
output = Path(directory) / "project.json"
|
|
with patch.object(builder, "emit_json") as emit:
|
|
self.assertEqual(builder.main(["build", str(PLAN_PATH), "--output", str(output)]), 0)
|
|
self.assertEqual(emit.call_args.args[0]["checks"]["xmlV3"], "passed")
|
|
data = output.read_bytes()
|
|
self.assertEqual(builder.main(["build", str(PLAN_PATH), "--output", str(output)]), 2)
|
|
self.assertEqual(output.read_bytes(), data)
|
|
self.assertEqual(builder.main(["compare", str(output), str(output)]), 0)
|
|
changed = json.loads(data)
|
|
changed["nodes"][1]["data"]["parameters"]["p0"] = 3
|
|
other = Path(directory) / "changed.json"
|
|
other.write_text(json.dumps(changed), encoding="utf-8")
|
|
self.assertEqual(builder.main(["compare", str(output), str(other)]), 2)
|
|
invalid = deepcopy(self.plan)
|
|
invalid["connections"] = []
|
|
source = Path(directory) / "invalid-plan.json"
|
|
source.write_text(json.dumps(invalid), encoding="utf-8")
|
|
rejected = Path(directory) / "must-not-exist.json"
|
|
self.assertEqual(builder.main(["build", str(source), "--output", str(rejected)]), 2)
|
|
self.assertFalse(rejected.exists())
|
|
|
|
def test_catalog_filter_exposes_real_details_without_experimental_models(self):
|
|
with patch.object(builder, "emit_json") as emit:
|
|
self.assertEqual(builder.main(["catalog", "--model", "amesim_pnch023"]), 0)
|
|
self.assertEqual(emit.call_args.args[0]["models"][0]["modelType"], "amesim_pnch023")
|
|
self.assertIn("parameters", emit.call_args.args[0]["models"][0])
|
|
self.assertEqual(builder.main(["catalog", "--model", "not-a-model"]), 2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|