majoplot 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {majoplot-0.1.2 → majoplot-0.1.4}/PKG-INFO +1 -1
  2. majoplot-0.1.4/TODO.md +1 -0
  3. {majoplot-0.1.2 → majoplot-0.1.4}/pyproject.toml +12 -1
  4. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/base.py +6 -6
  5. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/muti_axes_spec.py +0 -1
  6. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/scenarios/VSM/MT.py +2 -2
  7. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/scenarios/VSM/MT_insert.py +2 -2
  8. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/scenarios/VSM/MT_reliability_analysis.py +2 -2
  9. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/utils.py +1 -1
  10. majoplot-0.1.4/tests/README.md +29 -0
  11. majoplot-0.1.4/tests/conftest.py +39 -0
  12. majoplot-0.1.4/tests/domain/test_data_ignore_outliers.py +97 -0
  13. majoplot-0.1.4/tests/domain/test_insert_axes_spec.py +89 -0
  14. majoplot-0.1.4/tests/domain/test_labeldict_group.py +162 -0
  15. majoplot-0.1.4/tests/domain/test_labelvalue.py +104 -0
  16. {majoplot-0.1.2 → majoplot-0.1.4}/uv.lock +61 -1
  17. {majoplot-0.1.2 → majoplot-0.1.4}/.gitignore +0 -0
  18. {majoplot-0.1.2 → majoplot-0.1.4}/LICENSE +0 -0
  19. {majoplot-0.1.2 → majoplot-0.1.4}/README.md +0 -0
  20. {majoplot-0.1.2 → majoplot-0.1.4}/README.zh-CN.md +0 -0
  21. {majoplot-0.1.2 → majoplot-0.1.4}/doc.zh-CN/Label.md +0 -0
  22. {majoplot-0.1.2 → majoplot-0.1.4}/doc.zh-CN/interactive_steps.md +0 -0
  23. {majoplot-0.1.2 → majoplot-0.1.4}/doc.zh-CN/main_design.md +0 -0
  24. {majoplot-0.1.2 → majoplot-0.1.4}/doc.zh-CN/note_of_labtalk.md +0 -0
  25. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/__init__.py +0 -0
  26. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/__main__.py +0 -0
  27. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/app/__init__.py +0 -0
  28. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/app/cli.py +0 -0
  29. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/app/gui.py +0 -0
  30. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/config.json +0 -0
  31. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/importers/PPMS_Resistivity.py +0 -0
  32. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/importers/VSM.py +0 -0
  33. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/importers/XRD.py +0 -0
  34. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/scenarios/PPMS_Resistivity/RT.py +0 -0
  35. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/domain/scenarios/XRD/Compare.py +0 -0
  36. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/gui/__init__.py +0 -0
  37. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/gui/main.py +0 -0
  38. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/infra/plotters/matplot.py +0 -0
  39. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/infra/plotters/origin.py +0 -0
  40. {majoplot-0.1.2 → majoplot-0.1.4}/src/majoplot/infra/plotters/origin_utils/originlab_type_library.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: majoplot
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Automates scenario-specific data preprocessing and plotting workflows for condensed-matter physics labs (OriginLab COM backend + Matplotlib for preview).
5
5
  Project-URL: Homepage, https://github.com/ponyofshadows/majoplot
6
6
  Project-URL: Source, https://github.com/ponyofshadows/majoplot
majoplot-0.1.4/TODO.md ADDED
@@ -0,0 +1 @@
1
+ - take in account the situation that no point in data
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "majoplot"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Automates scenario-specific data preprocessing and plotting workflows for condensed-matter physics labs (OriginLab COM backend + Matplotlib for preview)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -58,3 +58,14 @@ build-backend = "hatchling.build"
58
58
 
59
59
  [tool.hatch.build.targets.wheel]
60
60
  packages = ["src/majoplot"]
61
+
62
+ [dependency-groups]
63
+ dev = [
64
+ "pytest>=9.0.2",
65
+ ]
66
+
67
+ [tool.pytest.ini_options]
68
+ testpaths = ["tests"]
69
+ addopts = "--import-mode=importlib"
70
+ norecursedirs = ["playground", "dist", "build", ".venv", "__pycache__"]
71
+
@@ -38,7 +38,7 @@ class LabelValue(NamedTuple):
38
38
  elif other.unit is None:
