"""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""" """ 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']>' b"&xxe;" ) 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(" 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(" 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()