1069 lines
40 KiB
Python
1069 lines
40 KiB
Python
"""Standard-library tests for the system-simulation skill helper.
|
|
|
|
These tests deliberately mock only the HTTP transport boundary. Parsing,
|
|
normalization, simulation event monitoring, artifact creation, and command
|
|
validation therefore run through the same code used by the skill.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPT_PATH = (
|
|
REPOSITORY_ROOT
|
|
/ "skills"
|
|
/ "system-simulation"
|
|
/ "scripts"
|
|
/ "simulation_skill.py"
|
|
)
|
|
MODULE_NAME = "system_simulation_skill_under_test"
|
|
MODULE_SPEC = importlib.util.spec_from_file_location(MODULE_NAME, SCRIPT_PATH)
|
|
if MODULE_SPEC is None or MODULE_SPEC.loader is None: # pragma: no cover
|
|
raise RuntimeError(f"Cannot load skill helper from {SCRIPT_PATH}")
|
|
skill = importlib.util.module_from_spec(MODULE_SPEC)
|
|
sys.modules[MODULE_NAME] = skill
|
|
MODULE_SPEC.loader.exec_module(skill)
|
|
|
|
|
|
BASE_URL = "http://127.0.0.1:8000"
|
|
RESULT_VARIABLES = [
|
|
{
|
|
"key": "mass.position",
|
|
"componentId": "mass",
|
|
"name": "position",
|
|
"label": "Position",
|
|
"quantity": "displacement",
|
|
"unit": "m",
|
|
},
|
|
{
|
|
"key": "mass.velocity",
|
|
"componentId": "mass",
|
|
"name": "velocity",
|
|
"label": "Velocity",
|
|
"quantity": "velocity",
|
|
"unit": "m/s",
|
|
},
|
|
]
|
|
COMPILED_MODEL = {
|
|
"validation": {"valid": True, "issues": []},
|
|
"components": [
|
|
{
|
|
"id": "mass",
|
|
"type": "MECMAS21",
|
|
"resultVariables": RESULT_VARIABLES,
|
|
}
|
|
],
|
|
"connections": [{"id": "connection-1"}],
|
|
"unconnectedPorts": [],
|
|
}
|
|
PROJECT = {
|
|
"projectSchemaVersion": 1,
|
|
"name": "json-system",
|
|
"nodes": [
|
|
{"id": "mass", "type": "component", "data": {"componentType": "MECMAS21"}}
|
|
],
|
|
"edges": [{"id": "connection-1", "source": "mass", "target": "mass"}],
|
|
"simulation": {"startTime": 0.0, "endTime": 1.0},
|
|
}
|
|
SYSTEM_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
|
|
<System name="xml-system" schemaVersion="3">
|
|
<Simulation startTime="0" endTime="1" />
|
|
</System>
|
|
"""
|
|
|
|
|
|
def json_bytes(value: object) -> bytes:
|
|
return json.dumps(value, ensure_ascii=False).encode("utf-8")
|
|
|
|
|
|
def compile_http_response(
|
|
_base_url: str,
|
|
path: str,
|
|
**_kwargs: object,
|
|
) -> tuple[bytes, dict[str, str]]:
|
|
if path == "/api/reactflow/compile-model":
|
|
return json_bytes(COMPILED_MODEL), {}
|
|
raise AssertionError(f"Unexpected HTTP request: {path}")
|
|
|
|
|
|
class FileRecognitionTests(unittest.TestCase):
|
|
def test_detects_content_format_independently_of_extension(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "project.xml"
|
|
path.write_text(json.dumps(PROJECT), encoding="utf-8")
|
|
|
|
source = skill.load_source(str(path), "auto")
|
|
|
|
self.assertEqual(source.format, "json")
|
|
self.assertEqual(source.parsed, PROJECT)
|
|
with self.assertRaises(skill.InputError) as caught:
|
|
skill.load_source(str(path), "xml")
|
|
self.assertEqual(caught.exception.code, "INPUT_FORMAT_MISMATCH")
|
|
|
|
def test_rejects_duplicate_json_keys(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "duplicate.json"
|
|
path.write_text('{"name":"first","name":"second"}', encoding="utf-8")
|
|
|
|
with self.assertRaises(skill.InputError) as caught:
|
|
skill.load_source(str(path))
|
|
|
|
self.assertEqual(caught.exception.code, "JSON_SYNTAX_INVALID")
|
|
self.assertIn("duplicate", caught.exception.message.lower())
|
|
|
|
def test_rejects_xml_doctype_before_parsing(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "unsafe.xml"
|
|
path.write_bytes(
|
|
b'<?xml version="1.0"?><!DOCTYPE System [<!ENTITY xxe SYSTEM "file:///x">]>'
|
|
b"<System>&xxe;</System>"
|
|
)
|
|
|
|
with self.assertRaises(skill.InputError) as caught:
|
|
skill.load_source(str(path))
|
|
|
|
self.assertEqual(
|
|
caught.exception.code,
|
|
"XML_EXTERNAL_DECLARATION_FORBIDDEN",
|
|
)
|
|
|
|
def test_rejects_non_loopback_backend_url(self) -> None:
|
|
with self.assertRaises(skill.InputError) as caught:
|
|
skill.validate_base_url("https://simulation.example.com")
|
|
|
|
self.assertEqual(caught.exception.code, "REMOTE_BACKEND_FORBIDDEN")
|
|
self.assertEqual(skill.validate_base_url("http://localhost:8000/"), "http://localhost:8000")
|
|
|
|
def test_timeout_must_remain_above_backend_heartbeat(self) -> None:
|
|
with self.assertRaises(argparse.ArgumentTypeError):
|
|
skill.positive_timeout("5")
|
|
self.assertEqual(skill.positive_timeout("10"), 10.0)
|
|
|
|
def test_inspect_parser_exposes_bounded_paging_defaults(self) -> None:
|
|
args = skill.build_parser().parse_args(["inspect", "model.xml"])
|
|
|
|
self.assertEqual(args.variable_limit, 20)
|
|
self.assertEqual(args.component_limit, 50)
|
|
self.assertEqual(args.connection_limit, 25)
|
|
with mock.patch("sys.stderr", new=io.StringIO()), self.assertRaises(SystemExit):
|
|
skill.build_parser().parse_args(
|
|
["inspect", "model.xml", "--component-limit", "201"]
|
|
)
|
|
|
|
def test_json_output_falls_back_to_utf8_on_legacy_console(self) -> None:
|
|
class LegacyConsole:
|
|
def __init__(self) -> None:
|
|
self.buffer = io.BytesIO()
|
|
|
|
def write(self, value: str) -> None:
|
|
raise UnicodeEncodeError("gbk", value, 0, 1, "unsupported")
|
|
|
|
def flush(self) -> None:
|
|
return None
|
|
|
|
console = LegacyConsole()
|
|
skill.emit_json({"unit": "kg/m³"}, stream=console)
|
|
self.assertEqual(
|
|
json.loads(console.buffer.getvalue().decode("utf-8")),
|
|
{"unit": "kg/m³"},
|
|
)
|
|
|
|
|
|
class InspectionTests(unittest.TestCase):
|
|
def test_inspect_json_returns_system_summary(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "project.json"
|
|
path.write_text(json.dumps(PROJECT), encoding="utf-8")
|
|
source = skill.load_source(str(path))
|
|
|
|
with mock.patch.object(
|
|
skill,
|
|
"http_request",
|
|
side_effect=compile_http_response,
|
|
):
|
|
report = skill.inspect_source(source, base_url=BASE_URL, timeout=1.0)
|
|
|
|
self.assertTrue(report["ok"])
|
|
self.assertEqual(report["source"]["format"], "json")
|
|
self.assertEqual(report["system"]["name"], "json-system")
|
|
self.assertEqual(report["system"]["formatVersion"], 1)
|
|
self.assertEqual(report["system"]["componentCount"], 1)
|
|
self.assertEqual(report["system"]["connectionCount"], 1)
|
|
self.assertEqual(
|
|
[variable["key"] for variable in report["system"]["resultVariables"]],
|
|
["mass.position", "mass.velocity"],
|
|
)
|
|
|
|
def test_inspect_xml_returns_backend_and_document_summary(self) -> None:
|
|
parsed_model = {
|
|
"name": "xml-system",
|
|
"components": [{"id": "mass"}, {"id": "fixed"}],
|
|
"connections": [{"id": "connection-1"}],
|
|
"simulation": {"startTime": 0.0, "endTime": 1.0},
|
|
}
|
|
|
|
def xml_http_response(
|
|
_base_url: str,
|
|
path: str,
|
|
**_kwargs: object,
|
|
) -> tuple[bytes, dict[str, str]]:
|
|
if path == "/api/system-xml/parse":
|
|
return json_bytes({"model": parsed_model}), {}
|
|
if path == "/api/system-xml/compile-model":
|
|
return json_bytes(COMPILED_MODEL), {}
|
|
raise AssertionError(f"Unexpected HTTP request: {path}")
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "system.xml"
|
|
path.write_bytes(SYSTEM_XML)
|
|
source = skill.load_source(str(path))
|
|
|
|
with mock.patch.object(
|
|
skill,
|
|
"http_request",
|
|
side_effect=xml_http_response,
|
|
) as request:
|
|
report = skill.inspect_source(source, base_url=BASE_URL, timeout=1.0)
|
|
|
|
self.assertEqual(request.call_count, 2)
|
|
self.assertEqual(report["source"]["format"], "xml")
|
|
self.assertEqual(report["system"]["name"], "xml-system")
|
|
self.assertEqual(report["system"]["formatVersion"], "3")
|
|
self.assertEqual(report["system"]["componentCount"], 2)
|
|
self.assertEqual(report["system"]["connectionCount"], 1)
|
|
|
|
def test_inspect_pages_result_variables_and_keeps_components_compact(self) -> None:
|
|
variables = [
|
|
{
|
|
"key": f"mass.value_{index}",
|
|
"componentId": "mass",
|
|
"label": f"Value {index}",
|
|
"quantity": "dimensionless",
|
|
"unit": "",
|
|
}
|
|
for index in range(75)
|
|
]
|
|
compiled = {
|
|
**COMPILED_MODEL,
|
|
"components": [
|
|
{
|
|
"id": "mass",
|
|
"type": "MECMAS21",
|
|
"parameters": [{"name": "mass", "value": 1.0}],
|
|
"ports": [{"name": "port_1"}],
|
|
"resultVariables": variables,
|
|
}
|
|
],
|
|
}
|
|
|
|
def response(_base_url: str, path: str, **_kwargs: object):
|
|
self.assertEqual(path, "/api/reactflow/compile-model")
|
|
return json_bytes(compiled), {}
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "project.json"
|
|
path.write_text(json.dumps(PROJECT), encoding="utf-8")
|
|
source = skill.load_source(str(path))
|
|
with mock.patch.object(skill, "http_request", side_effect=response):
|
|
report = skill.inspect_source(source, base_url=BASE_URL, timeout=10.0)
|
|
|
|
self.assertEqual(len(report["system"]["resultVariables"]), 20)
|
|
self.assertTrue(report["system"]["resultVariablePage"]["hasMore"])
|
|
self.assertEqual(report["system"]["resultVariablePage"]["nextOffset"], 20)
|
|
self.assertEqual(report["system"]["componentPage"]["total"], 1)
|
|
self.assertEqual(report["system"]["connectionPage"]["total"], 1)
|
|
self.assertNotIn("parameters", report["system"]["components"][0])
|
|
|
|
def test_inspect_keeps_source_only_medium_component_visible(self) -> None:
|
|
project = {
|
|
**PROJECT,
|
|
"nodes": [
|
|
{
|
|
"id": "medium",
|
|
"type": "simulationComponent",
|
|
"data": {
|
|
"label": "Helium properties",
|
|
"componentType": "amesim_helium_medium",
|
|
"modelVersion": "0.1.0",
|
|
"parameters": {"property_model": 0},
|
|
"ports": [],
|
|
},
|
|
},
|
|
*PROJECT["nodes"],
|
|
],
|
|
}
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "project.json"
|
|
path.write_text(json.dumps(project), encoding="utf-8")
|
|
source = skill.load_source(str(path))
|
|
with mock.patch.object(
|
|
skill,
|
|
"http_request",
|
|
side_effect=compile_http_response,
|
|
):
|
|
report = skill.inspect_source(
|
|
source,
|
|
base_url=BASE_URL,
|
|
timeout=10.0,
|
|
component_ids=("medium",),
|
|
)
|
|
|
|
self.assertEqual(report["system"]["componentCount"], 2)
|
|
self.assertEqual(report["system"]["componentPage"]["total"], 2)
|
|
self.assertEqual(report["system"]["components"][0]["id"], "medium")
|
|
self.assertEqual(report["system"]["components"][0]["label"], "Helium properties")
|
|
self.assertEqual(report["system"]["components"][0]["modelVersion"], "0.1.0")
|
|
self.assertFalse(report["system"]["components"][0]["compiledForSimulation"])
|
|
self.assertEqual(report["system"]["missingComponentDetails"], [])
|
|
medium_detail = report["system"]["componentDetails"][0]
|
|
self.assertFalse(medium_detail["compiledForSimulation"])
|
|
self.assertEqual(medium_detail["source"]["data"]["parameters"], {"property_model": 0})
|
|
self.assertIsNone(medium_detail["compiled"])
|
|
|
|
def test_variable_query_is_applied_before_nonzero_final_page(self) -> None:
|
|
matching = [
|
|
{
|
|
"key": f"mass.speed_{index}",
|
|
"componentId": "mass",
|
|
"label": f"Speed {index}",
|
|
"quantity": "velocity",
|
|
"unit": "m/s",
|
|
}
|
|
for index in range(5)
|
|
]
|
|
unrelated = [
|
|
{
|
|
"key": f"mass.position_{index}",
|
|
"componentId": "mass",
|
|
"label": f"Position {index}",
|
|
"quantity": "displacement",
|
|
"unit": "m",
|
|
}
|
|
for index in range(8)
|
|
]
|
|
compiled = {
|
|
**COMPILED_MODEL,
|
|
"components": [
|
|
{
|
|
"id": "mass",
|
|
"type": "MECMAS21",
|
|
"resultVariables": [*unrelated, *matching],
|
|
}
|
|
],
|
|
}
|
|
|
|
def response(_base_url: str, path: str, **_kwargs: object):
|
|
self.assertEqual(path, "/api/reactflow/compile-model")
|
|
return json_bytes(compiled), {}
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "project.json"
|
|
path.write_text(json.dumps(PROJECT), encoding="utf-8")
|
|
source = skill.load_source(str(path))
|
|
with mock.patch.object(skill, "http_request", side_effect=response):
|
|
report = skill.inspect_source(
|
|
source,
|
|
base_url=BASE_URL,
|
|
timeout=10.0,
|
|
variable_query="speed",
|
|
variable_offset=3,
|
|
variable_limit=2,
|
|
)
|
|
|
|
page = report["system"]["resultVariablePage"]
|
|
self.assertEqual(
|
|
[variable["key"] for variable in report["system"]["resultVariables"]],
|
|
["mass.speed_3", "mass.speed_4"],
|
|
)
|
|
self.assertEqual(page["query"], "speed")
|
|
self.assertEqual(page["offset"], 3)
|
|
self.assertEqual(page["total"], 5)
|
|
self.assertFalse(page["hasMore"])
|
|
self.assertIsNone(page["nextOffset"])
|
|
|
|
def test_xml_source_only_medium_survives_on_final_component_page(self) -> None:
|
|
parsed_model = {
|
|
"name": "xml-medium-system",
|
|
"components": [
|
|
{"id": "mass", "model_type": "amesim_mecmas21"},
|
|
{"id": "fixed", "model_type": "amesim_fixed"},
|
|
{
|
|
"id": "medium",
|
|
"model_type": "amesim_helium_medium",
|
|
"parameters": {"property_model": 0},
|
|
},
|
|
],
|
|
"connections": [],
|
|
"simulation": {"t_start": 0.0, "t_stop": 1.0},
|
|
}
|
|
compiled = {
|
|
**COMPILED_MODEL,
|
|
"components": [
|
|
{"id": "mass", "type": "amesim_mecmas21", "resultVariables": []},
|
|
{"id": "fixed", "type": "amesim_fixed", "resultVariables": []},
|
|
],
|
|
"connections": [],
|
|
}
|
|
|
|
def response(
|
|
_base_url: str,
|
|
path: str,
|
|
**_kwargs: object,
|
|
) -> tuple[bytes, dict[str, str]]:
|
|
if path == "/api/system-xml/parse":
|
|
return json_bytes({"model": parsed_model}), {}
|
|
if path == "/api/system-xml/compile-model":
|
|
return json_bytes(compiled), {}
|
|
raise AssertionError(f"Unexpected HTTP request: {path}")
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "system.xml"
|
|
path.write_bytes(SYSTEM_XML)
|
|
source = skill.load_source(str(path))
|
|
with mock.patch.object(skill, "http_request", side_effect=response):
|
|
report = skill.inspect_source(
|
|
source,
|
|
base_url=BASE_URL,
|
|
timeout=10.0,
|
|
component_offset=2,
|
|
component_limit=1,
|
|
)
|
|
|
|
system = report["system"]
|
|
self.assertEqual(system["componentPage"]["total"], 3)
|
|
self.assertEqual(system["componentPage"]["offset"], 2)
|
|
self.assertFalse(system["componentPage"]["hasMore"])
|
|
self.assertIsNone(system["componentPage"]["nextOffset"])
|
|
self.assertEqual(len(system["components"]), 1)
|
|
self.assertEqual(system["components"][0]["id"], "medium")
|
|
self.assertEqual(system["components"][0]["type"], "amesim_helium_medium")
|
|
self.assertFalse(system["components"][0]["compiledForSimulation"])
|
|
self.assertIn(
|
|
{"type": "amesim_helium_medium", "count": 1},
|
|
system["componentTypes"],
|
|
)
|
|
|
|
|
|
class TextNormalizationTests(unittest.TestCase):
|
|
def _repair_args(
|
|
self,
|
|
source: Path,
|
|
output: Path,
|
|
expected_hash: str,
|
|
*,
|
|
confirmed: bool,
|
|
confirmation_token: str | None = None,
|
|
) -> argparse.Namespace:
|
|
return argparse.Namespace(
|
|
input=str(source),
|
|
format="auto",
|
|
output=str(output),
|
|
expected_sha256=expected_hash,
|
|
confirmed=confirmed,
|
|
confirmation_token=confirmation_token,
|
|
base_url=BASE_URL,
|
|
timeout=1.0,
|
|
)
|
|
|
|
def test_preview_does_not_write_and_confirmation_writes_new_file(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
source_path = root / "source.json"
|
|
output_path = root / "normalized.json"
|
|
raw = b"\xef\xbb\xbf" + json.dumps(PROJECT, separators=(",", ":")).encode("utf-8") + b"\r\n"
|
|
source_path.write_bytes(raw)
|
|
source_hash = skill.load_source(str(source_path)).sha256
|
|
|
|
with (
|
|
mock.patch.object(
|
|
skill,
|
|
"http_request",
|
|
side_effect=compile_http_response,
|
|
),
|
|
mock.patch.object(skill, "emit_json") as emit,
|
|
):
|
|
preview_code = skill.command_repair_format(
|
|
self._repair_args(
|
|
source_path,
|
|
output_path,
|
|
source_hash,
|
|
confirmed=False,
|
|
)
|
|
)
|
|
preview = emit.call_args.args[0]
|
|
|
|
self.assertEqual(preview_code, 0)
|
|
self.assertFalse(output_path.exists())
|
|
self.assertFalse(preview["written"])
|
|
self.assertTrue(preview["requiresConfirmation"])
|
|
self.assertEqual(preview["preview"]["sourceSha256"], source_hash)
|
|
self.assertTrue(preview["preview"]["unifiedDiff"])
|
|
|
|
emit.reset_mock()
|
|
write_code = skill.command_repair_format(
|
|
self._repair_args(
|
|
source_path,
|
|
output_path,
|
|
source_hash,
|
|
confirmed=True,
|
|
confirmation_token=preview["preview"]["confirmationToken"],
|
|
)
|
|
)
|
|
written = emit.call_args.args[0]
|
|
|
|
normalized = output_path.read_bytes()
|
|
self.assertEqual(write_code, 0)
|
|
self.assertTrue(written["written"])
|
|
self.assertFalse(normalized.startswith(b"\xef\xbb\xbf"))
|
|
self.assertNotIn(b"\r\n", normalized)
|
|
self.assertTrue(normalized.endswith(b"\n"))
|
|
self.assertEqual(json.loads(normalized), PROJECT)
|
|
self.assertEqual(source_path.read_bytes(), raw)
|
|
|
|
def test_stale_confirmation_hash_and_source_overwrite_are_rejected(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
source_path = root / "source.json"
|
|
source_path.write_text(json.dumps(PROJECT), encoding="utf-8")
|
|
source_hash = skill.load_source(str(source_path)).sha256
|
|
|
|
with self.assertRaises(skill.InputError) as stale:
|
|
skill.command_repair_format(
|
|
self._repair_args(
|
|
source_path,
|
|
root / "output.json",
|
|
"0" * 64,
|
|
confirmed=True,
|
|
)
|
|
)
|
|
self.assertEqual(stale.exception.code, "SOURCE_HASH_MISMATCH")
|
|
|
|
with self.assertRaises(skill.InputError) as overwrite:
|
|
skill.command_repair_format(
|
|
self._repair_args(
|
|
source_path,
|
|
source_path,
|
|
source_hash,
|
|
confirmed=True,
|
|
)
|
|
)
|
|
self.assertEqual(overwrite.exception.code, "SOURCE_OVERWRITE_FORBIDDEN")
|
|
|
|
def test_confirmation_token_binds_the_preview_target(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
source_path = root / "source.json"
|
|
preview_target = root / "preview-target.json"
|
|
changed_target = root / "changed-target.json"
|
|
source_path.write_text(json.dumps(PROJECT, separators=(",", ":")), encoding="utf-8")
|
|
source_hash = skill.load_source(str(source_path)).sha256
|
|
|
|
with (
|
|
mock.patch.object(
|
|
skill,
|
|
"http_request",
|
|
side_effect=compile_http_response,
|
|
),
|
|
mock.patch.object(skill, "emit_json") as emit,
|
|
):
|
|
skill.command_repair_format(
|
|
self._repair_args(
|
|
source_path,
|
|
preview_target,
|
|
source_hash,
|
|
confirmed=False,
|
|
)
|
|
)
|
|
token = emit.call_args.args[0]["preview"]["confirmationToken"]
|
|
|
|
with self.assertRaises(skill.InputError) as caught:
|
|
skill.command_repair_format(
|
|
self._repair_args(
|
|
source_path,
|
|
changed_target,
|
|
source_hash,
|
|
confirmed=True,
|
|
confirmation_token=token,
|
|
)
|
|
)
|
|
|
|
self.assertEqual(caught.exception.code, "REPAIR_CONFIRMATION_TOKEN_MISMATCH")
|
|
self.assertFalse(preview_target.exists())
|
|
self.assertFalse(changed_target.exists())
|
|
|
|
def test_output_race_never_deletes_a_file_created_by_another_process(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
output = Path(directory) / "race.json"
|
|
|
|
def raced_open(*_args: object, **_kwargs: object):
|
|
with io.open(output, "wb") as handle:
|
|
handle.write(b"other-process")
|
|
raise FileExistsError("simulated output race")
|
|
|
|
with mock.patch("pathlib.Path.open", side_effect=raced_open):
|
|
with self.assertRaises(skill.ArtifactError):
|
|
skill._write_new_file(output, b"skill-output")
|
|
|
|
self.assertEqual(output.read_bytes(), b"other-process")
|
|
|
|
|
|
class SimulationTests(unittest.TestCase):
|
|
def _simulate_args(
|
|
self,
|
|
source: Path,
|
|
output: Path,
|
|
*,
|
|
variables: list[str],
|
|
chart_mode: str,
|
|
) -> argparse.Namespace:
|
|
return argparse.Namespace(
|
|
input=str(source),
|
|
format="auto",
|
|
output_dir=str(output),
|
|
variables=variables,
|
|
chart_mode=chart_mode,
|
|
simulation_id="simulation-test-1",
|
|
base_url=BASE_URL,
|
|
timeout=1.0,
|
|
)
|
|
|
|
def test_streaming_simulation_writes_result_full_csv_and_svg(self) -> None:
|
|
result = {
|
|
"status": "completed",
|
|
"success": True,
|
|
"actualEndTime": 1.0,
|
|
"variables": RESULT_VARIABLES,
|
|
"series": {
|
|
"time": [0.0, 0.5, 1.0],
|
|
"mass.position": [0.0, 0.25, 1.0],
|
|
"mass.velocity": [0.0, 1.0, 2.0],
|
|
},
|
|
}
|
|
stream = io.BytesIO(
|
|
b'{"event":"progress","phase":"integrating","progress":50}\n'
|
|
+ json_bytes({"event": "result", "result": result})
|
|
+ b"\n"
|
|
)
|
|
csv_payloads: list[dict[str, object]] = []
|
|
|
|
def simulation_http_response(
|
|
_base_url: str,
|
|
path: str,
|
|
**kwargs: object,
|
|
) -> tuple[bytes, dict[str, str]]:
|
|
if path == "/api/reactflow/compile-model":
|
|
return json_bytes(COMPILED_MODEL), {}
|
|
if path == "/api/reactflow/system-xml":
|
|
return SYSTEM_XML, {"Content-Type": "application/xml"}
|
|
if path == "/api/simulation-results/csv":
|
|
body = kwargs.get("body")
|
|
if not isinstance(body, bytes):
|
|
raise AssertionError("CSV request did not contain a JSON body")
|
|
csv_payloads.append(json.loads(body.decode("utf-8")))
|
|
return b"time,mass.position,mass.velocity\n0,0,0\n0.5,0.25,1\n1,1,2\n", {}
|
|
raise AssertionError(f"Unexpected HTTP request: {path}")
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
source_path = root / "project.json"
|
|
output_path = root / "artifacts"
|
|
source_path.write_text(json.dumps(PROJECT), encoding="utf-8")
|
|
|
|
with (
|
|
mock.patch.object(
|
|
skill,
|
|
"http_request",
|
|
side_effect=simulation_http_response,
|
|
),
|
|
mock.patch.object(
|
|
skill,
|
|
"_open_simulation_stream",
|
|
return_value=stream,
|
|
) as open_stream,
|
|
mock.patch.object(skill, "emit_json") as emit,
|
|
):
|
|
exit_code = skill.command_simulate(
|
|
self._simulate_args(
|
|
source_path,
|
|
output_path,
|
|
variables=["mass.position"],
|
|
chart_mode="separate",
|
|
)
|
|
)
|
|
|
|
self.assertEqual(exit_code, 0)
|
|
open_stream.assert_called_once_with(
|
|
BASE_URL,
|
|
SYSTEM_XML,
|
|
"simulation-test-1",
|
|
1.0,
|
|
)
|
|
self.assertEqual(json.loads((output_path / "result.json").read_text("utf-8")), result)
|
|
self.assertIn("mass.velocity", (output_path / "results.csv").read_text("utf-8"))
|
|
charts = list(output_path.glob("curve-mass.position-*.svg"))
|
|
self.assertEqual(len(charts), 1)
|
|
chart = charts[0]
|
|
self.assertIn("<polyline", chart.read_text("utf-8"))
|
|
progress_lines = (output_path / "progress.jsonl").read_text("utf-8").splitlines()
|
|
self.assertEqual([json.loads(line)["event"] for line in progress_lines], ["progress", "result"])
|
|
self.assertNotIn("result", json.loads(progress_lines[-1]))
|
|
|
|
self.assertEqual(len(csv_payloads), 1)
|
|
self.assertEqual(
|
|
[value["key"] for value in csv_payloads[0]["variables"]],
|
|
["mass.position", "mass.velocity"],
|
|
)
|
|
self.assertEqual(
|
|
set(csv_payloads[0]["series"]),
|
|
{"time", "mass.position", "mass.velocity"},
|
|
)
|
|
emitted_events = [call.args[0].get("event") for call in emit.call_args_list]
|
|
self.assertEqual(emitted_events, ["submitted", "progress", "result", "artifacts"])
|
|
emitted_result = emit.call_args_list[2].args[0]
|
|
self.assertNotIn("result", emitted_result)
|
|
self.assertEqual(emitted_result["simulationId"], "simulation-test-1")
|
|
self.assertEqual(emitted_result["actualEndTime"], 1.0)
|
|
self.assertEqual(emitted_result["sampleCount"], 3)
|
|
self.assertEqual(emitted_result["resultVariableCount"], 2)
|
|
|
|
def test_progress_stdout_throttling_keeps_phase_and_periodic_updates(self) -> None:
|
|
event = {"event": "progress", "phase": "integrating", "progress": 4}
|
|
|
|
self.assertFalse(
|
|
skill._should_emit_progress(
|
|
event,
|
|
previous_phase="integrating",
|
|
previous_progress=0.0,
|
|
seconds_since_emit=1.0,
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
skill._should_emit_progress(
|
|
{**event, "progress": 5},
|
|
previous_phase="integrating",
|
|
previous_progress=0.0,
|
|
seconds_since_emit=1.0,
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
skill._should_emit_progress(
|
|
event,
|
|
previous_phase="compilation",
|
|
previous_progress=0.0,
|
|
seconds_since_emit=1.0,
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
skill._should_emit_progress(
|
|
event,
|
|
previous_phase="integrating",
|
|
previous_progress=0.0,
|
|
seconds_since_emit=5.0,
|
|
)
|
|
)
|
|
|
|
def test_real_stream_throttles_stdout_but_logs_every_progress_event(self) -> None:
|
|
progress_events = [
|
|
{"event": "progress", "phase": "validation", "progress": 0},
|
|
{"event": "progress", "phase": "validation", "progress": 1},
|
|
{"event": "progress", "phase": "validation", "progress": 4},
|
|
{"event": "progress", "phase": "validation", "progress": 5},
|
|
{"event": "progress", "phase": "integrating", "progress": 6},
|
|
{"event": "progress", "phase": "integrating", "progress": 7},
|
|
{"event": "progress", "phase": "integrating", "progress": 100},
|
|
]
|
|
result = {
|
|
"status": "completed",
|
|
"success": True,
|
|
"actualEndTime": 1.0,
|
|
"variables": RESULT_VARIABLES,
|
|
"series": {
|
|
"time": [0.0, 1.0],
|
|
"mass.position": [0.0, 1.0],
|
|
"mass.velocity": [0.0, 2.0],
|
|
},
|
|
}
|
|
lines = [json_bytes(event) + b"\n" for event in progress_events]
|
|
lines.append(json_bytes({"event": "result", "result": result}) + b"\n")
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
progress_path = Path(directory) / "progress.jsonl"
|
|
with (
|
|
mock.patch.object(
|
|
skill,
|
|
"_open_simulation_stream",
|
|
return_value=io.BytesIO(b"".join(lines)),
|
|
),
|
|
mock.patch.object(
|
|
skill.time,
|
|
"monotonic",
|
|
side_effect=[100.0, 101.0, 102.0, 103.0, 104.0, 110.0, 111.0],
|
|
),
|
|
mock.patch.object(skill, "emit_json") as emit,
|
|
):
|
|
returned_result, returned_error = skill._read_simulation_stream(
|
|
BASE_URL,
|
|
SYSTEM_XML,
|
|
"simulation-test-1",
|
|
10.0,
|
|
progress_path,
|
|
)
|
|
|
|
logged = [
|
|
json.loads(line)
|
|
for line in progress_path.read_text("utf-8").splitlines()
|
|
]
|
|
|
|
self.assertEqual(returned_result, result)
|
|
self.assertIsNone(returned_error)
|
|
self.assertEqual(logged[:7], progress_events)
|
|
self.assertEqual(len(logged), 8)
|
|
self.assertEqual(logged[-1]["event"], "result")
|
|
self.assertNotIn("result", logged[-1])
|
|
public = [call.args[0] for call in emit.call_args_list]
|
|
self.assertEqual(
|
|
[event.get("progress") for event in public if event["event"] == "progress"],
|
|
[0, 5, 6, 7, 100],
|
|
)
|
|
self.assertEqual(public[-1]["event"], "result")
|
|
self.assertNotIn("result", public[-1])
|
|
self.assertEqual(public[-1]["sampleCount"], 2)
|
|
|
|
def test_overlay_and_stacked_positive_chart_generation(self) -> None:
|
|
variables = {
|
|
"mass.position": RESULT_VARIABLES[0],
|
|
"reference.position": {
|
|
**RESULT_VARIABLES[0],
|
|
"key": "reference.position",
|
|
"componentId": "reference",
|
|
},
|
|
"mass.velocity": RESULT_VARIABLES[1],
|
|
}
|
|
series = {
|
|
"time": [0.0, 0.5, 1.0],
|
|
"mass.position": [0.0, 0.25, 1.0],
|
|
"reference.position": [1.0, 0.5, 0.0],
|
|
"mass.velocity": [0.0, 1.0, 2.0],
|
|
}
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
overlay_paths = skill._write_charts(
|
|
root,
|
|
"overlay",
|
|
["mass.position", "reference.position"],
|
|
variables,
|
|
series,
|
|
)
|
|
stacked_paths = skill._write_charts(
|
|
root,
|
|
"stacked",
|
|
["mass.position", "mass.velocity"],
|
|
variables,
|
|
series,
|
|
)
|
|
|
|
overlay = Path(overlay_paths[0]).read_text("utf-8")
|
|
stacked = Path(stacked_paths[0]).read_text("utf-8")
|
|
|
|
self.assertEqual([Path(path).name for path in overlay_paths], ["curves-overlay.svg"])
|
|
self.assertEqual([Path(path).name for path in stacked_paths], ["curves-stacked.svg"])
|
|
self.assertEqual(overlay.count("<polyline"), 2)
|
|
self.assertEqual(stacked.count("<polyline"), 2)
|
|
self.assertIn("reference.Position", overlay)
|
|
self.assertIn("mass.Velocity [m/s]", stacked)
|
|
|
|
def test_noncompleted_result_still_writes_partial_artifacts(self) -> None:
|
|
partial_result = {
|
|
"status": "stopped",
|
|
"success": False,
|
|
"partial": True,
|
|
"simulatedUntil": 0.5,
|
|
"variables": RESULT_VARIABLES,
|
|
"series": {
|
|
"time": [0.0, 0.5],
|
|
"mass.position": [0.0, 0.25],
|
|
"mass.velocity": [0.0, 1.0],
|
|
},
|
|
}
|
|
inspection = {
|
|
"system": {
|
|
"name": "partial-system",
|
|
"resultVariables": RESULT_VARIABLES,
|
|
}
|
|
}
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
source_path = root / "project.json"
|
|
output_path = root / "partial-artifacts"
|
|
source_path.write_text(json.dumps(PROJECT), encoding="utf-8")
|
|
|
|
with (
|
|
mock.patch.object(skill, "inspect_source", return_value=inspection),
|
|
mock.patch.object(skill, "_json_to_xml", return_value=SYSTEM_XML),
|
|
mock.patch.object(
|
|
skill,
|
|
"_read_simulation_stream",
|
|
return_value=(partial_result, None),
|
|
),
|
|
mock.patch.object(
|
|
skill,
|
|
"_download_csv",
|
|
return_value=b"time,mass.position,mass.velocity\n0,0,0\n0.5,0.25,1\n",
|
|
),
|
|
mock.patch.object(skill, "emit_json") as emit,
|
|
):
|
|
exit_code = skill.command_simulate(
|
|
self._simulate_args(
|
|
source_path,
|
|
output_path,
|
|
variables=["mass.position"],
|
|
chart_mode="separate",
|
|
)
|
|
)
|
|
|
|
artifact_event = emit.call_args.args[0]
|
|
self.assertEqual(exit_code, 4)
|
|
self.assertEqual(artifact_event["event"], "artifacts")
|
|
self.assertEqual(artifact_event["status"], "stopped")
|
|
self.assertTrue(artifact_event["partial"])
|
|
self.assertTrue((output_path / "result.json").is_file())
|
|
self.assertTrue((output_path / "results.csv").is_file())
|
|
self.assertEqual(len(list(output_path.glob("curve-mass.position-*.svg"))), 1)
|
|
|
|
def test_overlay_unit_mismatch_is_rejected_before_simulation(self) -> None:
|
|
mismatched = {
|
|
"system": {
|
|
"name": "unit-test",
|
|
"resultVariables": [
|
|
{"key": "pressure", "unit": "Pa"},
|
|
{"key": "flow", "unit": "kg/s"},
|
|
],
|
|
}
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
source_path = root / "project.json"
|
|
output_path = root / "must-not-exist"
|
|
source_path.write_text(json.dumps(PROJECT), encoding="utf-8")
|
|
|
|
with (
|
|
mock.patch.object(skill, "inspect_source", return_value=mismatched),
|
|
mock.patch.object(skill, "_read_simulation_stream") as run,
|
|
):
|
|
with self.assertRaises(skill.InputError) as caught:
|
|
skill.command_simulate(
|
|
self._simulate_args(
|
|
source_path,
|
|
output_path,
|
|
variables=["pressure", "flow"],
|
|
chart_mode="overlay",
|
|
)
|
|
)
|
|
|
|
self.assertEqual(caught.exception.code, "OVERLAY_UNITS_MISMATCH")
|
|
run.assert_not_called()
|
|
self.assertFalse(output_path.exists())
|
|
|
|
|
|
class TaskControlTests(unittest.TestCase):
|
|
def test_status_queries_the_requested_task(self) -> None:
|
|
args = argparse.Namespace(
|
|
simulation_id="run-123",
|
|
base_url=BASE_URL,
|
|
timeout=2.0,
|
|
)
|
|
task = {"simulationId": "run-123", "status": "integrating"}
|
|
|
|
with (
|
|
mock.patch.object(skill, "http_json", return_value=task) as http_json,
|
|
mock.patch.object(skill, "emit_json") as emit,
|
|
):
|
|
exit_code = skill.command_status(args)
|
|
|
|
self.assertEqual(exit_code, 0)
|
|
http_json.assert_called_once_with(
|
|
BASE_URL,
|
|
"/api/system-xml/simulations/run-123",
|
|
timeout=2.0,
|
|
)
|
|
self.assertEqual(
|
|
emit.call_args.args[0],
|
|
{"ok": True, "command": "status", "task": task},
|
|
)
|
|
|
|
def test_status_compacts_a_completed_result(self) -> None:
|
|
args = argparse.Namespace(
|
|
simulation_id="run-123",
|
|
base_url=BASE_URL,
|
|
timeout=2.0,
|
|
)
|
|
task = {
|
|
"simulationId": "run-123",
|
|
"status": "completed",
|
|
"result": {
|
|
"status": "completed",
|
|
"success": True,
|
|
"simulatedUntil": 1.0,
|
|
"variables": RESULT_VARIABLES,
|
|
"series": {"time": [0.0, 1.0], "mass.position": [0.0, 1.0]},
|
|
},
|
|
}
|
|
|
|
with (
|
|
mock.patch.object(skill, "http_json", return_value=task),
|
|
mock.patch.object(skill, "emit_json") as emit,
|
|
):
|
|
exit_code = skill.command_status(args)
|
|
|
|
self.assertEqual(exit_code, 0)
|
|
public_task = emit.call_args.args[0]["task"]
|
|
self.assertNotIn("result", public_task)
|
|
self.assertNotIn("fullResult", public_task["resultSummary"])
|
|
self.assertEqual(public_task["resultSummary"]["sampleCount"], 2)
|
|
self.assertEqual(public_task["resultSummary"]["actualEndTime"], 1.0)
|
|
|
|
def test_cancel_posts_reason_to_the_requested_task(self) -> None:
|
|
args = argparse.Namespace(
|
|
simulation_id="run-123",
|
|
reason="stalled",
|
|
base_url=BASE_URL,
|
|
timeout=2.0,
|
|
)
|
|
task = {"simulationId": "run-123", "status": "cancelling"}
|
|
|
|
with (
|
|
mock.patch.object(skill, "http_json", return_value=task) as http_json,
|
|
mock.patch.object(skill, "emit_json") as emit,
|
|
):
|
|
exit_code = skill.command_cancel(args)
|
|
|
|
self.assertEqual(exit_code, 0)
|
|
http_json.assert_called_once_with(
|
|
BASE_URL,
|
|
"/api/system-xml/simulations/run-123/cancel",
|
|
method="POST",
|
|
payload={"reason": "stalled"},
|
|
timeout=2.0,
|
|
)
|
|
self.assertEqual(
|
|
emit.call_args.args[0],
|
|
{"ok": True, "command": "cancel", "task": task},
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|