39
39
  return False
40
40
  else:
41
- return self.unit.name < other.unit.name
41
+ return self.unit < other.unit
42
42
  else:
43
43
  return True
44
44
  elif isinstance(other.value, Real):
@@ -216,7 +216,7 @@ class Data:
216
216
 
217
217
  @property
218
218
  def y_for_plot(self)->NDArray[np.floating]:
219
- if self._ylim is None:
219
+ if self._y_for_plot is None:
220
220
  self._y_for_plot = self.points_for_plot[:,1]
221
221
  return self._y_for_plot
222
222
 
@@ -320,8 +320,8 @@ class AnnotationSpec(NamedTuple):
320
320
  class ArrowSpec(NamedTuple):
321
321
  point_x:float
322
322
  point_y:float
323
- arrowstyle:str = "->",
324
- color:str = "black",
323
+ arrowstyle:str = "->"
324
+ color:str = "black"
325
325
  linewidth:float = 1.0
326
326
 
327
327
  class IgnoreOutlierSpec(NamedTuple):
@@ -350,10 +350,10 @@ class MutiAxesSpec(Protocol):
350
350
  ...
351
351
 
352
352
  # ======== Proj & folder ========
353
- class folder(dict[str:Figure]):
353
+ class folder(dict[str,Figure]):
354
354
  pass
355
355
 
356
- class Project(dict[str:folder]):
356
+ class Project(dict[str,folder]):
357
357
  pass
358
358
 
359
359
  # ======== Scenario ========
@@ -77,7 +77,6 @@ class InsertAxesSpec(NamedTuple):
77
77
  else:
78
78
  break
79
79
  histograms[y, x] = h
80
- import matplotlib.pyplot as plt
81
80
  @dataclass(slots=True)
82
81
  class Rectangle:
83
82
  x0:int|np.int_
@@ -12,7 +12,7 @@ H = "Magnetic Field (Oe)"
12
12
 
13
13
 
14
14
  class MT:
15
- data_summary_label_names = ["H","cooling_type"]
15
+ data_summary_label_names = ["mass","H","cooling_type"]
16
16
  axes_label_names = ("material","date","raw_data", "H")
17
17
  figure_label_names = ("material","date", "raw_data","H")
18
18
  figure_summary_label_names = ("raw_data","date")
@@ -76,7 +76,7 @@ class MT:
76
76
  check_deque.append(point)
77
77
  if len(check_deque) == 2:
78
78
  # not the same H?
79
- if abs(check_deque[1][iH] - H_stage) > 2:
79
+ if abs(check_deque[1][iH] - H_stage) > 1.5:
80
80
  append_data()
81
81
  current_points = [check_deque.pop()]
82
82
  H_stage = round(current_points[0][iH])
@@ -13,7 +13,7 @@ H = "Magnetic Field (Oe)"
13
13
 
14
14
 
15
15
  class MT_insert:
16
- data_summary_label_names = ["H","cooling_type"]
16
+ data_summary_label_names = ["mass","H","cooling_type"]
17
17
  axes_label_names = ("material","date","raw_data", "H")
18
18
  figure_label_names = ("material","date", "raw_data")
19
19
  figure_summary_label_names = ("raw_data","date")
@@ -77,7 +77,7 @@ class MT_insert:
77
77
  check_deque.append(point)
78
78
  if len(check_deque) == 2:
79
79
  # not the same H?
80
- if abs(check_deque[1][iH] - H_stage) > 2:
80
+ if abs(check_deque[1][iH] - H_stage) > 1.5:
81
81
  append_data()
82
82
  current_points = [check_deque.pop()]
83
83
  H_stage = round(current_points[0][iH])
@@ -24,7 +24,7 @@ _headers_group = [
24
24
  ]
25
25
 
26
26
  class MT_reliability_analysis:
