tree-annotated-plot 0.2.0__tar.gz → 0.2.1__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 (42) hide show
  1. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/CHANGELOG.md +13 -0
  2. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/PKG-INFO +1 -1
  3. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/pyproject.toml +1 -1
  4. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/_plot.py +119 -67
  5. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_introspection.py +68 -1
  6. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_plot.py +67 -0
  7. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/.github/workflows/docs.yml +0 -0
  8. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/.github/workflows/release.yml +0 -0
  9. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/.github/workflows/tests.yml +0 -0
  10. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/.gitignore +0 -0
  11. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/CLAUDE.md +0 -0
  12. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/LICENSE +0 -0
  13. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/README.md +0 -0
  14. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/docs/cli.md +0 -0
  15. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/docs/examples.md +0 -0
  16. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/docs/index.md +0 -0
  17. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/docs/python-api.md +0 -0
  18. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/examples/fetch_auspice_data.py +0 -0
  19. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/examples/flu-seqneut-2025to2026_titer_charts.py +0 -0
  20. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/examples/synthetic_example.py +0 -0
  21. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/mkdocs.yml +0 -0
  22. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/scripts/build_docs.sh +0 -0
  23. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/scripts/check.sh +0 -0
  24. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/scripts/generate_docs_assets.py +0 -0
  25. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/__init__.py +0 -0
  26. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/_color.py +0 -0
  27. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/_config.py +0 -0
  28. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/_tree.py +0 -0
  29. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/src/tree_annotated_plot/cli.py +0 -0
  30. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_appearance_tuning.py +0 -0
  31. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_branch_length.py +0 -0
  32. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_chart_loader.py +0 -0
  33. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_cli.py +0 -0
  34. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_color_tree.py +0 -0
  35. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_examples_run.py +0 -0
  36. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_label_connection.py +0 -0
  37. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_plot_config.py +0 -0
  38. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_real_data.py +0 -0
  39. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_reconciliation.py +0 -0
  40. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_scale_bar.py +0 -0
  41. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_style.py +0 -0
  42. {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.1}/tests/test_tree_location.py +0 -0
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.1] - 2026-05-06
9
+
10
+ ### Fixed
11
+
12
+ - Apply tree-tip ordering to chart axes that use untyped Altair
13
+ shorthand (`alt.Y("strain")` without a `:N` / `:O` type suffix).
14
+ Previously the sort override was silently skipped, so the chart
15
+ rendered in data order instead of tree order.
16
+ - An internal consistency check now raises if the spec-level walk
17
+ and the live-object walk over the user's chart ever disagree on
18
+ the number of strain-axis encodings, so silent skips like the
19
+ one above can't recur in another shape.
20
+
8
21
  ## [0.2.0] - 2026-05-06
9
22
 
10
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tree-annotated-plot
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree.
5
5
  Project-URL: Documentation, https://jbloomlab.github.io/tree-annotated-plot/
6
6
  Project-URL: Homepage, https://github.com/jbloomlab/tree-annotated-plot
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tree-annotated-plot"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -7,6 +7,7 @@ import json
7
7
  import math
8
8
  import re
9
9
  import warnings
10
+ from collections.abc import Iterator
10
11
  from html.parser import HTMLParser
11
12
  from pathlib import Path
12
13
  from typing import Any, Literal
@@ -223,11 +224,15 @@ def _build(
223
224
  legend_show=config.tree_color_legend_show,
224
225
  )
225
226
 
226
- new_chart = _apply_tree_order_to_chart_object(
227
- chart, config.chart_strain_field, tip_names
228
- )
229
- if config.connect_leader_to_label:
230
- _suppress_chart_strain_axis(new_chart, config.chart_strain_field)
227
+ new_chart = copy.deepcopy(chart)
228
+ suppress_axis_chrome = config.connect_leader_to_label
229
+ n_hits = 0
230
+ for ch in _iter_strain_axis_channels(new_chart, config.chart_strain_field):
231
+ ch.sort = list(tip_names)
232
+ if suppress_axis_chrome:
233
+ ch.axis = alt.Axis(labels=False, ticks=False, domain=False, title=None)
234
+ n_hits += 1
235
+ _check_walker_hits("strain-axis update", n_hits, len(axis_hits), axis)
231
236
  hoisted_config, hoisted_other = _pop_toplevel_only_attrs(new_chart)
232
237
 
