1280 lines
42 KiB
Python
1280 lines
42 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import zlib
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
WORKER_PATH = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "windows-node"
|
|
/ "adapters"
|
|
/ "origin.plot@v2"
|
|
/ "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_registered_origin_version_comes_from_com_server_executable(self) -> None:
|
|
class FakeKey:
|
|
def __init__(self, path: str):
|
|
self.path = path
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
return None
|
|
|
|
class FakeRegistry:
|
|
HKEY_CLASSES_ROOT = object()
|
|
values = {
|
|
r"Origin.ApplicationSI\CLSID": "{origin-clsid}",
|
|
r"CLSID\{origin-clsid}\LocalServer32": (
|
|
r'"C:\Program Files\OriginLab\Origin2025\Origin64.exe" /Automation'
|
|
),
|
|
}
|
|
|
|
@classmethod
|
|
def OpenKey(cls, _root, path):
|
|
return FakeKey(path)
|
|
|
|
@classmethod
|
|
def QueryValueEx(cls, key, _name):
|
|
return cls.values[key.path], 1
|
|
|
|
with patch.object(worker, "_windows_file_version", return_value="2025") as read:
|
|
self.assertEqual(worker._registered_origin_version(FakeRegistry), "2025")
|
|
read.assert_called_once_with(Path(r"C:\Program Files\OriginLab\Origin2025\Origin64.exe"))
|
|
FakeRegistry.values[r"CLSID\{origin-clsid}\LocalServer32"] = "invalid"
|
|
self.assertIsNone(worker._registered_origin_version(FakeRegistry))
|
|
|
|
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_new_graph_converts_template_layers_before_applying_canvas(self) -> None:
|
|
events = []
|
|
|
|
class Layer:
|
|
def set_int(self, name, value):
|
|
events.append(("layer", name, value))
|
|
|
|
class Graph:
|
|
class Obj:
|
|
@staticmethod
|
|
def LT_execute(command):
|
|
events.append(("page", "lt", command))
|
|
|
|
obj = Obj()
|
|
|
|
def __iter__(self):
|
|
return iter([Layer()])
|
|
|
|
@staticmethod
|
|
def activate():
|
|
events.append(("page", "activate"))
|
|
|
|
class Origin:
|
|
@staticmethod
|
|
def new_graph(*, template):
|
|
events.append(("new", template))
|
|
return Graph()
|
|
|
|
graph = worker._new_graph(
|
|
Origin(), "3D", {"width_mm": 140, "height_mm": 100}
|
|
)
|
|
|
|
self.assertIsInstance(graph, Graph)
|
|
self.assertEqual(events[0], ("new", "3D"))
|
|
self.assertEqual(events[1], ("layer", "unit", 1))
|
|
self.assertEqual(events[2], ("page", "activate"))
|
|
self.assertEqual(events[3][0:2], ("page", "lt"))
|
|
self.assertIn(f"page.width={140 / 25.4 * 600}", events[3][2])
|
|
self.assertIn(f"page.height={100 / 25.4 * 600}", events[3][2])
|
|
|
|
def test_surface_3d_presentation_hides_color_scale_with_legend(self) -> None:
|
|
created = []
|
|
|
|
class Label:
|
|
def __init__(self, text=""):
|
|
self.text = text
|
|
self.name = ""
|
|
self.show = True
|
|
self.values = {}
|
|
|
|
def set_int(self, name, value):
|
|
self.values[name] = value
|
|
|
|
class Obj:
|
|
commands = []
|
|
|
|
@classmethod
|
|
def LT_execute(cls, command):
|
|
cls.commands.append(command)
|
|
|
|
class Layer:
|
|
obj = Obj()
|
|
values = {}
|
|
spectrum = Label()
|
|
|
|
@classmethod
|
|
def set_int(cls, name, value):
|
|
cls.values[name] = value
|
|
|
|
@classmethod
|
|
def set_float(cls, name, value):
|
|
cls.values[name] = value
|
|
|
|
@classmethod
|
|
def label(cls, name):
|
|
return cls.spectrum if name == "Spectrum1" else None
|
|
|
|
@staticmethod
|
|
def add_label(text):
|
|
label = Label(text)
|
|
created.append(label)
|
|
return label
|
|
|
|
worker._apply_surface_3d_presentation(
|
|
Layer(),
|
|
{"enabled": False},
|
|
x_title="Temperature (°C)",
|
|
y_title="Time (min)",
|
|
z_title="Strength (MPa)",
|
|
)
|
|
|
|
self.assertEqual(Layer.values["unit"], 1)
|
|
self.assertEqual(Layer.values["left"], 22)
|
|
self.assertEqual(Layer.values["width"], 58)
|
|
self.assertEqual(Layer.values["z.label.pt"], 7)
|
|
self.assertFalse(Layer.spectrum.show)
|
|
self.assertEqual(
|
|
[(label.name, label.text) for label in created],
|
|
[
|
|
("ZCBOT_X_TITLE", "Temperature (°C)"),
|
|
("ZCBOT_Y_TITLE", "Time (min)"),
|
|
("ZCBOT_Z_TITLE", "Strength (MPa)"),
|
|
],
|
|
)
|
|
self.assertEqual(created[0].values["attach"], 0)
|
|
self.assertEqual(created[2].values["rotate"], 90)
|
|
self.assertIn("label -r zf", Obj.commands[0])
|
|
|
|
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_rescale_applies_log_scales_before_deriving_limits(self) -> None:
|
|
class FakeAxis:
|
|
def __init__(self, name, events):
|
|
self.name = name
|
|
self.events = events
|
|
self._scale = "linear"
|
|
|
|
@property
|
|
def scale(self):
|
|
return self._scale
|
|
|
|
@scale.setter
|
|
def scale(self, value):
|
|
self._scale = value
|
|
self.events.append(("scale", self.name, value))
|
|
|
|
class FakeLayer:
|
|
def __init__(self):
|
|
self.events = []
|
|
self.axes = {name: FakeAxis(name, self.events) for name in ("x", "y")}
|
|
|
|
def axis(self, name):
|
|
return self.axes[name]
|
|
|
|
def rescale(self):
|
|
self.events.append(("rescale",))
|
|
|
|
layer = FakeLayer()
|
|
worker._rescale_with_axis_scales(
|
|
layer,
|
|
[
|
|
("x", {"scale": "log10", "minimum": 1, "maximum": 100}),
|
|
("y", {"scale": "ln"}),
|
|
],
|
|
)
|
|
|
|
self.assertEqual(
|
|
layer.events,
|
|
[
|
|
("scale", "x", "log10"),
|
|
("scale", "y", "ln"),
|
|
("rescale",),
|
|
],
|
|
)
|
|
|
|
def test_rescale_does_not_apply_explicit_limits_before_rescaling(self) -> None:
|
|
class FakeAxis:
|
|
scale = "linear"
|
|
|
|
def set_limits(self, *_):
|
|
raise AssertionError("limits must be applied after rescale")
|
|
|
|
class FakeLayer:
|
|
def __init__(self):
|
|
self.axis_value = FakeAxis()
|
|
self.rescaled = False
|
|
|
|
def axis(self, _name):
|
|
return self.axis_value
|
|
|
|
def rescale(self):
|
|
self.rescaled = True
|
|
|
|
layer = FakeLayer()
|
|
worker._rescale_with_axis_scales(
|
|
layer,
|
|
[
|
|
("x", {"minimum": 1, "maximum": 100}),
|
|
],
|
|
)
|
|
|
|
self.assertTrue(layer.rescaled)
|
|
self.assertEqual(layer.axis_value.scale, "linear")
|
|
|
|
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, 5)
|
|
self.assertEqual(plot.symbol_size, 8)
|
|
self.assertEqual(plot.transparency, 20)
|
|
|
|
def test_origin_symbol_ids_match_labtalk_values(self) -> None:
|
|
self.assertEqual(
|
|
worker.SYMBOLS,
|
|
{
|
|
"square": 1,
|
|
"circle": 2,
|
|
"triangle_up": 3,
|
|
"diamond": 5,
|
|
"plus": 6,
|
|
"cross": 7,
|
|
},
|
|
)
|
|
|
|
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_multi_panel_semantics_cover_layout_axes_and_total_series(self) -> None:
|
|
request = {
|
|
"inputs": [{"key": "sample"}],
|
|
"operation": {
|
|
"plot": {
|
|
"type": "multi_panel",
|
|
"layout": {"rows": 1, "columns": 2, "share_x": True},
|
|
"panels": [
|
|
{
|
|
"key": "strength",
|
|
"series": [
|
|
{
|
|
"input": "sample",
|
|
"x": "age",
|
|
"y": "strength",
|
|
"kind": "scatter",
|
|
"y_error": "sd",
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"key": "thermal",
|
|
"series": [
|
|
{
|
|
"input": "sample",
|
|
"x": "temperature",
|
|
"y": "tg",
|
|
"kind": "line",
|
|
},
|
|
{
|
|
"input": "sample",
|
|
"x": "temperature",
|
|
"y": "dtg",
|
|
"kind": "line",
|
|
"y_axis": "right",
|
|
},
|
|
],
|
|
"right_y_axis": {"title": "DTG"},
|
|
},
|
|
],
|
|
}
|
|
},
|
|
"outputs": [{"key": "figure_png", "format": "png"}],
|
|
}
|
|
worker._validate_semantics(request)
|
|
|
|
request["operation"]["plot"]["layout"] = {"rows": 1, "columns": 1}
|
|
with self.assertRaisesRegex(ValueError, "PANEL_LAYOUT_TOO_SMALL"):
|
|
worker._validate_semantics(request)
|
|
|
|
request["operation"]["plot"]["layout"] = {
|
|
"rows": 1,
|
|
"columns": 2,
|
|
"share_x": True,
|
|
}
|
|
request["operation"]["plot"]["panels"][0]["x_axis"] = {
|
|
"scale": "log10",
|
|
"minimum": 0,
|
|
}
|
|
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
|
|
worker._validate_semantics(request)
|
|
|
|
request["operation"]["plot"]["panels"][0]["x_axis"] = {"minimum": 1}
|
|
with self.assertRaisesRegex(ValueError, "SHARED_X_AXIS_CONFIG_MISMATCH"):
|
|
worker._validate_semantics(request)
|
|
|
|
request["operation"]["plot"]["panels"][0].pop("x_axis")
|
|
request["operation"]["plot"]["x_axis"] = {
|
|
"scale": "log10",
|
|
"minimum": 0,
|
|
}
|
|
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
|
|
worker._validate_semantics(request)
|
|
|
|
def test_recipe_uses_the_composition_semantics(self) -> None:
|
|
request = {
|
|
"inputs": [{"key": "sample"}],
|
|
"operation": {
|
|
"plot": {
|
|
"type": "recipe",
|
|
"recipe_version": 1,
|
|
"layout": {"rows": 1, "columns": 1},
|
|
"panels": [
|
|
{
|
|
"key": "main",
|
|
"series": [
|
|
{
|
|
"input": "sample",
|
|
"x": "age",
|
|
"y": "strength",
|
|
"kind": "line_scatter",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
},
|
|
"outputs": [{"key": "figure_png", "format": "png"}],
|
|
}
|
|
worker._validate_semantics(request)
|
|
self.assertIn("recipe", worker.COMPOSITION_PLOT_TYPES)
|
|
|
|
def test_panel_settings_inherit_plot_defaults_and_allow_overrides(self) -> None:
|
|
plot = {
|
|
"x_axis": {"title": "Age", "unit": "d"},
|
|
"y_axis": {"title": "Value"},
|
|
"legend": {"enabled": False, "font_size": 8},
|
|
}
|
|
panel = {
|
|
"y_axis": {"title": "Strength", "unit": "MPa"},
|
|
"legend": {"enabled": True},
|
|
}
|
|
self.assertEqual(
|
|
worker._panel_setting(plot, panel, "x_axis"),
|
|
{"title": "Age", "unit": "d"},
|
|
)
|
|
self.assertEqual(
|
|
worker._panel_setting(plot, panel, "y_axis"),
|
|
{"title": "Strength", "unit": "MPa"},
|
|
)
|
|
self.assertEqual(
|
|
worker._panel_legend(plot, panel),
|
|
{
|
|
"enabled": True,
|
|
"position": "top_right",
|
|
"font_size": 8,
|
|
},
|
|
)
|
|
|
|
def test_panel_y_axis_fallback_prefers_title_then_column_name(self) -> None:
|
|
series = [{"y": "CS_20C"}]
|
|
self.assertEqual(
|
|
worker._panel_y_axis_fallback({"title": "抗压强度 (MPa)"}, series),
|
|
"抗压强度 (MPa)",
|
|
)
|
|
self.assertEqual(
|
|
worker._panel_y_axis_fallback({}, series),
|
|
"CS_20C",
|
|
)
|
|
|
|
def test_mixed_xy_series_pass_kind_axis_and_error_columns_to_origin(self) -> None:
|
|
class FakePlot:
|
|
pass
|
|
|
|
class FakeLayer:
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.grouped = False
|
|
self.plots = []
|
|
|
|
def add_plot(self, worksheet, **arguments):
|
|
self.calls.append((worksheet, arguments))
|
|
plot = FakePlot()
|
|
self.plots.append(plot)
|
|
if "colxerr" in arguments:
|
|
self.plots.append(FakePlot())
|
|
if "colyerr" in arguments:
|
|
self.plots.append(FakePlot())
|
|
return plot
|
|
|
|
def plot_list(self):
|
|
return self.plots
|
|
|
|
def group(self, enabled=True):
|
|
self.grouped = enabled
|
|
|
|
layer = FakeLayer()
|
|
series = [
|
|
{
|
|
"input": "sample",
|
|
"x": "age",
|
|
"y": "strength",
|
|
"kind": "scatter",
|
|
"x_error": "age_sd",
|
|
"y_error": "strength_sd",
|
|
},
|
|
{"input": "sample", "x": "age", "y": "fit", "kind": "line"},
|
|
]
|
|
resolved = [
|
|
{"input": "sample", "x": 0, "y": 1, "x_error": 2, "y_error": 3},
|
|
{"input": "sample", "x": 0, "y": 4},
|
|
]
|
|
worker._add_xy_plots(layer, {"sample": "worksheet"}, series, resolved)
|
|
self.assertEqual(
|
|
layer.calls,
|
|
[
|
|
(
|
|
"worksheet",
|
|
{"coly": 1, "colx": 0, "type": "s", "colxerr": 2, "colyerr": 3},
|
|
),
|
|
("worksheet", {"coly": 4, "colx": 0, "type": "l"}),
|
|
],
|
|
)
|
|
self.assertFalse(layer.grouped)
|
|
|
|
def test_series_support_xyz_and_error_roles(self) -> None:
|
|
resolved, labels = worker._resolve_series(
|
|
{"sample": (["x", "y", "z", "x_sd", "y_sd"], [[0, 1, 2, 0.1, 0.2]])},
|
|
[
|
|
{
|
|
"input": "sample",
|
|
"x": "x",
|
|
"y": "y",
|
|
"z": "z",
|
|
"x_error": "x_sd",
|
|
"y_error": "y_sd",
|
|
"label": "测量值",
|
|
}
|
|
],
|
|
)
|
|
self.assertEqual(
|
|
resolved,
|
|
[
|
|
{
|
|
"input": "sample",
|
|
"label": "测量值",
|
|
"x": 0,
|
|
"y": 1,
|
|
"z": 2,
|
|
"x_error": 3,
|
|
"y_error": 4,
|
|
}
|
|
],
|
|
)
|
|
self.assertEqual(labels, {("sample", 1): "测量值"})
|
|
|
|
def test_series_support_second_batch_roles(self) -> None:
|
|
resolved, _ = worker._resolve_series(
|
|
{
|
|
"sample": (
|
|
["x", "mean", "size", "lower", "upper"],
|
|
[[0, 1, 2, 0.5, 1.5]],
|
|
)
|
|
},
|
|
[
|
|
{
|
|
"input": "sample",
|
|
"x": "x",
|
|
"y": "mean",
|
|
"size": "size",
|
|
"lower": "lower",
|
|
"upper": "upper",
|
|
}
|
|
],
|
|
)
|
|
self.assertEqual(
|
|
resolved,
|
|
[
|
|
{
|
|
"input": "sample",
|
|
"label": None,
|
|
"x": 0,
|
|
"y": 1,
|
|
"size": 2,
|
|
"lower": 3,
|
|
"upper": 4,
|
|
}
|
|
],
|
|
)
|
|
|
|
def test_second_batch_value_validation_rejects_invalid_data(self) -> None:
|
|
input_data = {"sample": (["x", "y", "size", "lower", "upper"], [[0, 1, 0, 2, 1]])}
|
|
resolved = [
|
|
{
|
|
"input": "sample",
|
|
"x": 0,
|
|
"y": 1,
|
|
"size": 2,
|
|
"lower": 3,
|
|
"upper": 4,
|
|
}
|
|
]
|
|
with self.assertRaisesRegex(ValueError, "SIZE_VALUE_NOT_POSITIVE"):
|
|
worker._validate_bubble_values(input_data, resolved)
|
|
with self.assertRaisesRegex(ValueError, "BAND_BOUNDS_INVERTED"):
|
|
worker._validate_band_values(input_data, resolved)
|
|
|
|
def test_band_graph_adds_bounds_fill_and_center_line(self) -> None:
|
|
class FakePlot:
|
|
def __init__(self):
|
|
self.fill = None
|
|
|
|
def set_fill_area(self, **arguments):
|
|
self.fill = arguments
|
|
|
|
class FakeLayer:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def add_plot(self, worksheet, **arguments):
|
|
plot = FakePlot()
|
|
self.calls.append((worksheet, arguments, plot))
|
|
return plot
|
|
|
|
class FakeGraph:
|
|
def __init__(self):
|
|
self.layer = FakeLayer()
|
|
|
|
def __getitem__(self, index):
|
|
self.assert_index = index
|
|
return self.layer
|
|
|
|
class FakeOp:
|
|
def __init__(self):
|
|
self.graph = FakeGraph()
|
|
|
|
def new_graph(self, *, template):
|
|
self.template = template
|
|
return self.graph
|
|
|
|
op = FakeOp()
|
|
graph, layer, centers, legend = worker._build_band_graph(
|
|
op,
|
|
{"sample": "worksheet"},
|
|
[{"input": "sample", "x": "x", "y": "mean", "label": "95% CI"}],
|
|
[{"input": "sample", "x": 0, "y": 1, "lower": 2, "upper": 3}],
|
|
)
|
|
self.assertIs(graph, op.graph)
|
|
self.assertIs(layer, op.graph.layer)
|
|
self.assertEqual(op.template, "line")
|
|
self.assertEqual([item[1]["coly"] for item in layer.calls], [3, 2, 1])
|
|
self.assertEqual(layer.calls[0][2].fill, {"type": 9})
|
|
self.assertEqual(centers, [layer.calls[2][2]])
|
|
self.assertEqual(legend, r"\l(3) 95% CI")
|
|
|
|
def test_second_batch_plot_ids_and_templates_are_fixed(self) -> None:
|
|
self.assertEqual(worker.PLOT_CONFIG["box"], ("box", 206))
|
|
self.assertEqual(worker.PLOT_CONFIG["histogram"], ("hist", 219))
|
|
self.assertEqual(worker.PLOT_CONFIG["stacked_column"], ("StackColumn", 213))
|
|
self.assertEqual(worker.PLOT_CONFIG["bubble"], ("scatter", "s"))
|
|
|
|
def test_bubble_execution_parameters(self) -> None:
|
|
class FakeOp:
|
|
@staticmethod
|
|
def modi_col(offset):
|
|
return ("modifier", offset)
|
|
|
|
class FakePlot:
|
|
symbol_size = None
|
|
symbol_sizefactor = None
|
|
|
|
plot = FakePlot()
|
|
worker._configure_bubble_plot(
|
|
FakeOp(), plot, {"style": {"symbol_size": 12}}, {"y": 1, "size": 4}
|
|
)
|
|
self.assertEqual(plot.symbol_size, ("modifier", 3))
|
|
self.assertEqual(plot.symbol_sizefactor, 12)
|
|
|
|
def test_stacked_graph_uses_one_native_xyy_range(self) -> None:
|
|
class FakeSheet:
|
|
def __init__(self):
|
|
self.columns = []
|
|
|
|
def from_list(self, index, values, *, lname):
|
|
self.columns.append((index, values, lname))
|
|
|
|
@staticmethod
|
|
def lt_range(_use_name):
|
|
return "[Book1]1"
|
|
|
|
class FakeLayer:
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.obj = self
|
|
|
|
def add_plot(self, data_range, *, type):
|
|
self.calls.append((data_range, type))
|
|
|
|
def activate(self):
|
|
self.activated = True
|
|
|
|
def group(self, enabled, begin, end):
|
|
self.group_call = (enabled, begin, end)
|
|
|
|
def LT_execute(self, command):
|
|
self.command = command
|
|
|
|
@staticmethod
|
|
def plot_list():
|
|
return ["a", "b"]
|
|
|
|
class FakeGraph:
|
|
def __init__(self):
|
|
self.layer = FakeLayer()
|
|
|
|
def __getitem__(self, index):
|
|
self.index = index
|
|
return self.layer
|
|
|
|
class FakeOp:
|
|
def __init__(self):
|
|
self.sheet = FakeSheet()
|
|
self.graph = FakeGraph()
|
|
|
|
def new_sheet(self, kind, *, lname):
|
|
self.sheet_call = (kind, lname)
|
|
return self.sheet
|
|
|
|
def new_graph(self, *, template):
|
|
self.template = template
|
|
return self.graph
|
|
|
|
op = FakeOp()
|
|
graph, layer, plots = worker._build_stacked_graph(
|
|
op,
|
|
{"sample": (["x", "a", "b"], [[1, 10, 5], [2, 12, 6]])},
|
|
[
|
|
{"input": "sample", "x": "x", "y": "a", "label": "A"},
|
|
{"input": "sample", "x": "x", "y": "b", "label": "B"},
|
|
],
|
|
[
|
|
{"input": "sample", "x": 0, "y": 1},
|
|
{"input": "sample", "x": 0, "y": 2},
|
|
],
|
|
"StackColumn",
|
|
213,
|
|
)
|
|
self.assertIs(graph, op.graph)
|
|
self.assertIs(layer, op.graph.layer)
|
|
self.assertEqual(plots, ["a", "b"])
|
|
self.assertEqual(op.template, "StackColumn")
|
|
self.assertEqual(layer.calls, [("[Book1]1!(1,2:3)", 213)])
|
|
self.assertEqual(layer.group_call, (True, 0, 1))
|
|
self.assertTrue(layer.activated)
|
|
self.assertEqual(layer.command, "layer -b s 1")
|
|
self.assertEqual(
|
|
op.sheet.columns[1:],
|
|
[
|
|
(1, [10, 12], "A"),
|
|
(2, [5, 6], "B"),
|
|
],
|
|
)
|
|
|
|
def test_stacked_graph_rejects_mismatched_x_values(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "STACKED_PLOT_X_VALUES_MISMATCH"):
|
|
worker._build_stacked_graph(
|
|
object(),
|
|
{
|
|
"a": (["x", "y"], [[1, 10], [2, 12]]),
|
|
"b": (["x", "y"], [[1, 5], [3, 6]]),
|
|
},
|
|
[
|
|
{"input": "a", "x": "x", "y": "y"},
|
|
{"input": "b", "x": "x", "y": "y"},
|
|
],
|
|
[
|
|
{"input": "a", "x": 0, "y": 1},
|
|
{"input": "b", "x": 0, "y": 1},
|
|
],
|
|
"StackColumn",
|
|
213,
|
|
)
|
|
|
|
def test_third_batch_plot_ids_and_templates_are_fixed(self) -> None:
|
|
self.assertEqual(worker.PLOT_CONFIG["area"], ("area", 204))
|
|
self.assertEqual(worker.PLOT_CONFIG["stacked_area"], ("stackarea", 214))
|
|
self.assertEqual(worker.PLOT_CONFIG["polar"], ("polar", 192))
|
|
self.assertEqual(worker.PLOT_CONFIG["pie"], ("pie", 225))
|
|
self.assertEqual(worker.PLOT_CONFIG["stacked_bar"], ("bar", 216))
|
|
|
|
def test_stacked_legend_only_contains_requested_series(self) -> None:
|
|
class Label:
|
|
text = ""
|
|
|
|
class Layer:
|
|
legend = Label()
|
|
|
|
def label(self, name):
|
|
self.name = name
|
|
return self.legend
|
|
|
|
layer = Layer()
|
|
worker._replace_series_legend(
|
|
layer,
|
|
[
|
|
{"y": "a", "label": "Phase A"},
|
|
{"y": "phase_b"},
|
|
],
|
|
)
|
|
self.assertEqual(layer.name, "Legend")
|
|
self.assertEqual(layer.legend.text, r"\l(1) Phase A" "\n" r"\l(2) phase_b")
|
|
|
|
def test_multi_panel_legend_uses_actual_data_plot_indices(self) -> None:
|
|
class Label:
|
|
text = ""
|
|
|
|
class Layer:
|
|
legend = Label()
|
|
|
|
def label(self, _name):
|
|
return self.legend
|
|
|
|
class Plot:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def index(self):
|
|
return self.value
|
|
|
|
layer = Layer()
|
|
worker._replace_series_legend(
|
|
layer,
|
|
[
|
|
{"y": "a", "label": "5 C"},
|
|
{"y": "b", "label": "20 C"},
|
|
],
|
|
[Plot(0), Plot(2)],
|
|
)
|
|
self.assertEqual(
|
|
layer.legend.text,
|
|
r"\l(1) 5 C" "\n" r"\l(3) 20 C",
|
|
)
|
|
|
|
def test_panel_title_and_legend_use_page_pixel_coordinates(self) -> None:
|
|
class Label:
|
|
def __init__(self):
|
|
self.values = {}
|
|
self.show = None
|
|
|
|
def set_int(self, name, value):
|
|
self.values[name] = value
|
|
|
|
def set_float(self, name, value):
|
|
self.values[name] = value
|
|
|
|
class Layer:
|
|
def __init__(self):
|
|
self.legend = Label()
|
|
self.title = None
|
|
|
|
def label(self, _name):
|
|
return self.legend
|
|
|
|
def add_label(self, _text):
|
|
self.title = Label()
|
|
return self.title
|
|
|
|
@staticmethod
|
|
def get_float(name):
|
|
return {
|
|
"left": 10,
|
|
"top": 20,
|
|
"width": 40,
|
|
"height": 30,
|
|
}[name]
|
|
|
|
class Graph:
|
|
@staticmethod
|
|
def get_float(name):
|
|
return {
|
|
"width": 1800,
|
|
"height": 1450,
|
|
"resx": 254,
|
|
"resy": 254,
|
|
}[name]
|
|
|
|
layer = Layer()
|
|
graph = Graph()
|
|
worker._apply_legend(
|
|
layer,
|
|
{"enabled": True, "position": "top_right"},
|
|
attach_to_layer=True,
|
|
graph=graph,
|
|
geometry=(10, 20, 40, 30),
|
|
)
|
|
worker._apply_panel_title(
|
|
graph,
|
|
layer,
|
|
(10, 20, 40, 30),
|
|
"Strength",
|
|
{"font_size": 12},
|
|
)
|
|
self.assertEqual(layer.legend.values["attach"], 0)
|
|
self.assertEqual(layer.legend.values["background"], 0)
|
|
self.assertEqual(layer.legend.values["smartpos"], 1)
|
|
self.assertNotIn("left", layer.legend.values)
|
|
self.assertNotIn("top", layer.legend.values)
|
|
self.assertEqual(layer.title.values["attach"], 1)
|
|
self.assertEqual(layer.title.values["background"], 0)
|
|
self.assertEqual(layer.title.values["fsize"], 12)
|
|
self.assertEqual(layer.title.values["left"], 382)
|
|
self.assertEqual(layer.title.values["top"], 229)
|
|
|
|
def test_origin_session_disables_printed_text_baselines(self) -> None:
|
|
class Origin:
|
|
def __init__(self):
|
|
self.variables = []
|
|
|
|
def set_lt_var(self, name, value):
|
|
self.variables.append((name, value))
|
|
|
|
origin = Origin()
|
|
worker._configure_origin_session(origin)
|
|
self.assertEqual(origin.variables, [("@U", 1)])
|
|
|
|
def test_svg_normalizer_removes_only_text_baseline_path(self) -> None:
|
|
source = (
|
|
b'<svg viewBox="0,0 100,100" width="100" height="100">'
|
|
b'<text x="10" y="30" font-size="20">Title</text>\n'
|
|
b'<path d="M 10,14.4 L 70,14.4" '
|
|
b'style="stroke: black;stroke-width: 2;stroke-linecap:butt" '
|
|
b'fill="none"/>'
|
|
b'<path d="M 0,90 L 100,90" '
|
|
b'style="stroke: black;stroke-width: 2" fill="none"/>'
|
|
b'</svg>'
|
|
)
|
|
|
|
normalized, baselines = worker._strip_origin_svg_text_baselines(source)
|
|
|
|
self.assertEqual(len(baselines), 1)
|
|
self.assertNotIn(b'M 10,14.4 L 70,14.4', normalized)
|
|
self.assertIn(b'M 0,90 L 100,90', normalized)
|
|
|
|
def test_pdf_normalizer_preserves_stream_size_and_removes_baseline(self) -> None:
|
|
decoded = b"4 w\n0 J\n10 20 m\n90 20 l\nS\nQ\n"
|
|
compressed = zlib.compress(decoded)
|
|
original = b"%PDF-1.2\nstream\n" + compressed + b"\nendstream\n%%EOF"
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "figure.pdf"
|
|
path.write_bytes(original)
|
|
|
|
removed = worker._strip_origin_pdf_text_baselines(path)
|
|
content = path.read_bytes()
|
|
start = content.index(b"stream\n") + len(b"stream\n")
|
|
end = content.index(b"\nendstream", start)
|
|
normalized = zlib.decompress(content[start:end])
|
|
|
|
self.assertEqual(removed, 1)
|
|
self.assertNotIn(b"10 20 m", normalized)
|
|
self.assertEqual(len(content), len(original))
|
|
|
|
def test_png_is_rendered_from_normalized_pdf_at_requested_width(self) -> None:
|
|
events = []
|
|
|
|
class Pixmap:
|
|
width = 1654
|
|
height = 1181
|
|
|
|
@staticmethod
|
|
def save(path):
|
|
events.append(("save", Path(path).name))
|
|
|
|
class Page:
|
|
class Rect:
|
|
width = 371
|
|
|
|
rect = Rect()
|
|
|
|
@staticmethod
|
|
def get_pixmap(*, matrix, alpha):
|
|
events.append(("render", matrix, alpha))
|
|
return Pixmap()
|
|
|
|
class Document:
|
|
page_count = 1
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
return None
|
|
|
|
@staticmethod
|
|
def __getitem__(_index):
|
|
return Page()
|
|
|
|
class PyMuPDF:
|
|
@staticmethod
|
|
def Matrix(x, y):
|
|
return (x, y)
|
|
|
|
@staticmethod
|
|
def open(path):
|
|
events.append(("open", Path(path).name))
|
|
return Document()
|
|
|
|
with patch.dict(sys.modules, {"pymupdf": PyMuPDF()}):
|
|
size = worker._render_pdf_to_png(
|
|
Path("normalized.pdf"), Path("figure.png"), 1654
|
|
)
|
|
|
|
self.assertEqual(size, (1654, 1181))
|
|
self.assertEqual(
|
|
events,
|
|
[
|
|
("open", "normalized.pdf"),
|
|
("render", (1654 / 371, 1654 / 371), False),
|
|
("save", "figure.png"),
|
|
],
|
|
)
|
|
|
|
def test_origin_arranges_panel_layers_and_reports_resulting_geometry(self) -> None:
|
|
class Layer:
|
|
def __init__(self, geometry):
|
|
self.geometry = geometry
|
|
self.values = {}
|
|
self.commands = []
|
|
self.activated = False
|
|
self.obj = self
|
|
|
|
def set_int(self, name, value):
|
|
self.values[name] = value
|
|
|
|
def activate(self):
|
|
self.activated = True
|
|
|
|
def LT_execute(self, command):
|
|
self.commands.append(command)
|
|
|
|
def get_float(self, name):
|
|
return self.geometry[name]
|
|
|
|
layers = [
|
|
Layer({"left": 9, "top": 14, "width": 38, "height": 32}),
|
|
Layer({"left": 58, "top": 14, "width": 38, "height": 32}),
|
|
Layer({"left": 9, "top": 56, "width": 38, "height": 32}),
|
|
Layer({"left": 58, "top": 56, "width": 38, "height": 32}),
|
|
]
|
|
geometries = worker._arrange_panel_layers(layers, (2, 2))
|
|
|
|
self.assertTrue(layers[0].activated)
|
|
self.assertTrue(all(layer.values["unit"] == 1 for layer in layers))
|
|
self.assertEqual(
|
|
layers[0].commands,
|
|
["layarrange row:=2 col:=2 left:=9 right:=4 top:=14 bottom:=12 xgap:=11 ygap:=10;"],
|
|
)
|
|
self.assertEqual(
|
|
geometries,
|
|
[
|
|
(9, 14, 38, 32),
|
|
(58, 14, 38, 32),
|
|
(9, 56, 38, 32),
|
|
(58, 56, 38, 32),
|
|
],
|
|
)
|
|
|
|
def test_shared_x_hides_upper_row_title_and_tick_labels(self) -> None:
|
|
class Title:
|
|
show = True
|
|
|
|
class Layer:
|
|
def __init__(self):
|
|
self.title = Title()
|
|
self.commands = []
|
|
self.obj = self
|
|
|
|
def label(self, name):
|
|
return self.title if name == "xb" else None
|
|
|
|
def activate(self):
|
|
return None
|
|
|
|
def LT_execute(self, command):
|
|
self.commands.append(command)
|
|
|
|
upper = Layer()
|
|
lower = Layer()
|
|
worker._apply_shared_x_presentation(upper, panel_index=0, rows=2, columns=2, share_x=True)
|
|
worker._apply_shared_x_presentation(lower, panel_index=2, rows=2, columns=2, share_x=True)
|
|
self.assertFalse(upper.title.show)
|
|
self.assertEqual(upper.commands, ["axis -ps X L 0;"])
|
|
self.assertTrue(lower.title.show)
|
|
self.assertEqual(lower.commands, [])
|
|
|
|
def test_auto_legend_delegates_positioning_to_origin(self) -> None:
|
|
class Label:
|
|
def __init__(self):
|
|
self.values = {}
|
|
self.show = None
|
|
|
|
def set_int(self, name, value):
|
|
self.values[name] = value
|
|
|
|
class Layer:
|
|
def __init__(self):
|
|
self.legend = Label()
|
|
|
|
def label(self, _name):
|
|
return self.legend
|
|
|
|
layer = Layer()
|
|
worker._apply_legend(layer, {"enabled": True, "position": "auto"})
|
|
self.assertEqual(layer.legend.values["smartpos"], 1)
|
|
self.assertNotIn("left", layer.legend.values)
|
|
self.assertNotIn("top", layer.legend.values)
|
|
|
|
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()
|