27
- data_summary_label_names = ["H","cooling_type"]
27
+ data_summary_label_names = ["mass","H","cooling_type"]
28
28
  axes_label_names = ("material","date","raw_data", "H", "Y_axis")
29
29
  figure_label_names = ("material","date", "raw_data","H")
30
30
  figure_summary_label_names = ("raw_data","date")
@@ -88,7 +88,7 @@ class MT_reliability_analysis:
88
88
  check_deque.append(point)
89
89
  if len(check_deque) == 2:
90
90
  # not the same H?
91
- if abs(check_deque[1][iH] - H_stage) > 2:
91
+ if abs(check_deque[1][iH] - H_stage) > 1.5:
92
92
  append_data()
93
93
  current_points = [check_deque.pop()]
94
94
  H_stage = round(current_points[0][iH])
@@ -8,7 +8,7 @@ def group_into_axes(all_datas:Iterable[Data], scenario:Scenario)->list[Axes]:
8
8
  make_axes_spec = scenario.make_axes_spec
9
9
 
10
10
  axes_labels_and_data_pools, _ = LabelDict.group(
11
- ((data.labels, data) for data in all_datas if not data.unused),
11
+ ((data.labels, data) for data in all_datas if ( (not data.unused) and (data.points.size > 0) )),
12
12
  group_label_names=axes_label_names,
13
13
  )
14
14
  axes_pool = []
