Repository: altair-viz/altair
Pull request #2882 (2023-02-12 15:14:18)

Add Heat Lane example
This code attempts to port the VegaLite example found here: https://vega.github.io/vega-lite/examples/bar_heatlane.html

Here's how it turns out in my notebook: 
![visualization](https://user-images.githubusercontent.com/9993/218319450-ee5758f0-b22d-4c0d-94f4-d43c80a0adf2.png)



Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1d6886a3..ff90df0f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -15,29 +15,29 @@ jobs:
         uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python-version }}
-      - name: Set Up Chromedriver
-        run: |
-          sudo apt-get update
-          sudo apt-get --only-upgrade install google-chrome-stable
-          sudo apt-get -yqq install chromium-chromedriver
+      # - name: Set Up Chromedriver
+      #   run: |
+      #     sudo apt-get update
+      #     sudo apt-get --only-upgrade install google-chrome-stable
+      #     sudo apt-get -yqq install chromium-chromedriver
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
           pip install .[dev]
-          pip install "selenium<4.3.0"
-          pip install altair_saver
+          # pip install "selenium<4.3.0"
+          # pip install altair_saver
       - name: Test with pytest
         run: |
           pytest --doctest-modules tests
-      - name: Selected tests without vl-convert-python
-        run: |
-          pip uninstall vl-convert-python --yes
-          pytest -m save_engine --doctest-modules tests
-      - name: Selected tests without vl-convert-python and altair_saver
-        run: |
-          pip uninstall altair_saver --yes
-          pytest -m save_engine --doctest-modules tests
+      # - name: Selected tests without vl-convert-python
+      #   run: |
+      #     pip uninstall vl-convert-python --yes
+      #     pytest -m save_engine --doctest-modules tests
+      # - name: Selected tests without vl-convert-python and altair_saver
+      #   run: |
+      #     pip uninstall altair_saver --yes
+      #     pytest -m save_engine --doctest-modules tests
       - name: Selected tests with vl-convert-python and without altair_saver
         run: |
-          pip install vl-convert-python
+          # pip install vl-convert-python
           pytest -m save_engine --doctest-modules tests
diff --git a/altair/utils/schemapi.py b/altair/utils/schemapi.py
index 0c8f9b27..0b4dd372 100644
--- a/altair/utils/schemapi.py
+++ b/altair/utils/schemapi.py
@@ -159,13 +159,27 @@ class SchemaValidationError(jsonschema.ValidationError):
         self._additional_errors = getattr(err, "_additional_errors", [])
 
     def __str__(self):
-        cls = self.obj.__class__
+        # Try to get the lowest class possible in the chart hierarchy so
+        # it can be displayed in the error message. This should lead to more informative
+        # error messages pointing the user closer to the source of the issue.
+        for prop_name in reversed(self.absolute_path):
+            # Check if str as e.g. first item can be a 0
+            if isinstance(prop_name, str):
+                potential_class_name = prop_name[0].upper() + prop_name[1:]
+                cls = getattr(vegalite, potential_class_name, None)
+                if cls is not None:
+                    break
+        else:
+            # Did not find a suitable class based on traversing the path so we fall
+            # back on the class of the top-level object which created
+            # the SchemaValidationError
+            cls = self.obj.__class__
         schema_path = ["{}.{}".format(cls.__module__, cls.__name__)]
         schema_path.extend(self.schema_path)
         schema_path = "->".join(
             str(val)
             for val in schema_path[:-1]
-            if val not in ("properties", "additionalProperties", "patternProperties")
+            if val not in (0, "properties", "additionalProperties", "patternProperties")
         )
         message = self.message
         if self._additional_errors:
@@ -390,10 +404,10 @@ class SchemaBase(object):
             parsed_shorthand = context.pop("parsed_shorthand", {})
             # Prevent that pandas categorical data is automatically sorted
             # when a non-ordinal data type is specifed manually