233
238
  combined = _concat_for_location(
@@ -916,11 +921,45 @@ def _enumerate_tree_tip_attrs(
916
921
  return out
917
922
 
918
923
 
919
- def _apply_tree_order_to_chart_object(
920
- chart: alt.TopLevelMixin, chart_strain_field: str, sort_order: list[str]
921
- ) -> alt.TopLevelMixin:
922
- """Return a deepcopy of the user's chart with `sort=sort_order` applied
923
- to every axis encoding that references `chart_strain_field`.
924
+ def _check_walker_hits(operation: str, actual: int, expected: int, axis: str) -> None:
925
+ """Cross-check the live-object walk's hit count against the spec walk's.
926
+
927
+ `_find_strain_encoding` walks the chart's serialized dict form to count
928
+ how many `x`/`y` strain encodings the chart has and to validate
929
+ consistency (axis agreement, type=nominal/ordinal, etc.). The live
930
+ iteration in `_build` (driven by `_iter_strain_axis_channels`) must
931
+ visit exactly the same number of encodings — fewer would silently skip
932
+ applying the tree's tip order (or the axis suppression) to part of the
933
+ chart, more would mean we mutated structures the dict walker doesn't
934
+ know about.
935
+
936
+ The check is symmetric (`!=`) rather than one-sided (`<`) because
937
+ either direction signals that spec-level introspection and
938
+ live-object traversal have diverged, and continuing would render an
939
+ unverified chart.
940
+ """
941
+ if actual != expected:
942
+ raise RuntimeError(
943
+ f"internal consistency check failed for {operation!r}: "
944
+ f"_find_strain_encoding located {expected} strain {axis!r}-axis "
945
+ f"encoding(s) in the chart spec, but the live-object walk "
946
+ f"updated {actual}. Spec-level and live-object traversal have "
947
+ "diverged, which would render the chart with a wrong tip order "
948
+ "or leave axis chrome behind. Please file a bug at "
949
+ "https://github.com/jbloomlab/tree-annotated-plot/issues with a "
950
+ "minimal reproducer."
951
+ )
952
+
953
+
954
+ def _iter_strain_axis_channels(node: Any, chart_strain_field: str) -> Iterator[Any]:
955
+ """Yield every live x/y channel object whose field matches
956
+ `chart_strain_field`.
957
+
958
+ Pure read — no mutation, no count. The caller iterates and applies
959
+ whatever mutation it needs (currently `sort` and, when
960
+ `connect_leader_to_label=True`, an axis-suppression `alt.Axis(...)`),
961
+ counting hits as it goes so the cross-check in `_check_walker_hits`
962
+ can compare against the spec walker.
924
963
 
925
964
  Walks the live altair object tree (Chart / LayerChart / FacetChart /
926
965
  HConcatChart / VConcatChart / ConcatChart) rather than its dict form,
@@ -929,62 +968,25 @@ def _apply_tree_order_to_chart_object(
929
968
  dict approach has to fight. Modifying the object in place is robust as
930
969
  long as altair's container attribute names hold (.hconcat / .vconcat /
931
970
  .concat / .layer / .spec / .encoding), which is stable in altair 5+.
971
+ `FacetChart.spec` is recursed into unconditionally so we descend to the
972
+ inner LayerChart / Chart that actually carries the encoding (gating on
973
+ `spec.encoding is not None` would skip LayerCharts, whose encodings
974
+ live on their layers rather than at the top level).
932
975
  """
933
- new_chart = copy.deepcopy(chart)
934
- _walk_and_apply_sort(new_chart, chart_strain_field, sort_order)
935
- return new_chart
936
-
937
-
938
- def _walk_and_apply_sort(
939
- node: Any, chart_strain_field: str, sort_order: list[str]
940
- ) -> None:
941
- """Recursively set sort on every encoding whose field == chart_strain_field
942
- on a live altair chart object."""
943
976
  enc = _live_attr(node, "encoding")
944
977
  if enc is not None:
945
978
  for channel in ("x", "y"):
946
979
  ch = _live_attr(enc, channel)
947
980
  if ch is not None and _channel_field(ch) == chart_strain_field:
948
- ch.sort = list(sort_order)
981
+ yield ch
949
982
  for attr in ("hconcat", "vconcat", "concat", "layer"):
950
983
  sub = _live_attr(node, attr)
951
984
  if isinstance(sub, list):
952
985
  for s in sub:
953
- _walk_and_apply_sort(s, chart_strain_field, sort_order)
954
- # FacetChart.spec is the chart being faceted; recurse unconditionally so
955
- # we descend into the inner LayerChart / Chart that actually carries the
956
- # encoding. Gating on `spec.encoding is not None` was wrong because a
957
- # LayerChart has no top-level encoding — its encodings live on its layers.
986
+ yield from _iter_strain_axis_channels(s, chart_strain_field)
958
987
  spec = _live_attr(node, "spec")
959
988
  if spec is not None:
960
- _walk_and_apply_sort(spec, chart_strain_field, sort_order)
961
-
962
-
963
- def _suppress_chart_strain_axis(
964
- chart: alt.TopLevelMixin, chart_strain_field: str
965
- ) -> None:
966
- """Hide labels, ticks, axis line, and title on every chart strain-axis encoding.
967
-
968
- Walks the live altair object the same way `_walk_and_apply_sort` does
969
- and replaces the matching encoding's ``axis`` with one that suppresses
970
- every visible bit of axis chrome. This **overrides** any user-supplied
971
- ``axis=alt.Axis(...)`` on those encodings; that's documented on the
972
- ``connect_leader_to_label`` description in `_config.py`.
973
- """
974
- enc = _live_attr(chart, "encoding")
975
- if enc is not None:
976
- for channel in ("x", "y"):
977
- ch = _live_attr(enc, channel)
978
- if ch is not None and _channel_field(ch) == chart_strain_field:
979
- ch.axis = alt.Axis(labels=False, ticks=False, domain=False, title=None)
980
- for attr in ("hconcat", "vconcat", "concat", "layer"):
981
- sub = _live_attr(chart, attr)
982
- if isinstance(sub, list):
983
- for s in sub:
984
- _suppress_chart_strain_axis(s, chart_strain_field)
985
- spec = _live_attr(chart, "spec")
986
- if spec is not None:
987
- _suppress_chart_strain_axis(spec, chart_strain_field)
989
+ yield from _iter_strain_axis_channels(spec, chart_strain_field)
988
990
 
989
991
 
990
992
  def _live_attr(obj: Any, name: str) -> Any:
@@ -1044,21 +1046,71 @@ def _apply_combined_config(combined: alt.HConcatChart, hoisted_config: Any) -> N
1044
1046
  def _channel_field(ch: Any) -> str | None:
1045
1047
  """Read the underlying `field` string from an altair channel object.
1046
1048
 
1047
- `ch.field` returns altair's `_PropertySetter` (used for fluent chaining),
1048
- not the stored field name. The stored value is reachable via `to_dict()`.
1049
+ Returns the field name when the channel references a data field (either
1050
+ via the `field=` keyword or via positional shorthand like
1051
+ `alt.Y("strain")` / `alt.Y("strain:N")` / `alt.Y("mean(titer):Q")`).
1052
+ Returns `None` when the channel has no field at all (a `value=` /
1053
+ `datum=` constant encoding). Raises `ValueError` when the channel's
1054
+ `_kwds` shape is unrecognized — silent fallthrough hid a real bug
1055
+ where untyped shorthand axes were never reordered to match the tree.
1056
+
1057
+ Reads `ch._kwds` directly rather than going through `ch.to_dict()`:
1058
+ altair's `to_dict()` on a bare channel raises when the shorthand has
1059
+ no explicit type (e.g. `alt.Y("strain")`), because the `nominal` /
1060
+ `quantitative` inference needs the chart's data context. That
1061
+ exception is what the previous catch-all hid.
1049
1062
  """
1050
- to_dict = getattr(ch, "to_dict", None)
1051
- if not callable(to_dict):
1052
- return None
1053
- try:
1054
- d = to_dict()
1055
- except Exception:
1063
+ kwds = getattr(ch, "_kwds", None)
1064
+ if not isinstance(kwds, dict):
1065
+ raise ValueError(
1066
+ f"channel object {type(ch).__name__} has no `_kwds` mapping; "
1067
+ "this isn't a recognized altair channel encoding."
1068
+ )
1069
+ field = kwds.get("field")
1070
+ if field is not None and field is not alt.Undefined:
1071
+ # `from_dict`-roundtripped channels store the field as a
1072
+ # `FieldName(SchemaBase)` wrapper rather than a plain str; its
1073
+ # `to_dict()` returns the raw string, while `str(...)` returns the
1074
+ # repr `FieldName('x')`. Cover both.
1075
+ if isinstance(field, str):
1076
+ if field:
1077
+ return field
1078
+ elif hasattr(field, "to_dict"):
1079
+ unwrapped = field.to_dict()
1080
+ if isinstance(unwrapped, str) and unwrapped:
1081
+ return unwrapped
1082
+ raise ValueError(
1083
+ f"channel field wrapper {type(field).__name__} unwrapped "
1084
+ f"to {unwrapped!r}; expected a non-empty string."
1085
+ )
1086
+ else:
1087
+ raise ValueError(
1088
+ f"channel field has unexpected value {field!r} "
1089
+ f"(type {type(field).__name__}); expected a string."
1090
+ )
1091
+ shorthand = kwds.get("shorthand")
1092
+ if shorthand is None or shorthand is alt.Undefined:
1056
1093
  return None
1057
- if isinstance(d, dict):
1058
- f = d.get("field")
1059
- if isinstance(f, str):
1060
- return f
1061
- return None
1094
+ if not isinstance(shorthand, str) or not shorthand:
1095
+ raise ValueError(
1096
+ f"channel shorthand has unexpected value {shorthand!r}; "
1097
+ "expected a string like 'strain', 'strain:N', or "
1098
+ "'mean(strain):Q'."
1099
+ )
1100
+ # Shorthand grammar: '[<aggregate>(]<field>[)][:<type>]'.
1101
+ bare = shorthand.split(":", 1)[0]
1102
+ if "(" in bare:
1103
+ if not bare.endswith(")"):
1104
+ raise ValueError(
1105
+ f"channel shorthand {shorthand!r} has an unbalanced "
1106
+ "aggregate wrapper; expected 'aggregate(field)[:type]'."
1107
+ )
1108
+ bare = bare[bare.index("(") + 1 : -1]
1109
+ if not bare:
1110
+ raise ValueError(
1111
+ f"channel shorthand {shorthand!r} parsed to an empty field name."
1112
+ )
1113
+ return bare
1062
1114
 
1063
1115
 
1064
1116
  def _chart_strain_dim(
@@ -15,7 +15,7 @@ import altair as alt
15
15
  import pandas as pd
16
16
  import pytest
17
17
 
18
- from tree_annotated_plot._plot import _find_strain_encoding
18
+ from tree_annotated_plot._plot import _channel_field, _find_strain_encoding
19
19
 
20
20
  DATA_DIR = Path(__file__).resolve().parent.parent / "examples" / "data"
21
21
 
@@ -139,3 +139,70 @@ def test_find_strain_encoding_quantitative_type_raises() -> None:
139
139
  spec = _flat_chart_spec(typ="quantitative")
140
140
  with pytest.raises(ValueError, match="type='quantitative'"):
141
141
  _find_strain_encoding(spec, "strain")
142
+
143
+
144
+ # ---------- _channel_field: covers the "untyped shorthand" silent-skip bug.
145
+
146
+
147
+ def test_channel_field_typed_shorthand() -> None:
148
+ """`alt.Y('strain:N')` — the form every existing test uses."""
149
+ ch = alt.Y("strain:N")
150
+ assert _channel_field(ch) == "strain"
151
+
152
+
153
+ def test_channel_field_untyped_shorthand() -> None:
154
+ """`alt.Y('strain')` without a type — the form that triggered the
155
+ original silent-skip bug. `to_dict()` on this channel raises because
156
+ type inference needs the chart's data, but we can still recover the
157
+ field from `_kwds['shorthand']`."""
158
+ ch = alt.Y("strain")
159
+ assert _channel_field(ch) == "strain"
160
+
161
+
162
+ def test_channel_field_explicit_field_kwarg() -> None:
163
+ """`alt.Y(field='strain', type='nominal')` — the explicit form."""
164
+ ch = alt.Y(field="strain", type="nominal")
165
+ assert _channel_field(ch) == "strain"
166
+
167
+
168
+ def test_channel_field_after_from_dict_roundtrip() -> None:
169
+ """After `alt.Chart.from_dict(...)` (used by the CLI / JSON / HTML
170
+ chart loaders), the field is stored as a `FieldName(SchemaBase)`
171
+ wrapper, not a plain `str`. The helper must unwrap it."""
172
+ df = pd.DataFrame({"strain": ["A", "B"], "titer": [1.0, 2.0]})
173
+ chart = alt.Chart(df).mark_circle().encode(x="titer:Q", y=alt.Y("strain:N"))
174
+ roundtripped = alt.Chart.from_dict(chart.to_dict())
175
+ assert _channel_field(roundtripped.encoding.y) == "strain"
176
+
177
+
178
+ def test_channel_field_aggregate_shorthand() -> None:
179
+ """`alt.X('mean(titer):Q')` aggregate-wrapped shorthand."""
180
+ ch = alt.X("mean(titer):Q")
181
+ assert _channel_field(ch) == "titer"
182
+
183
+
184
+ def test_channel_field_value_only_returns_none() -> None:
185
+ """A `value=` constant encoding has no field — legitimate None,
186
+ not an error."""
187
+ ch = alt.Y(value=5)
188
+ assert _channel_field(ch) is None
189
+
190
+
191
+ def test_channel_field_unbalanced_aggregate_raises() -> None:
192
+ """A shorthand string we can't parse must raise rather than silently
193
+ return None — silent fallthrough is what hid the original bug."""
194
+ ch = alt.Y("strain")
195
+ ch._kwds["shorthand"] = "mean(titer:Q"
196
+ with pytest.raises(ValueError, match="unbalanced aggregate"):
197
+ _channel_field(ch)
198
+
199
+
200
+ def test_channel_field_non_kwds_object_raises() -> None:
201
+ """A non-altair object accidentally passed in must raise, not return
202
+ None — same fail-fast principle."""
203
+
204
+ class NotAChannel:
205
+ pass
206
+
207
+ with pytest.raises(ValueError, match="no `_kwds` mapping"):
208
+ _channel_field(NotAChannel())
@@ -110,6 +110,73 @@ def test_plot_overrides_y_sort_to_tree_tip_order():
110
110
  assert user_panel_spec["encoding"]["y"]["sort"] == ["A", "B", "C", "D"]
111
111
 
112
112
 
113
+ def test_plot_overrides_sort_for_untyped_shorthand_y():
114
+ """Regression: `alt.Y('strain')` (no `:N` suffix) used to silently
115
+ skip the sort override because `_channel_field` called `to_dict()`
116
+ on the bare channel, which raises without a chart-data context."""
117
+ chart = (
118
+ alt.Chart(_synthetic_df())
119
+ .mark_line(point=True)
120
+ .encode(
121
+ x=alt.X("titer:Q", scale=alt.Scale(type="log")),
122
+ y=alt.Y("strain"),
123
+ color="serum:N",
124
+ )
125
+ .properties(width=300, height=200)
126
+ )
127
+ out = tree_annotated_plot.plot(
128
+ _synthetic_auspice(),
129
+ chart,
130
+ chart_strain_field="strain",
131
+ tree_strain_field="name",
132
+ branch_length="div",
133
+ )
134
+ user_panel_spec = out.hconcat[1].to_dict()
135
+ assert user_panel_spec["encoding"]["y"]["sort"] == ["A", "B", "C", "D"]
136
+
137
+
138
+ def test_plot_overrides_sort_for_untyped_shorthand_x():
139
+ """Same regression on the horizontal layout."""
140
+ chart = (
141
+ alt.Chart(_synthetic_df())
142
+ .mark_line(point=True)
143
+ .encode(
144
+ x=alt.X("strain"),
145
+ y=alt.Y("titer:Q", scale=alt.Scale(type="log")),
146
+ color="serum:N",
147
+ )
148
+ .properties(width=200, height=300)
149
+ )
150
+ out = tree_annotated_plot.plot(
151
+ _synthetic_auspice(),
152
+ chart,
153
+ chart_strain_field="strain",
154
+ tree_strain_field="name",
155
+ branch_length="div",
156
+ )
157
+ # tree_location defaults to "bottom" for x-axis strain → vconcat(user, tree)
158
+ user_panel_spec = out.vconcat[0].to_dict()
159
+ assert user_panel_spec["encoding"]["x"]["sort"] == ["A", "B", "C", "D"]
160
+
161
+
162
+ def test_plot_consistency_tripwire_fires_when_live_walker_misses(monkeypatch):
163
+ """If `_channel_field` ever reverts to silently failing, the spec walker
164
+ finds a strain encoding the live walker doesn't, and the count-based
165
+ consistency check raises rather than letting a wrong-ordered chart
166
+ render."""
167
+ from tree_annotated_plot import _plot
168
+
169
+ monkeypatch.setattr(_plot, "_channel_field", lambda ch: None)
170
+ with pytest.raises(RuntimeError, match="internal consistency check failed"):
171
+ tree_annotated_plot.plot(
172
+ _synthetic_auspice(),
173
+ _synthetic_chart(),
174
+ chart_strain_field="strain",
175
+ tree_strain_field="name",
176
+ branch_length="div",
177
+ )
178
+
179
+
113
180
  def test_plot_strain_mismatch_raises():
114
181
  bad = _synthetic_chart()
115
182
  bad.data = bad.data.replace({"strain": {"D": "X"}})