@@ -0,0 +1,29 @@
1
+ # majoplot tests
2
+
3
+ This folder contains **pytest** tests added without modifying any production code.
4
+
5
+ ## Quick start
6
+
7
+ If you use `uv`:
8
+
9
+ ```bash
10
+ uv sync --extra test
11
+ uv run pytest
12
+ ```
13
+
14
+ If you use `pip`:
15
+
16
+ ```bash
17
+ pip install -e .
18
+ pip install pytest
19
+ pytest
20
+ ```
21
+
22
+ ## Test scope
23
+
24
+ - `tests/domain/`: Pure-logic tests (no GUI, no Origin COM). These should run on any OS.
25
+
26
+ ## Notes
27
+
28
+ - We intentionally avoid testing Origin COM and GUI here because they are environment-dependent.
29
+ A recommended next step is to add optional smoke tests behind a marker, e.g. `@pytest.mark.origin`.
@@ -0,0 +1,39 @@
1
+ """Pytest configuration for majoplot.
2
+
3
+ Important constraints
4
+ ---------------------
5
+ - We do NOT edit or patch any production source files.
6
+ - We only add tests.
7
+
8
+ Why we touch sys.path
9
+ ---------------------
10
+ This repository uses a `src/` layout (package code lives under `src/majoplot`).
11
+ When running tests from a plain source checkout, Python may not automatically
12
+ find `src/` on the import path.
13
+
14
+ Adding `src/` to `sys.path` inside tests is a common, minimal approach that
15
+ keeps tests runnable without requiring an editable install.
16
+
17
+ If you prefer, you can remove this file and run tests after an editable install:
18
+ uv pip install -e .
19
+ uv run pytest
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import sys
25
+ from pathlib import Path
26
+
27
+
28
+ def pytest_configure() -> None:
29
+ """Add the repository's `src/` directory to `sys.path`.
30
+
31
+ English comment: This makes `import majoplot` work in a fresh checkout.
32
+ """
33
+
34
+ repo_root = Path(__file__).resolve().parents[1]
35
+ src_dir = repo_root / "src"
36
+
37
+ # English comment: Insert at the front so local sources win over any
38
+ # globally-installed `majoplot` package the developer might have.
39
+ sys.path.insert(0, str(src_dir))
@@ -0,0 +1,97 @@
1
+ """Tests for `Data.ignore_outliers` behavior.
2
+
3
+ The `Data` class can optionally filter outliers using a sliding-window heuristic.
4
+ This is a pure, deterministic transformation of the `points` array.
5
+
6
+ Why this matters
7
+ ----------------
8
+ Outlier filtering changes plotted points and therefore impacts:
9
+ - axis limits
10
+ - marker/line appearance (dense vs sparse)
11
+ - derived downstream computations
12
+
13
+ These tests intentionally use small arrays so expected outputs are easy to verify.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+ from majoplot.domain.base import Data, IgnoreOutlierSpec, LabelDict
21
+
22
+
23
+ def _mk_data(points: np.ndarray, *, ignore: IgnoreOutlierSpec | None = None) -> Data:
24
+ """Helper: build a Data object with minimal required fields."""
25
+
26
+ return Data(
27
+ labels=LabelDict({}),
28
+ _headers=("x", "y"),
29
+ points=points.astype(float),
30
+ ignore_outliers=ignore,
31
+ )
32
+
33
+
34
+ def test_points_for_plot_no_ignore_outliers_returns_original_points() -> None:
35
+ """If ignore_outliers is None, points_for_plot should be the original array."""
36
+
37
+ pts = np.array([[0, 0], [1, 1], [2, 2]], dtype=float)
38
+ d = _mk_data(pts, ignore=None)
39
+
40
+ # English comment: We expect identity or at least identical values.
41
+ assert np.allclose(d.points_for_plot, pts)
42
+
43
+
44
+ def test_ignore_outliers_drops_middle_spike() -> None:
45
+ """A large spike at the middle element should be dropped.
46
+
47
+ The algorithm (in production code) inspects triples (p0, p1, p2) and checks
48
+ whether the middle y-value is an outlier compared to endpoints.
49
+
50
+ We craft points where:
51
+ - p0 -> p2 is small change
52
+ - p0 -> p1 is huge change
53
+
54
+ Under those conditions, p1 is removed.
55
+ """
56
+
57
+ pts = np.array(
58
+ [
59
+ [0.0, 0.0],
60
+ [1.0, 100.0], # outlier
61
+ [2.0, 1.0],
62
+ [3.0, 2.0],
63
+ ]
64
+ )
65
+
66
+ d = _mk_data(pts, ignore=IgnoreOutlierSpec(min_gap_base=1.0, min_gap_multiple=20.0))
67
+
68
+ filtered = d.points_for_plot
69
+
70
+ # English comment: The outlier point [1, 100] should not appear.
71
+ assert filtered.shape[0] == 3
72
+ assert not np.any((filtered == np.array([1.0, 100.0])).all(axis=1))
73
+
74
+
75
+ def test_ignore_outliers_cache_refreshes_when_spec_changes() -> None:
76
+ """Changing ignore_outliers spec should refresh the cached filtered points."""
77
+
78
+ pts = np.array(
79
+ [
80
+ [0.0, 0.0],
81
+ [1.0, 100.0],
82
+ [2.0, 1.0],
83
+ ]
84
+ )
85
+
86
+ d = _mk_data(pts, ignore=IgnoreOutlierSpec(min_gap_base=1.0, min_gap_multiple=20.0))
87
+
88
+ first = d.points_for_plot.copy()
89
+
90
+ # English comment: Make the filter less aggressive so it may keep the middle.
91
+ d.ignore_outliers = IgnoreOutlierSpec(min_gap_base=1.0, min_gap_multiple=2000.0)
92
+
93
+ second = d.points_for_plot
94
+
95
+ # English comment: We don't mandate the exact output; we only require that
96
+ # the cache was recomputed (i.e., values can differ).
97
+ assert not np.array_equal(first, second) or second.shape != first.shape
@@ -0,0 +1,89 @@
1
+ """Tests for `InsertAxesSpec.default`.
2
+
3
+ `InsertAxesSpec.default` computes a rectangle (in figure-relative coordinates)
4
+ where an inset axes can be placed without overlapping existing data points.
5
+
6
+ Why this matters
7
+ ----------------
8
+ Inset placement is a visually sensitive feature. If the algorithm returns
9
+ nonsensical coordinates (negative, >1, zero size) you will see glitches like:
10
+ - inset axes outside the figure
11
+ - inset axes covering data points
12
+ - random-looking inset placement
13
+
14
+ These tests focus on *invariants* rather than exact numeric outputs.
15
+ Exact coordinates may change as the algorithm is improved.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+
22
+ from majoplot.domain.base import Axes, AxesSpec, Data, IgnoreOutlierSpec, LabelDict, fail_signal
23
+ from majoplot.domain.muti_axes_spec import InsertAxesSpec
24
+
25
+
26
+ def _mk_data(points: np.ndarray) -> Data:
27
+ """Helper: create a minimal Data instance."""
28
+
29
+ return Data(
30
+ labels=LabelDict({}),
31
+ _headers=("x", "y"),
32
+ points=points.astype(float),
33
+ ignore_outliers=None,
34
+ )
35
+
36
+
37
+ def _mk_axes(points: np.ndarray) -> Axes:
38
+ """Helper: create a minimal Axes instance holding exactly one Data."""
39
+
40
+ spec = AxesSpec(x_axis_title="x", y_axis_title="y")
41
+ return Axes(spec=spec, labels=LabelDict({}), data_pool=[_mk_data(points)])
42
+
43
+
44
+ def test_insert_axes_spec_returns_fail_when_more_than_two_axes() -> None:
45
+ """Design rule: current implementation refuses len(axes_pool) > 2."""
46
+
47
+ axes_pool = [_mk_axes(np.array([[0.0, 0.0], [1.0, 1.0]])) for _ in range(3)]
48
+
49
+ res = InsertAxesSpec.default(figure_size=(3.4, 2.6), axes_pool=axes_pool)
50
+ assert res is fail_signal
51
+
52
+
53
+ def test_insert_axes_spec_basic_invariants() -> None:
54
+ """Basic invariants for a valid inset rectangle.
55
+
56
+ English comment:
57
+ - x, y are left-bottom anchor in normalized figure coordinates.
58
+ - width, height are normalized sizes.
59
+ - They should lie within [0, 1] and be positive.
60
+
61
+ We place a few points near the bottom-left corner so there is free space
62
+ elsewhere for the algorithm to pick.
63
+ """
64
+
65
+ pts = np.array(
66
+ [
67
+ [0.0, 0.0],
68
+ [0.1, 0.05],
69
+ [0.2, 0.1],
70
+ [0.3, 0.2],
71
+ ]
72
+ )
73
+
74
+ axes_pool = [_mk_axes(pts)]
75
+
76
+ res = InsertAxesSpec.default(figure_size=(3.4, 2.6), axes_pool=axes_pool)
77
+
78
+ assert res is not fail_signal
79
+ assert isinstance(res, InsertAxesSpec)
80
+
81
+ # English comment: Geometry must be meaningful.
82
+ assert 0.0 <= res.x <= 1.0
83
+ assert 0.0 <= res.y <= 1.0
84
+ assert res.width > 0.0
85
+ assert res.height > 0.0
86
+
87
+ # English comment: The inset should not start beyond the figure.
88
+ assert res.x + res.width <= 1.0 + 1e-6
89
+ assert res.y + res.height <= 1.0 + 1e-6
@@ -0,0 +1,162 @@
1
+ """Tests for `LabelDict.group`.
2
+
3
+ This is one of the most important pure-logic utilities in majoplot.
4
+ It groups arbitrary elements based on a subset of labels.
5
+
6
+ Why this matters
7
+ ----------------
8
+ Downstream plotting logic often assumes:
9
+ - Grouping is deterministic.
10
+ - Sorting within a group is deterministic.
11
+ - Group member limiting (subgrouping) produces stable subgroup ids.
12
+
13
+ If any of these assumptions break, you may observe symptoms like:
14
+ - Styles seemingly "randomly" applied to curves.
15
+ - Legend items not matching curve order.
16
+
17
+ We keep tests small and explicit so a failure immediately pinpoints the issue.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from majoplot.domain.base import LabelDict, LabelValue
23
+
24
+
25
+ def _ld(**kwargs: object) -> LabelDict:
26
+ """Helper: build a LabelDict from simple key=value inputs.
27
+
28
+ English comment:
29
+ - Values are wrapped into LabelValue.
30
+ - This keeps test data compact.
31
+ """
32
+
33
+ return LabelDict({k: (v if isinstance(v, LabelValue) else LabelValue(v)) for k, v in kwargs.items()})
34
+
35
+
36
+ def test_group_basic_two_groups() -> None:
37
+ """Elements should be partitioned by the group label values."""
38
+
39
+ pairs = [
40
+ (_ld(a=1, b=10), "e1"),
41
+ (_ld(a=1, b=11), "e2"),
42
+ (_ld(a=2, b=20), "e3"),
43
+ ]
44
+
45
+ groups, remains = LabelDict.group(
46
+ pairs,
47
+ group_label_names=["a"],
48
+ summary_label_names=["a"],
49
+ )
50
+
51
+ assert remains == []
52
+
53
+ # English comment: `LabelDict.group` returns pairs of
54
+ # (group_label_dict: LabelDict, members: list[Any]).
55
+ # The group_label_dict directly stores the grouping labels.
56
+ by_a = {g["a"].value: members for g, members in groups}
57
+ assert by_a[1] == ["e1", "e2"]
58
+ assert by_a[2] == ["e3"]
59
+
60
+
61
+ def test_group_missing_label_goes_to_remains() -> None:
62
+ """If a LabelDict is missing any group label, the element goes to remains."""
63
+
64
+ pairs = [
65
+ (_ld(a=1), "ok"),
66
+ (_ld(b=2), "missing"),
67
+ ]
68
+
69
+ groups, remains = LabelDict.group(
70
+ pairs,
71
+ group_label_names=["a"],
72
+ )
73
+
74
+ assert [m for m in remains] == ["missing"]
75
+ assert len(groups) == 1
76
+
77
+
78
+ def test_group_sorting_within_group_is_deterministic() -> None:
79
+ """sort_label_names should sort group members by the specified label(s)."""
80
+
81
+ pairs = [
82
+ (_ld(g=1, s=3), "e3"),
83
+ (_ld(g=1, s=1), "e1"),
84
+ (_ld(g=1, s=2), "e2"),
85
+ ]
86
+
87
+ groups, remains = LabelDict.group(
88
+ pairs,
89
+ group_label_names=["g"],
90
+ sort_label_names=("s",),
91
+ )
92
+
93
+ assert remains == []
94
+ assert len(groups) == 1
95
+
96
+ (_group_labels, members) = groups[0]
97
+ assert members == ["e1", "e2", "e3"]
98
+
99
+
100
+ def test_group_member_limit_creates_subgroups_with_ids() -> None:
101
+ """group_member_limit should split a group into multiple subgroups.
102
+
103
+ English comment:
104
+ - subgroup id is derived from position // limit.
105
+ - the returned LabelDict should carry that subgroup id.
106
+ """
107
+
108
+ pairs = [(_ld(g=1, s=i), f"e{i}") for i in range(5)]
109
+
110
+ groups, remains = LabelDict.group(
111
+ pairs,
112
+ group_label_names=["g"],
113
+ sort_label_names=("s",),
114
+ group_member_limit=2,
115
+ )
116
+
117
+ assert remains == []
118
+
119
+ # NOTE: The current implementation has a known behavior/bug:
120
+ # it only cuts the first subgroup at exactly the limit, and then puts the
121
+ # remaining members into a second subgroup.
122
+ #
123
+ # We assert the CURRENT behavior here so the test suite is stable and can
124
+ # catch unintentional changes.
125
+ assert [members for (_lbl, members) in groups] == [["e0", "e1"], ["e2", "e3", "e4"]]
126
+ # English comment:
127
+ # The current implementation assigns subgroup id based on the *last* element
128
+ # index of the subgroup, because the subgroup LabelDict is re-created on
129
+ # each iteration and the last created one is stored when the subgroup is
130
+ # appended. For members [2,3,4], the last index is 4, and 4//2 == 2.
131
+ assert [lbl.subgroup for (lbl, _members) in groups] == [0, 2]
132
+
133
+
134
+ def test_group_member_limit_expected_chunking_documented_as_xfail() -> None:
135
+ """Desired behavior for subgrouping (documented as xfail).
136
+
137
+ English comment:
138
+ The more intuitive behavior is to chunk each group into blocks of size
139
+ `group_member_limit`:
140
+ [0,1], [2,3], [4]
141
+
142
+ The current production implementation does not do this yet.
143
+ We mark this as xfail to record the intended contract for future fixes.
144
+ """
145
+
146
+ import pytest
147
+
148
+ pairs = [(_ld(g=1, s=i), f"e{i}") for i in range(5)]
149
+
150
+ groups, remains = LabelDict.group(
151
+ pairs,
152
+ group_label_names=["g"],
153
+ sort_label_names=("s",),
154
+ group_member_limit=2,
155
+ )
156
+
157
+ assert remains == []
158
+
159
+ pytest.xfail("Known issue: subgroup chunking does not fully respect the limit.")
160
+
161
+ assert [members for (_lbl, members) in groups] == [["e0", "e1"], ["e2", "e3"], ["e4"]]
162
+ assert [lbl.subgroup for (lbl, _members) in groups] == [0, 1, 2]
@@ -0,0 +1,104 @@
1
+ """Tests for the `LabelValue` type.
2
+
3
+ These tests focus on *ordering* and *string formatting* because those behaviors
4
+ feed directly into grouping, sorting, and legend/label generation.
5
+
6
+ Why this matters
7
+ ----------------
8
+ In plotting pipelines, subtle ordering bugs tend to appear as "random" or
9
+ "inverted" style application (e.g., legend labels swapped, colors applied to the
10
+ wrong curve). Deterministic ordering in foundational types like `LabelValue`
11
+ reduces such systemic issues.
12
+
13
+ We do NOT attempt to change production behavior here.
14
+ If a test fails, that's a signal that the current implementation is inconsistent
15
+ with the behavior we want to rely on.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import pytest
21
+
22
+ from majoplot.domain.base import LabelValue
23
+
24
+
25
+ def test_labelvalue_str_no_unit() -> None:
26
+ """If unit is None, __str__ should be just the value."""
27
+
28
+ lv = LabelValue(3.14)
29
+ assert str(lv) == "3.14"
30
+
31
+
32
+ def test_labelvalue_str_unit_postfix() -> None:
33
+ """If unit is set and unit_as_postfix=True, unit should be appended."""
34
+
35
+ lv = LabelValue(10, "K", True)
36
+ assert str(lv) == "10K"
37
+
38
+
39
+ def test_labelvalue_str_unit_prefix() -> None:
40
+ """If unit_as_postfix=False, unit should be placed before the value."""
41
+
42
+ lv = LabelValue(10, "K", False)
43
+ assert str(lv) == "K10"
44
+
45
+
46
+ def test_labelvalue_numeric_vs_numeric_same_unit_orders_by_value() -> None:
47
+ """Numeric values with the same unit should compare by numeric value."""
48
+
49
+ a = LabelValue(1, "K")
50
+ b = LabelValue(2, "K")
51
+ assert a < b
52
+
53
+
54
+ def test_labelvalue_numeric_unit_none_sorts_before_with_unit() -> None:
55
+ """Implementation detail: unit=None is considered "smaller" than unit!=None.
56
+
57
+ English comment:
58
+ This matches the current implementation (unit=None returns True in the
59
+ ordering logic when comparing numeric values with different units).
60
+ """
61
+
62
+ a = LabelValue(1, None)
63
+ b = LabelValue(1, "K")
64
+ assert a < b
65
+
66
+
67
+ def test_labelvalue_numeric_unit_lexicographic_when_units_differ() -> None:
68
+ """When both are numeric but units differ, ordering falls back to unit string."""
69
+
70
+ a = LabelValue(1, "A")
71
+ b = LabelValue(1, "B")
72
+ assert a < b
73
+
74
+
75
+ def test_labelvalue_numeric_always_less_than_non_numeric() -> None:
76
+ """Current rule: numeric values compare as smaller than non-numeric values."""
77
+
78
+ a = LabelValue(1, "K")
79
+ b = LabelValue("foo", None)
80
+ assert a < b
81
+
82
+
83
+ def test_labelvalue_non_numeric_compares_by_string() -> None:
84
+ """Non-numeric values are compared by their string representation."""
85
+
86
+ a = LabelValue("A")
87
+ b = LabelValue("B")
88
+ assert a < b
89
+
90
+
91
+ @pytest.mark.parametrize(
92
+ "value",
93
+ [True, False],
94
+ )
95
+ def test_labelvalue_bool_is_unsupported_and_ordering_is_undefined(value: bool) -> None:
96
+ """The docstring says bool is unsupported.
97
+
98
+ English comment:
99
+ We don't enforce a hard failure (production code does not raise).
100
+ We only document the boundary by asserting that the type is bool.
101
+ """
102
+
103
+ lv = LabelValue(value)
104
+ assert isinstance(lv.value, bool)
@@ -2,6 +2,15 @@ version = 1
2
2
  revision = 3
3
3
  requires-python = ">=3.11"
4
4
 
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
12
+ ]
13
+
5
14
  [[package]]
6
15
  name = "contourpy"
7
16
  version = "1.3.3"
@@ -142,6 +151,15 @@ wheels = [
142
151
  { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" },
143
152
  ]
144
153
 
154
+ [[package]]
155
+ name = "iniconfig"
156
+ version = "2.3.0"
157
+ source = { registry = "https://pypi.org/simple" }
158
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
159
+ wheels = [
160
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
161
+ ]
162
+
145
163
  [[package]]
146
164
  name = "kiwisolver"
147
165
  version = "1.4.9"
@@ -234,7 +252,7 @@ wheels = [
234
252
 
235
253
  [[package]]
236
254
  name = "majoplot"
237
- version = "0.1.0"
255
+ version = "0.1.3"
238
256
  source = { editable = "." }
239
257
  dependencies = [
240
258
  { name = "matplotlib" },
@@ -242,6 +260,11 @@ dependencies = [
242
260
  { name = "pywin32", marker = "sys_platform == 'win32'" },
243
261
  ]
244
262
 
263
+ [package.dev-dependencies]
264
+ dev = [
265
+ { name = "pytest" },
266
+ ]
267
+
245
268
  [package.metadata]
246
269
  requires-dist = [
247
270
  { name = "matplotlib", specifier = ">=3.8" },
@@ -249,6 +272,9 @@ requires-dist = [
249
272
  { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=311" },
250
273
  ]
251
274
 
275
+ [package.metadata.requires-dev]
276
+ dev = [{ name = "pytest", specifier = ">=9.0.2" }]
277
+
252
278
  [[package]]
253
279
  name = "matplotlib"
254
280
  version = "3.10.7"
@@ -490,6 +516,24 @@ wheels = [
490
516
  { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
491
517
  ]
492
518
 
519
+ [[package]]
520
+ name = "pluggy"
521
+ version = "1.6.0"
522
+ source = { registry = "https://pypi.org/simple" }
523
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
524
+ wheels = [
525
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
526
+ ]
527
+
528
+ [[package]]
529
+ name = "pygments"
530
+ version = "2.19.2"
531
+ source = { registry = "https://pypi.org/simple" }
532
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
533
+ wheels = [
534
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
535
+ ]
536
+
493
537
  [[package]]
494
538
  name = "pyparsing"
495
539
  version = "3.2.5"
@@ -499,6 +543,22 @@ wheels = [
499
543
  { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" },
500
544
  ]
501
545
 
546
+ [[package]]
547
+ name = "pytest"
548
+ version = "9.0.2"
549
+ source = { registry = "https://pypi.org/simple" }
550
+ dependencies = [
551
+ { name = "colorama", marker = "sys_platform == 'win32'" },
552
+ { name = "iniconfig" },
553
+ { name = "packaging" },
554
+ { name = "pluggy" },
555
+ { name = "pygments" },
556
+ ]
557
+ sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
558
+ wheels = [
559
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
560
+ ]
561
+
502
562
  [[package]]
503
563
  name = "python-dateutil"
504
564
  version = "2.9.0.post0"
File without changes
File without changes
File without changes
File without changes
File without changes