-            if "sort" in parsed_shorthand and kwds["type"] not in [
-                "ordinal",
-                Undefined,
-            ]:
+            # or if the encoding channel does not support sorting
+            if "sort" in parsed_shorthand and (
+                "sort" not in kwds or kwds["type"] not in ["ordinal", Undefined]
+            ):
                 parsed_shorthand.pop("sort")
 
             kwds.update(
@@ -406,6 +420,8 @@ class SchemaBase(object):
             kwds = {
                 k: v for k, v in kwds.items() if k not in list(ignore) + ["shorthand"]
             }
+            if "mark" in kwds and isinstance(kwds["mark"], str):
+                kwds["mark"] = {"type": kwds["mark"]}
             result = _todict(
                 kwds,
                 context=context,
diff --git a/doc/conf.py b/doc/conf.py
index cd850dc3..a217eb19 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -155,6 +155,7 @@ html_theme_options = {
         },
     ],
     "header_links_before_dropdown": 6,
+    "announcement": "This website is for Vega-Altair v5 (release candidate). Visit the <a href='https://altair-viz.github.io/altair-viz-v4/'>Vega-Altair v4 homepage</a> for previous release documentation.",
 }
 
 html_context = {"default_mode": "light"}
diff --git a/doc/user_guide/internals.rst b/doc/user_guide/internals.rst
index f63479ad..d7187db4 100644
--- a/doc/user_guide/internals.rst
+++ b/doc/user_guide/internals.rst
@@ -136,7 +136,7 @@ from the Vega-Lite documentation, which has the following JSON specification::
           {"a": "G","b": 19}, {"a": "H","b": 87}, {"a": "I","b": 52}
         ]
       },
-      "mark": "bar",
+      "mark": {"type": "bar"},
       "encoding": {
         "x": {"field": "a", "type": "ordinal"},
         "y": {"field": "b", "type": "quantitative"}
@@ -161,7 +161,7 @@ construct an Altair chart object from this string of Vega-Lite JSON:
           {"a": "G","b": 19}, {"a": "H","b": 87}, {"a": "I","b": 52}
         ]
       },
-      "mark": "bar",
+      "mark": {"type": "bar"},
       "encoding": {
         "x": {"field": "a", "type": "ordinal"},
         "y": {"field": "b", "type": "quantitative"}
@@ -186,7 +186,7 @@ you can use the :meth:`~Chart.from_dict` method to construct the chart object:
           {"a": "G","b": 19}, {"a": "H","b": 87}, {"a": "I","b": 52}
         ]
       },
-      "mark": "bar",
+      "mark": {"type": "bar"},
       "encoding": {
         "x": {"field": "a", "type": "ordinal"},
         "y": {"field": "b", "type": "quantitative"}
diff --git a/doc/user_guide/saving_charts.rst b/doc/user_guide/saving_charts.rst
index ec0d5939..6fc18526 100644
--- a/doc/user_guide/saving_charts.rst
+++ b/doc/user_guide/saving_charts.rst
@@ -61,7 +61,7 @@ The contents of the resulting file will look something like this:
           "type": "quantitative"
         }
       },
-      "mark": "point"
+      "mark": {"type": "point"}
     }
 
 This JSON can then be inserted into any web page using the vegaEmbed_ library.
@@ -121,7 +121,7 @@ javascript-enabled web browser:
               "type": "quantitative"
             }
           },
-          "mark": "point"
+          "mark": {"type": "point"}
         };
         var opt = {"renderer": "canvas", "actions": false};
         vegaEmbed("#vis", spec, opt);
diff --git a/tools/schemapi/schemapi.py b/tools/schemapi/schemapi.py
index 190446bb..7f14f7bb 100644
--- a/tools/schemapi/schemapi.py
+++ b/tools/schemapi/schemapi.py
@@ -157,13 +157,27 @@ class SchemaValidationError(jsonschema.ValidationError):
         self._additional_errors = getattr(err, "_additional_errors", [])
 
     def __str__(self):
-        cls = self.obj.__class__
+        # Try to get the lowest class possible in the chart hierarchy so
+        # it can be displayed in the error message. This should lead to more informative
+        # error messages pointing the user closer to the source of the issue.
+        for prop_name in reversed(self.absolute_path):
+            # Check if str as e.g. first item can be a 0
+            if isinstance(prop_name, str):
+                potential_class_name = prop_name[0].upper() + prop_name[1:]
+                cls = getattr(vegalite, potential_class_name, None)
+                if cls is not None:
+                    break
+        else:
+            # Did not find a suitable class based on traversing the path so we fall
+            # back on the class of the top-level object which created
+            # the SchemaValidationError
+            cls = self.obj.__class__
         schema_path = ["{}.{}".format(cls.__module__, cls.__name__)]
         schema_path.extend(self.schema_path)
         schema_path = "->".join(
             str(val)
             for val in schema_path[:-1]
-            if val not in ("properties", "additionalProperties", "patternProperties")
+            if val not in (0, "properties", "additionalProperties", "patternProperties")
         )
         message = self.message
         if self._additional_errors:
@@ -388,10 +402,10 @@ class SchemaBase(object):
             parsed_shorthand = context.pop("parsed_shorthand", {})
             # Prevent that pandas categorical data is automatically sorted
             # when a non-ordinal data type is specifed manually
-            if "sort" in parsed_shorthand and kwds["type"] not in [
-                "ordinal",
-                Undefined,
-            ]:
+            # or if the encoding channel does not support sorting
+            if "sort" in parsed_shorthand and (
+                "sort" not in kwds or kwds["type"] not in ["ordinal", Undefined]
+            ):
                 parsed_shorthand.pop("sort")
 
             kwds.update(
@@ -404,6 +418,8 @@ class SchemaBase(object):
             kwds = {
                 k: v for k, v in kwds.items() if k not in list(ignore) + ["shorthand"]
             }
+            if "mark" in kwds and isinstance(kwds["mark"], str):
+                kwds["mark"] = {"type": kwds["mark"]}
             result = _todict(
                 kwds,
                 context=context,


Test patch:
diff --git a/tests/examples_arguments_syntax/heat_lane.py b/tests/examples_arguments_syntax/heat_lane.py
new file mode 100644
index 00000000..7239c1f9
--- /dev/null
+++ b/tests/examples_arguments_syntax/heat_lane.py
@@ -0,0 +1,44 @@
+"""
+Heat Lane Chart
+---------------
+This example shows how to make an alternative form of a histogram `designed at Google <https://www.smashingmagazine.com/2022/07/accessibility-first-approach-chart-visual-design/>`_ with the goal of increasing accessibility.
+"""
+# category: distributions
+import altair as alt
+from vega_datasets import data
+
+source = data.cars.url
+
+chart = alt.Chart(source, title="Car horsepower", height=100, width=300).encode(
+    x=alt.X(
+        "bin_Horsepower_start:Q",
+        title="Horsepower",
+        axis=alt.Axis(grid=False)
+    ),
+    x2="bin_Horsepower_end:Q",
+    y=alt.Y("y:O", axis=None),
+    y2="y2",
+).transform_bin(
+    ["bin_Horsepower_start", "bin_Horsepower_end"],
+    field='Horsepower'
+).transform_aggregate(
+    count='count()',
+    groupby=["bin_Horsepower_start", "bin_Horsepower_end"]
+).transform_bin(
+    ["bin_count_start", "bin_count_end"],
+    field='count'
+).transform_calculate(
+    y="datum.bin_count_end/2",
+    y2="-datum.bin_count_end/2",
+).transform_joinaggregate(
+    max_bin_count_end="max(bin_count_end)",
+)
+
+layer1 = chart.mark_bar(xOffset=1, x2Offset=-1, cornerRadius=3).encode(
+    color=alt.Color("max_bin_count_end:O", scale=alt.Scale(scheme="lighttealblue"), title="Number of models")
+)
+layer2 = chart.mark_bar(xOffset=1, x2Offset=-1, yOffset=-3, y2Offset=3).encode(
+    color=alt.Color("bin_count_end:O", title="Number of models")
+)
+
+layer1 + layer2
diff --git a/tests/examples_methods_syntax/heat_lane.py b/tests/examples_methods_syntax/heat_lane.py
new file mode 100644
index 00000000..63d8dd96
--- /dev/null
+++ b/tests/examples_methods_syntax/heat_lane.py
@@ -0,0 +1,44 @@
+"""
+Heat Lane Chart
+---------------
+This example shows how to make an alternative form of a histogram `designed at Google <https://www.smashingmagazine.com/2022/07/accessibility-first-approach-chart-visual-design/>`_ with the goal of increasing accessibility.
+"""
+# category: distributions
+import altair as alt
+from vega_datasets import data
+
+source = data.cars.url
+
+chart = alt.Chart(source, title="Car horsepower", height=100, width=300).encode(
+    alt.X("bin_Horsepower_start:Q")
+        .title("Horsepower")
+        .axis(grid=False),
+    alt.X2("bin_Horsepower_end:Q"),
+    alt.Y("y:O").axis(None),
+    alt.Y2("y2"),
+).transform_bin(
+    ["bin_Horsepower_start", "bin_Horsepower_end"],
+    field='Horsepower'
+).transform_aggregate(
+    count='count()',
+    groupby=["bin_Horsepower_start", "bin_Horsepower_end"]
+).transform_bin(
+    ["bin_count_start", "bin_count_end"],
+    field='count'
+).transform_calculate(
+    y="datum.bin_count_end/2",
+    y2="-datum.bin_count_end/2",
+).transform_joinaggregate(
+    max_bin_count_end="max(bin_count_end)",
+)
+
+layer1 = chart.mark_bar(xOffset=1, x2Offset=-1, cornerRadius=3).encode(
+    alt.Color("max_bin_count_end:O")
+        .title("Number of models")
+        .scale(scheme="lighttealblue")
+)
+layer2 = chart.mark_bar(xOffset=1, x2Offset=-1, yOffset=-3, y2Offset=3).encode(
+    alt.Color("bin_count_end:O").title("Number of models")
+)
+
+layer1 + layer2
diff --git a/tests/tests/test_examples.py b/tests/tests/test_examples.py
index 9a268ba8..4dcfee36 100644
--- a/tests/tests/test_examples.py
+++ b/tests/tests/test_examples.py
@@ -35,9 +35,17 @@ def test_render_examples_to_chart(syntax_module):
 
         if chart is None:
             raise ValueError(
-                "Example file should define chart in its final " "statement."
+                f"Example file {filename} should define chart in its final "
+                "statement."
+            )
+
+        try:
+            assert isinstance(chart.to_dict(), dict)
+        except Exception as e:
+            raise AssertionError(
+                f"Example file {filename} raised an exception when "
+                f"converting to a dict: {e}"
             )
-        assert isinstance(chart.to_dict(), dict)
 
 
 # We do not apply the save_engine mark to this test. This mark is used in
diff --git a/tests/tests/test_magics.py b/tests/tests/test_magics.py
index d794c733..db24ff48 100644
--- a/tests/tests/test_magics.py
+++ b/tests/tests/test_magics.py
@@ -45,7 +45,7 @@ VEGALITE_SPEC = {
         "x": {"field": "category", "type": "ordinal"},
         "y": {"field": "amount", "type": "quantitative"},
     },
-    "mark": "bar",
+    "mark": {"type": "bar"},
 }
 
 
diff --git a/tests/utils/tests/test_html.py b/tests/utils/tests/test_html.py
index 5f44704b..ccd41745 100644
--- a/tests/utils/tests/test_html.py
+++ b/tests/utils/tests/test_html.py
@@ -7,7 +7,7 @@ from altair.utils.html import spec_to_html
 def spec():
     return {
         "data": {"url": "data.json"},
-        "mark": "point",
+        "mark": {"type": "point"},
         "encoding": {
             "x": {"field": "x", "type": "quantitative"},
             "y": {"field": "y", "type": "quantitative"},
diff --git a/tests/utils/tests/test_mimebundle.py b/tests/utils/tests/test_mimebundle.py
index 6cdb9c37..3aa8734f 100644
--- a/tests/utils/tests/test_mimebundle.py
+++ b/tests/utils/tests/test_mimebundle.py
@@ -32,7 +32,7 @@ def vegalite_spec():
                 {"a": "I", "b": 52},
             ]
         },
-        "mark": "bar",
+        "mark": {"type": "bar"},
         "encoding": {
             "x": {"field": "a", "type": "ordinal"},
             "y": {"field": "b", "type": "quantitative"},
diff --git a/tests/utils/tests/test_schemapi.py b/tests/utils/tests/test_schemapi.py
index d7263404..af48ea74 100644
--- a/tests/utils/tests/test_schemapi.py
+++ b/tests/utils/tests/test_schemapi.py
@@ -467,30 +467,36 @@ def chart_example_invalid_y_option_value_with_condition():
     [
         (
             chart_example_invalid_y_option,
-            r"Additional properties are not allowed \('unknown' was unexpected\)",
+            r"schema.channels.X.*"
+            + r"Additional properties are not allowed \('unknown' was unexpected\)",
         ),
         (
             chart_example_invalid_y_option_value,
-            r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
+            r"schema.channels.Y.*"
+            + r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
             + r"'asdf' is not of type 'null'.*'asdf' is not of type 'boolean'",
         ),
         (
             chart_example_layer,
-            r"Additional properties are not allowed \('width' was unexpected\)",
+            r"api.VConcatChart.*"
+            + r"Additional properties are not allowed \('width' was unexpected\)",
         ),
         (
             chart_example_invalid_y_option_value_with_condition,
-            r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
+            r"schema.channels.Y.*"
+            + r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
             + r"'asdf' is not of type 'null'.*'asdf' is not of type 'boolean'",
         ),
         (
             chart_example_hconcat,
-            r"\{'text': 'Horsepower', 'align': 'right'\} is not of type 'string'.*"
+            r"schema.core.TitleParams.*"
+            + r"\{'text': 'Horsepower', 'align': 'right'\} is not of type 'string'.*"
             + r"\{'text': 'Horsepower', 'align': 'right'\} is not of type 'array'",
         ),
         (
             chart_example_invalid_channel_and_condition,
-            r"Additional properties are not allowed \('invalidChannel' was unexpected\)",
+            r"schema.core.Encoding->encoding.*"
+            + r"Additional properties are not allowed \('invalidChannel' was unexpected\)",
         ),
     ],
 )
@@ -554,3 +560,11 @@ def test_to_dict_no_side_effects():
     assert "shorthand" not in dct["encoding"]["y"]
     assert dct["encoding"]["y"]["field"] == "b"
     assert dct["encoding"]["y"]["type"] == "quantitative"
+
+
+def test_to_dict_expand_mark_spec():
+    # Test that `to_dict` correctly expands marks to a dictionary
+    # without impacting the original spec which remains a string
+    chart = alt.Chart().mark_bar()
+    assert chart.to_dict()["mark"] == {"type": "bar"}
+    assert chart.mark == "bar"
diff --git a/tests/vegalite/tests/test_common.py b/tests/vegalite/tests/test_common.py
index 3a3bc4db..0cc216e6 100644
--- a/tests/vegalite/tests/test_common.py
+++ b/tests/vegalite/tests/test_common.py
@@ -11,7 +11,7 @@ from altair.vegalite import v5
 def basic_spec():
     return {
         "data": {"url": "data.csv"},
-        "mark": "line",
+        "mark": {"type": "line"},
         "encoding": {
             "color": {"type": "nominal", "field": "color"},
             "x": {"type": "quantitative", "field": "xval"},
diff --git a/tests/vegalite/v5/tests/test_api.py b/tests/vegalite/v5/tests/test_api.py
index b4b82a17..404f0a45 100644
--- a/tests/vegalite/v5/tests/test_api.py
+++ b/tests/vegalite/v5/tests/test_api.py
@@ -138,16 +138,30 @@ def test_chart_infer_types():
         assert dct["encoding"]["size"]["type"] == "ordinal"
         assert dct["encoding"]["size"]["field"] == "s"
         assert dct["encoding"]["size"]["sort"] == [2, 1]
+        assert dct["encoding"]["tooltip"]["type"] == "ordinal"
+        assert dct["encoding"]["tooltip"]["field"] == "s"
+        # "sort" should be removed for channels that don't support it
+        assert "sort" not in dct["encoding"]["tooltip"]
 
     # Pass field names by keyword
-    chart = alt.Chart(data).mark_point().encode(x="x", y="y", color="c", size="s")
+    chart = (
+        alt.Chart(data)
+        .mark_point()
+        .encode(x="x", y="y", color="c", size="s", tooltip="s")
+    )
     _check_encodings(chart)
 
     # pass Channel objects by keyword
     chart = (
         alt.Chart(data)
         .mark_point()
-        .encode(x=alt.X("x"), y=alt.Y("y"), color=alt.Color("c"), size=alt.Size("s"))
+        .encode(
+            x=alt.X("x"),
+            y=alt.Y("y"),
+            color=alt.Color("c"),
+            size=alt.Size("s"),
+            tooltip=alt.Tooltip("s"),
+        )
     )
     _check_encodings(chart)
 
@@ -155,7 +169,7 @@ def test_chart_infer_types():
     chart = (
         alt.Chart(data)
         .mark_point()
-        .encode(alt.X("x"), alt.Y("y"), alt.Color("c"), alt.Size("s"))
+        .encode(alt.X("x"), alt.Y("y"), alt.Color("c"), alt.Size("s"), alt.Tooltip("s"))
     )
     _check_encodings(chart)
 
@@ -167,6 +181,7 @@ def test_chart_infer_types():
             alt.X("x", type="nominal"),
             alt.Y("y", type="ordinal"),
             alt.Size("s", type="nominal"),
+            alt.Tooltip("s", type="nominal"),
         )
     )
     dct = chart.to_dict()
@@ -174,6 +189,8 @@ def test_chart_infer_types():
     assert dct["encoding"]["y"]["type"] == "ordinal"
     assert dct["encoding"]["size"]["type"] == "nominal"
     assert "sort" not in dct["encoding"]["size"]
+    assert dct["encoding"]["tooltip"]["type"] == "nominal"
+    assert "sort" not in dct["encoding"]["tooltip"]
 
 
 @pytest.mark.parametrize(
diff --git a/tools/schemapi/tests/test_schemapi.py b/tools/schemapi/tests/test_schemapi.py
index d26b127f..09667a6e 100644
--- a/tools/schemapi/tests/test_schemapi.py
+++ b/tools/schemapi/tests/test_schemapi.py
@@ -461,34 +461,40 @@ def chart_example_invalid_y_option_value_with_condition():
 
 
 @pytest.mark.parametrize(
-    "chart_func,expected_error_message",
+    "chart_func, expected_error_message",
     [
         (
             chart_example_invalid_y_option,
-            r"Additional properties are not allowed \('unknown' was unexpected\)",
+            r"schema.channels.X.*"
+            + r"Additional properties are not allowed \('unknown' was unexpected\)",
         ),
         (
             chart_example_invalid_y_option_value,
-            r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
+            r"schema.channels.Y.*"
+            + r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
             + r"'asdf' is not of type 'null'.*'asdf' is not of type 'boolean'",
         ),
         (
             chart_example_layer,
-            r"Additional properties are not allowed \('width' was unexpected\)",
+            r"api.VConcatChart.*"
+            + r"Additional properties are not allowed \('width' was unexpected\)",
         ),
         (
             chart_example_invalid_y_option_value_with_condition,
-            r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
+            r"schema.channels.Y.*"
+            + r"'asdf' is not one of \['zero', 'center', 'normalize'\].*"
             + r"'asdf' is not of type 'null'.*'asdf' is not of type 'boolean'",
         ),
         (
             chart_example_hconcat,
-            r"\{'text': 'Horsepower', 'align': 'right'\} is not of type 'string'.*"
+            r"schema.core.TitleParams.*"
+            + r"\{'text': 'Horsepower', 'align': 'right'\} is not of type 'string'.*"
             + r"\{'text': 'Horsepower', 'align': 'right'\} is not of type 'array'",
         ),
         (
             chart_example_invalid_channel_and_condition,
-            r"Additional properties are not allowed \('invalidChannel' was unexpected\)",
+            r"schema.core.Encoding->encoding.*"
+            + r"Additional properties are not allowed \('invalidChannel' was unexpected\)",
         ),
     ],
 )
@@ -552,3 +558,11 @@ def test_to_dict_no_side_effects():
     assert "shorthand" not in dct["encoding"]["y"]
     assert dct["encoding"]["y"]["field"] == "b"
     assert dct["encoding"]["y"]["type"] == "quantitative"
+
+
+def test_to_dict_expand_mark_spec():
+    # Test that `to_dict` correctly expands marks to a dictionary
+    # without impacting the original spec which remains a string
+    chart = alt.Chart().mark_bar()
+    assert chart.to_dict()["mark"] == {"type": "bar"}
+    assert chart.mark == "bar"