228 lines
9.1 KiB
Python
228 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
WORKER_PATH = (
|
|
Path(__file__).resolve().parents[1] / "windows-node" / "origin-worker" / "worker.py"
|
|
)
|
|
ADAPTER_MANIFEST_PATH = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "windows-node" / "adapters" / "origin.plot@v2" / "adapter.json"
|
|
)
|
|
SPEC = importlib.util.spec_from_file_location("zcbot_origin_worker", WORKER_PATH)
|
|
assert SPEC and SPEC.loader
|
|
worker = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(worker)
|
|
|
|
|
|
class OriginWorkerUnitTests(unittest.TestCase):
|
|
@staticmethod
|
|
def _request() -> dict:
|
|
return {
|
|
"inputs": [{"key": "sample"}],
|
|
"operation": {"plot": {
|
|
"type": "line",
|
|
"series": [{"input": "sample", "x": "x", "y": "y"}],
|
|
}},
|
|
"outputs": [{"key": "figure_png", "format": "png"}],
|
|
}
|
|
|
|
def test_worker_version_matches_adapter_manifest(self) -> None:
|
|
manifest = json.loads(ADAPTER_MANIFEST_PATH.read_text(encoding="utf-8"))
|
|
self.assertEqual(worker.ADAPTER_VERSION, manifest["adapter_version"])
|
|
|
|
def test_csv_and_json_inputs_are_read_without_origin(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
csv_path = root / "input.csv"
|
|
csv_path.write_text("x,y\n1,2\n3,4\n", encoding="utf-8")
|
|
self.assertEqual(worker._read_rows(csv_path, None), (["x", "y"], [["1", "2"], ["3", "4"]]))
|
|
|
|
json_path = root / "input.json"
|
|
json_path.write_text(json.dumps([{"x": 1, "y": 2}, {"x": 3, "y": 4}]), encoding="utf-8")
|
|
self.assertEqual(worker._read_rows(json_path, None), (["x", "y"], [[1, 2], [3, 4]]))
|
|
|
|
def test_manifest_uses_stable_id_and_streaming_digest(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "plot-spec.json"
|
|
path.write_text("{}", encoding="utf-8")
|
|
manifest = worker._manifest(path, "application/json")
|
|
self.assertEqual(manifest["artifact_id"], "plot_spec")
|
|
self.assertEqual(manifest["sha256"], worker._file_sha256(path))
|
|
self.assertEqual(manifest["size_bytes"], 2)
|
|
|
|
def test_axis_title_includes_units(self) -> None:
|
|
self.assertEqual(worker._axis_title({"title": "Stress", "unit": "MPa"}, "Y"), "Stress (MPa)")
|
|
self.assertEqual(worker._axis_title(None, "Time"), "Time")
|
|
|
|
def test_layout_helpers_apply_validated_values(self) -> None:
|
|
class FakeAxis:
|
|
title = ""
|
|
scale = "linear"
|
|
limits = None
|
|
|
|
def set_limits(self, begin, end, step):
|
|
self.limits = (begin, end, step)
|
|
|
|
class FakeLayer:
|
|
def __init__(self):
|
|
self.axes = {name: FakeAxis() for name in ("x", "y", "z")}
|
|
self.values = {}
|
|
self.labels = {}
|
|
|
|
def axis(self, name):
|
|
return self.axes[name]
|
|
|
|
def set_float(self, name, value):
|
|
self.values[name] = value
|
|
|
|
def set_int(self, name, value):
|
|
self.values[name] = value
|
|
|
|
def label(self, name):
|
|
class FakeLabel:
|
|
def set_int(_, prop, value):
|
|
self.labels[(name, prop)] = value
|
|
return FakeLabel()
|
|
|
|
layer = FakeLayer()
|
|
worker._apply_axis(layer, "x", {
|
|
"title": "Time", "unit": "d", "scale": "log10",
|
|
"minimum": 1, "maximum": 100, "major_step": 1,
|
|
"tick_label_angle": 45, "tick_label_font_size": 10,
|
|
"title_font_size": 12, "grid": "major_minor",
|
|
}, "X")
|
|
self.assertEqual(layer.axes["x"].title, "Time (d)")
|
|
self.assertEqual(layer.axes["x"].scale, "log10")
|
|
self.assertEqual(layer.axes["x"].limits, (1, 100, 1))
|
|
self.assertEqual(layer.values, {
|
|
"x.label.rotate": 45, "x.label.pt": 10, "x.grid.show": 3,
|
|
})
|
|
self.assertEqual(layer.labels, {("xb", "fsize"): 12})
|
|
|
|
def test_series_style_maps_to_origin_properties(self) -> None:
|
|
class FakePlot:
|
|
def __init__(self):
|
|
self.values = {}
|
|
self.color = None
|
|
self.symbol_kind = None
|
|
self.symbol_size = None
|
|
self.transparency = None
|
|
|
|
def set_float(self, name, value):
|
|
self.values[name] = value
|
|
|
|
def set_int(self, name, value):
|
|
self.values[name] = value
|
|
|
|
plot = FakePlot()
|
|
worker._apply_series_style(plot, {
|
|
"color": "#3366CC", "line_width": 1.5, "line_style": "dash_dot",
|
|
"symbol": "diamond", "symbol_size": 8, "transparency": 20,
|
|
})
|
|
self.assertEqual(plot.color, (51, 102, 204))
|
|
self.assertEqual(plot.values, {"line.width": 1.5, "line.type": 4})
|
|
self.assertEqual(plot.symbol_kind, 3)
|
|
self.assertEqual(plot.symbol_size, 8)
|
|
self.assertEqual(plot.transparency, 20)
|
|
|
|
def test_keyed_input_directory_requires_exactly_one_file(self) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
keyed = root / "input" / "sample"
|
|
keyed.mkdir(parents=True)
|
|
path = keyed / "data.csv"
|
|
path.write_text("x,y\n1,2\n", encoding="utf-8")
|
|
self.assertEqual(worker._input_file(root, "sample"), path)
|
|
(keyed / "extra.csv").write_text("x,y\n3,4\n", encoding="utf-8")
|
|
with self.assertRaisesRegex(ValueError, "INPUT_FILE_COUNT_INVALID:sample"):
|
|
worker._input_file(root, "sample")
|
|
|
|
def test_series_are_resolved_against_each_input(self) -> None:
|
|
input_data = {
|
|
"first": (["time", "strength"], [[1, 10]]),
|
|
"second": (["temperature", "value"], [[20, 30]]),
|
|
}
|
|
series = [
|
|
{"input": "first", "x": "time", "y": "strength", "label": "7 d"},
|
|
{"input": "second", "x": "temperature", "y": "value", "label": "28 d"},
|
|
]
|
|
resolved, labels = worker._resolve_series(input_data, series)
|
|
self.assertEqual(resolved, [
|
|
{"input": "first", "label": "7 d", "x": 0, "y": 1},
|
|
{"input": "second", "label": "28 d", "x": 0, "y": 1},
|
|
])
|
|
self.assertEqual(labels, {("first", 1): "7 d", ("second", 1): "28 d"})
|
|
|
|
def test_shared_y_column_rejects_conflicting_labels(self) -> None:
|
|
input_data = {"sample": (["x1", "x2", "y"], [[1, 2, 3]])}
|
|
with self.assertRaisesRegex(ValueError, "SERIES_LABEL_CONFLICT"):
|
|
worker._resolve_series(input_data, [
|
|
{"input": "sample", "x": "x1", "y": "y", "label": "First"},
|
|
{"input": "sample", "x": "x2", "y": "y", "label": "Second"},
|
|
])
|
|
|
|
def test_worker_owns_cross_field_semantic_validation(self) -> None:
|
|
request = self._request()
|
|
worker._validate_semantics(request)
|
|
|
|
request["operation"]["plot"]["x_axis"] = {
|
|
"scale": "log10", "minimum": 0, "maximum": 100
|
|
}
|
|
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
|
|
worker._validate_semantics(request)
|
|
|
|
request = self._request()
|
|
request["inputs"].append({"key": "unused"})
|
|
with self.assertRaisesRegex(ValueError, "INPUT_BINDINGS_MUST_BE_USED_EXACTLY"):
|
|
worker._validate_semantics(request)
|
|
|
|
def test_series_support_xyz_and_y_error_roles(self) -> None:
|
|
resolved, labels = worker._resolve_series(
|
|
{"sample": (["x", "y", "z", "sd"], [[0, 1, 2, 0.1]])},
|
|
[{
|
|
"input": "sample", "x": "x", "y": "y", "z": "z",
|
|
"y_error": "sd", "label": "测量值",
|
|
}],
|
|
)
|
|
self.assertEqual(resolved, [{
|
|
"input": "sample", "label": "测量值", "x": 0, "y": 1,
|
|
"z": 2, "y_error": 3,
|
|
}])
|
|
self.assertEqual(labels, {("sample", 1): "测量值"})
|
|
|
|
def test_heatmap_matrix_accepts_complete_unordered_grid(self) -> None:
|
|
matrix, xy_map = worker._heatmap_matrix(
|
|
[[1, 20, 4], [0, 10, 1], [1, 10, 2], [0, 20, 3]],
|
|
{"x": 0, "y": 1, "z": 2},
|
|
)
|
|
self.assertEqual(matrix, [[1.0, 2.0], [3.0, 4.0]])
|
|
self.assertEqual(xy_map, (0.0, 1.0, 10.0, 20.0))
|
|
|
|
def test_heatmap_matrix_rejects_invalid_grid(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "HEATMAP_GRID_INCOMPLETE"):
|
|
worker._heatmap_matrix(
|
|
[[0, 10, 1], [1, 10, 2], [0, 20, 3]],
|
|
{"x": 0, "y": 1, "z": 2},
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "HEATMAP_COORDINATES_DUPLICATED"):
|
|
worker._heatmap_matrix(
|
|
[[0, 10, 1], [0, 10, 2]], {"x": 0, "y": 1, "z": 2}
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "HEATMAP_GRID_NOT_REGULAR"):
|
|
worker._heatmap_matrix(
|
|
[
|
|
[0, 10, 1], [1, 10, 2], [3, 10, 3],
|
|
[0, 20, 4], [1, 20, 5], [3, 20, 6],
|
|
],
|
|
{"x": 0, "y": 1, "z": 2},
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|