diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5b655..3dc867a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +### Changed ⚠️ + +- `exercise_style = "solution_follow_exercise"` now renders solutions **collapsed by default** ([#84](https://github.com/executablebooks/sphinx-exercise/issues/84)) + - That style places each solution directly beneath its exercise, where an expanded solution is too tempting to read; it now folds into a drop-down instead + - **To keep the previous behaviour, set `solution_collapsed = False` explicitly** + - No change for projects that do not set `exercise_style`, and no source files need editing either way + - Requires `sphinx_togglebutton`, which Jupyter Book loads by default; plain Sphinx projects should add it to `extensions` + +### New ✨ + +- Added `solution_collapsed` configuration option to control whether solutions render folded ([#85](https://github.com/executablebooks/sphinx-exercise/issues/85)) + - Tri-state: unset follows `exercise_style`, `True` always collapses, `False` never collapses + - Works with both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs + - Directive-level `:class:` values are preserved, and `:class: toggle-shown` keeps an individual solution expanded + - A warning is issued during HTML builds when collapsing is in effect but `sphinx_togglebutton` is not loaded, suppressible with `suppress_warnings = ["exercise.solution_collapsed"]` + - Non-HTML builders, such as LaTeX/PDF, render solutions inline as before + ## [v1.2.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.2.1) (2025-11-17) ### Fixes 🐛 diff --git a/docs/source/releases/index.md b/docs/source/releases/index.md index a19584b..0443610 100644 --- a/docs/source/releases/index.md +++ b/docs/source/releases/index.md @@ -5,6 +5,7 @@ This section contains detailed release notes for sphinx-exercise versions. ```{toctree} :maxdepth: 1 +v1.3.0 v1.2.1 v1.2.0 v1.1.1 diff --git a/docs/source/releases/v1.3.0.md b/docs/source/releases/v1.3.0.md new file mode 100644 index 0000000..e918252 --- /dev/null +++ b/docs/source/releases/v1.3.0.md @@ -0,0 +1,88 @@ +# Release v1.3.0 + +**Release Date**: unreleased + +This release adds a `solution_collapsed` configuration option for rendering solutions folded by default, and makes collapsing part of what `exercise_style = "solution_follow_exercise"` means. + +## ⚠️ Behaviour change + +**If you set `exercise_style = "solution_follow_exercise"`, your solutions will now render collapsed by default.** + +This style places each solution directly beneath its exercise. Reader feedback on books using it was that an adjacent, fully visible solution is too tempting to look at, so the style now folds solutions into a drop-down and readers opt in to seeing the answer. + +### Keeping the previous behaviour + +Set `solution_collapsed` to `False` explicitly: + +```python +# In conf.py +exercise_style = "solution_follow_exercise" +solution_collapsed = False +``` + +Or for Jupyter Book: + +```yaml +# In _config.yml +sphinx: + config: + exercise_style: "solution_follow_exercise" + solution_collapsed: False +``` + +Nothing changes for projects that do not set `exercise_style`, and no source files need editing either way. + +### Requirements + +Collapsing is provided by [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/), which supplies the drop-down behaviour for the `dropdown` class. Jupyter Book loads it as part of its default extension list, so Jupyter Book projects need no change. + +Plain Sphinx projects should add it: + +```python +# In conf.py +extensions = [ + ... + "sphinx_togglebutton" + ... +] +``` + +If solutions would be collapsed but the extension is not loaded, the build emits a warning naming both remedies — add the extension, or set `solution_collapsed = False` — and solutions render expanded rather than silently losing content. + +## ✨ New Features + +### Collapsing solutions + +`solution_collapsed` controls whether solutions render folded, independently of the exercise style. It takes three values: + +| Value | Behaviour | +|---|---| +| unset (default) | Follow the exercise style: collapsed when `exercise_style = "solution_follow_exercise"`, expanded otherwise. | +| `True` | Always collapse solutions, whatever the exercise style. | +| `False` | Never collapse solutions, whatever the exercise style. | + +Collapsing is equivalent to adding `:class: dropdown` to every solution directive, and applies to both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs. Classes set on an individual directive are preserved, and an explicit `:class: dropdown` is not duplicated. + +### Keeping one solution expanded + +Add `:class: toggle-shown` to an individual directive to keep it open while the rest of the project is collapsed: + +````md +```{solution} my-exercise +:class: toggle-shown + +This solution stays open even when the rest of the project is collapsed. +``` +```` + +## 📝 Notes + +**Non-HTML builders are unaffected.** LaTeX/PDF output renders solutions inline as before; the `dropdown` class is only meaningful to HTML. + +**Interactive outputs need care.** A collapsed solution is hidden by setting its height to zero rather than by removing it from the page, so outputs that measure their own size when the page loads — plotly, bokeh, ipywidgets and altair figures produced by `{code-cell}` blocks — will render at zero size inside a collapsed solution and may stay blank until the reader opens it. Static images, including matplotlib figures, are unaffected. Use `:class: toggle-shown` on solutions containing interactive outputs. + +**Suppressing the warning.** Projects supplying their own `.admonition.dropdown` styling can silence the missing-extension warning with `suppress_warnings = ["exercise.solution_collapsed"]`. + +## 📚 Documentation + +See [Collapse All Solutions](../syntax.md) in the syntax guide for full details. diff --git a/docs/source/syntax.md b/docs/source/syntax.md index ec0463b..8ca7496 100644 --- a/docs/source/syntax.md +++ b/docs/source/syntax.md @@ -411,6 +411,71 @@ sphinx: ... ``` +### Collapse All Solutions + +All solution directives can be rendered folded by default, so readers have to opt in to seeing the answer. This is controlled by `solution_collapsed`, which takes three values: + +| Value | Behaviour | +|---|---| +| unset (default) | Follow the exercise style: solutions are collapsed when `exercise_style = "solution_follow_exercise"`, and expanded otherwise. | +| `True` | Always collapse solutions, whatever the exercise style. | +| `False` | Never collapse solutions, whatever the exercise style. | + +The `solution_follow_exercise` style places each solution directly beneath its exercise, which is precisely the layout where an expanded solution is hard to look away from — so that style collapses solutions by default. See the **Solution Title Styling** section below. + +```{important} +If you use `exercise_style = "solution_follow_exercise"` and want your solutions to stay expanded, set `solution_collapsed = False` explicitly. +``` + +This option requires [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/) to be enabled, as it provides the drop-down behaviour for the `dropdown` class. For Sphinx projects, add the configuration key in the `conf.py` file: + +```python +# conf.py +extensions = [ + ... + "sphinx_togglebutton" + ... +] + +solution_collapsed = True +``` + +For Jupyter Book projects, set the configuration key in `_config.yml`: + +```yaml +... +sphinx: + extra_extensions: + - sphinx_togglebutton + config: + solution_collapsed: True +... +``` + +Collapsing is equivalent to adding `:class: dropdown` to every solution directive in your project, and applies to both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs. Any classes you have set on an individual directive are preserved. + +```{note} +The `dropdown` class only affects HTML output. Other builders, such as LaTeX/PDF, render the solution inline as usual. + +If solutions would be collapsed but `sphinx_togglebutton` is not loaded, a warning is issued during an HTML build and solutions render expanded. If your theme supplies its own `.admonition.dropdown` styling and you do not need the extension, silence the warning with `suppress_warnings = ["exercise.solution_collapsed"]`. +``` + +```{warning} +A collapsed solution is hidden by setting its height to zero rather than by removing it from the page. Outputs that measure their own size when the page loads — such as plotly, bokeh, ipywidgets and altair figures produced by `{code-cell}` blocks — will therefore render at zero size inside a collapsed solution, and may stay blank until the reader toggles it open. + +Static images, including matplotlib figures, are unaffected. If a solution contains an interactive output, keep that one expanded with `:class: toggle-shown`. +``` + +To keep an individual solution expanded while the rest of the project is collapsed, add `:class: toggle-shown` to that directive: + +````md +```{solution} my-exercise +:class: toggle-shown + +This solution stays open even when the rest of the project is collapsed. +``` +```` + ### Solution Title Styling By default, solution titles include a hyperlink to the corresponding exercise. This behavior can be modified using the `exercise_style` configuration option. @@ -436,6 +501,7 @@ sphinx: When `exercise_style` is set to `"solution_follow_exercise"`: - The solution title displays just "Solution" (plain text, no hyperlink) +- **Solutions are collapsed by default**, so readers opt in to seeing the answer. Set `solution_collapsed = False` to keep them expanded, and see the **Collapse All Solutions** section above for the details - The extension validates that solutions follow their referenced exercises and warns if they don't - Solutions must be in the same document as their exercises (warnings if not) diff --git a/pyproject.toml b/pyproject.toml index 1600f3f..e52a1ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ testing = [ "pytest-regressions", "pytest>=8.0", "sphinx>=6.1,<9", + "sphinx_togglebutton", "texsoup", "defusedxml", # Required by sphinx-testing ] diff --git a/sphinx_exercise/__init__.py b/sphinx_exercise/__init__.py index 1e74f78..5cd9d21 100644 --- a/sphinx_exercise/__init__.py +++ b/sphinx_exercise/__init__.py @@ -21,6 +21,7 @@ from sphinx.locale import get_translation from ._compat import findall +from .utils import solutions_are_collapsed, collapsed_is_implied_by_style from .directive import ( ExerciseDirective, ExerciseStartDirective, @@ -272,11 +273,72 @@ def doctree_read(app: Sphinx, document: Node) -> None: ) +# Extensions that make the "dropdown" class collapsible. +# +# Only sphinx-togglebutton qualifies: its default togglebutton_selector is +# ".toggle, .admonition.dropdown". Note that sphinx-design does NOT belong +# here - its dropdown is a directive emitting ".sd-dropdown", and it ships no +# rule for a bare "dropdown" class. Adding it would suppress the warning below +# for Jupyter Book projects, which load sphinx-design by default. +TOGGLE_EXTENSIONS = ("sphinx_togglebutton",) + + +def check_collapsed_solutions(app: Sphinx) -> None: + """ + Warn when solution_collapsed is enabled for an HTML build but no extension + that implements the "dropdown" class is loaded. + + Without one of TOGGLE_EXTENSIONS the class is inert, so solutions would + render fully expanded and the option would silently do nothing. + + Projects that supply their own ".admonition.dropdown" CSS can silence this + with suppress_warnings = ["exercise.solution_collapsed"]. + """ + if not solutions_are_collapsed(app.config): + return + + # The dropdown class is only meaningful to HTML-family builders; LaTeX and + # other builders render the solution inline, which is the intended fallback + if getattr(app.builder, "format", None) != "html": + return + + if any(ext in app.extensions for ext in TOGGLE_EXTENSIONS): + return + + if collapsed_is_implied_by_style(app.config): + # The author never asked for collapsing, so tell them how to turn it + # off as well as how to make it work + message = ( + "exercise_style='solution_follow_exercise' collapses solutions by " + "default, but 'sphinx_togglebutton' is not loaded, so they will " + "render expanded. Add 'sphinx_togglebutton' to your extensions, or " + "set solution_collapsed = False to keep solutions expanded." + ) + else: + message = ( + "solution_collapsed=True requires 'sphinx_togglebutton', which is " + "not loaded, so solutions will render expanded. Add " + "'sphinx_togglebutton' to your extensions." + ) + + logger.warning( + f"[sphinx-exercise] {message} " + "See https://sphinx-togglebutton.readthedocs.io", + type="exercise", + subtype="solution_collapsed", + color="yellow", + ) + + def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value("hide_solutions", False, "env") app.add_config_value("exercise_style", "", "env") + # Tri-state: None (default) defers to exercise_style, True/False are + # explicit author choices. See utils.solutions_are_collapsed. + app.add_config_value("solution_collapsed", None, "env") app.connect("config-inited", init_numfig) # event order - 1 + app.connect("builder-inited", check_collapsed_solutions) # event order - 2 app.connect("env-purge-doc", purge_exercises) # event order - 5 per file app.connect("doctree-read", doctree_read) # event order - 8 app.connect("env-merge-info", merge_exercises) # event order - 9 diff --git a/sphinx_exercise/directive.py b/sphinx_exercise/directive.py index 6dd8fcf..421a14d 100644 --- a/sphinx_exercise/directive.py +++ b/sphinx_exercise/directive.py @@ -18,6 +18,7 @@ from sphinx.util import logging from sphinx.util.docutils import SphinxDirective +from .utils import solutions_are_collapsed from .nodes import ( exercise_end_node, exercise_enumerable_node, @@ -267,6 +268,15 @@ def run(self) -> List[Node]: if self.options.get("class"): classes += self.options.get("class") + # Fold the solution by default when collapsing is in effect - either + # opted into with solution_collapsed, or implied by the + # solution_follow_exercise style. The "dropdown" class is consumed by + # sphinx-togglebutton, whose default selector is + # ".toggle, .admonition.dropdown". Authors can still opt an individual + # solution back open with :class: toggle-shown. + if solutions_are_collapsed(self.env.app.config) and "dropdown" not in classes: + classes.append("dropdown") + # Construct Node node = self.solution_node() node += title diff --git a/sphinx_exercise/utils.py b/sphinx_exercise/utils.py index 83e33e0..201f9e5 100644 --- a/sphinx_exercise/utils.py +++ b/sphinx_exercise/utils.py @@ -2,6 +2,43 @@ from sphinx.writers.latex import LaTeXTranslator +#: The exercise style that places solutions directly after their exercises, +#: and therefore implies collapsed solutions unless the author opts out. +SOLUTION_FOLLOW_EXERCISE = "solution_follow_exercise" + + +def solutions_are_collapsed(config) -> bool: + """Whether solution directives should render folded by default. + + ``solution_collapsed`` is deliberately tri-state: + + ``True`` / ``False`` + An explicit choice by the author, which always wins. + ``None`` (the default) + Defer to ``exercise_style``. The ``solution_follow_exercise`` style puts + the solution directly beneath its exercise, which is precisely the + layout where an expanded solution is hard to look away from, so that + style implies collapsed solutions. + + A plain ``False`` default could not express this, because Sphinx cannot + distinguish "unset" from "explicitly set to False" through the public + config API - so ``solution_collapsed = False`` would be unable to switch + the style's implied collapsing back off. + """ + if config.solution_collapsed is not None: + return bool(config.solution_collapsed) + return config.exercise_style == SOLUTION_FOLLOW_EXERCISE + + +def collapsed_is_implied_by_style(config) -> bool: + """Whether collapsing came from ``exercise_style`` rather than an explicit opt-in. + + Used to tailor the "sphinx-togglebutton is missing" warning, since an author + who never asked for collapsing needs to be told how to switch it off as well + as how to make it work. + """ + return config.solution_collapsed is None and solutions_are_collapsed(config) + def find_parent(env, node, parent_tag): """Find the nearest parent node with the given tagname.""" diff --git a/tests/books/test-mybook/index.rst b/tests/books/test-mybook/index.rst index 79e31ed..0dcd122 100644 --- a/tests/books/test-mybook/index.rst +++ b/tests/books/test-mybook/index.rst @@ -47,3 +47,10 @@ A Test Program! solution/_linked_ref_wronglabel solution/_linked_duplicate_label + + .. NOTE: append new entries here. Documents containing enumerated + exercises must never be inserted above existing entries, or the + global exercise numbers baked into the regression fixtures shift. + + solution/_linked_enum_dropdown + solution/_linked_gated diff --git a/tests/books/test-mybook/solution/_linked_enum_dropdown.rst b/tests/books/test-mybook/solution/_linked_enum_dropdown.rst new file mode 100644 index 0000000..0bf885c --- /dev/null +++ b/tests/books/test-mybook/solution/_linked_enum_dropdown.rst @@ -0,0 +1,8 @@ +_linked_enum_dropdown +===================== + +.. solution:: ex-number + :label: solution-dropdown-label + :class: dropdown + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. diff --git a/tests/books/test-mybook/solution/_linked_gated.rst b/tests/books/test-mybook/solution/_linked_gated.rst new file mode 100644 index 0000000..ea50220 --- /dev/null +++ b/tests/books/test-mybook/solution/_linked_gated.rst @@ -0,0 +1,15 @@ +_linked_gated +============= + +.. exercise:: A gated example + :label: gated-ex-label + :nonumber: + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +.. solution-start:: gated-ex-label + :label: gated-solution-label + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +.. solution-end:: diff --git a/tests/test_solution_collapsed.py b/tests/test_solution_collapsed.py new file mode 100644 index 0000000..6123152 --- /dev/null +++ b/tests/test_solution_collapsed.py @@ -0,0 +1,287 @@ +"""Tests for the ``solution_collapsed`` configuration option. + +Collapsing adds the ``dropdown`` class to every solution directive so solutions +render folded by default. The class is consumed by sphinx-togglebutton, whose +default selector is ``.toggle, .admonition.dropdown``. + +``solution_collapsed`` is tri-state: ``True``/``False`` are explicit author +choices that always win, while ``None`` (the default) defers to +``exercise_style`` - and the ``solution_follow_exercise`` style implies +collapsed solutions. +""" + +import importlib.util + +import pytest +from bs4 import BeautifulSoup + +HAS_TOGGLEBUTTON = importlib.util.find_spec("sphinx_togglebutton") is not None + + +def get_solution_classes(app, docname): + """Return the class list of the first ``div.solution`` in a built page.""" + path = app.outdir / docname + assert path.exists(), f"{docname} was not built" + soup = BeautifulSoup(path.read_text(encoding="utf8"), "html.parser") + solutions = soup.select("div.solution") + assert solutions, f"no solution directive found in {docname}" + return solutions[0].get("class", []) + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_adds_dropdown_class(app): + """solution_collapsed=True adds the 'dropdown' class to a solution.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert "dropdown" in classes, f"expected 'dropdown' in {classes}" + assert "solution" in classes, "the 'solution' class must be preserved" + + +@pytest.mark.sphinx("html", testroot="mybook") +def test_solution_collapsed_default_is_off(app): + """By default no 'dropdown' class is added (backwards compatibility).""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert "dropdown" not in classes, ( + "solution_collapsed defaults to False so no 'dropdown' class should be " + f"added, got {classes}" + ) + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_preserves_custom_class(app): + """A directive-level :class: is kept alongside the injected 'dropdown'.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum_class.html") + assert "dropdown" in classes, f"expected 'dropdown' in {classes}" + assert ( + "test-solution" in classes + ), f"the author's :class: value must be preserved, got {classes}" + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_no_duplicate_dropdown(app): + """An explicit ':class: dropdown' is not duplicated by the config option.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum_dropdown.html") + assert ( + classes.count("dropdown") == 1 + ), f"'dropdown' should appear exactly once, got {classes}" + + +@pytest.mark.sphinx("html", testroot="mybook") +def test_solution_collapsed_off_keeps_explicit_dropdown(app): + """':class: dropdown' keeps working when the config option is off.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum_dropdown.html") + assert ( + "dropdown" in classes + ), f"an explicit ':class: dropdown' must still be honoured, got {classes}" + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_gated_directive(app): + """Gated solution-start/solution-end pairs are collapsed too. + + The class list is rebuilt by ``MergeGatedSolutions`` when the pair is merged + into a single solution node, so this guards against the injected class being + dropped in the process. + """ + app.build() + classes = get_solution_classes(app, "solution/_linked_gated.html") + assert ( + "dropdown" in classes + ), f"gated solutions should also be collapsed, got {classes}" + + +@pytest.mark.sphinx("html", testroot="mybook") +def test_solution_collapsed_gated_default_is_off(app): + """Gated solutions get no 'dropdown' class by default.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_gated.html") + assert "dropdown" not in classes, f"expected no 'dropdown' in {classes}" + + +@pytest.mark.sphinx( + "html", testroot="gateddirective", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_gated_myst_source(app): + """The gated path is also covered from MyST source, not just RST. + + The other gated tests here use the RST ``.. solution-start::`` form in the + 'mybook' root. This one uses the MyST ```{solution-start}``` form, so the + directive-option parsing both parsers feed into is exercised from each side. + """ + app.build() + classes = get_solution_classes(app, "solution-exercise-gated.html") + assert ( + "dropdown" in classes + ), f"MyST-sourced gated solutions should be collapsed too, got {classes}" + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_warns_without_togglebutton(app, warnings): + """A warning is emitted when no extension implements the dropdown class. + + The 'mybook' test root does not load sphinx-togglebutton, so the injected + class would be inert and the solutions would silently render expanded. + """ + app.build() + assert "solution_collapsed=True requires 'sphinx_togglebutton'" in warnings(app) + + +@pytest.mark.skipif(not HAS_TOGGLEBUTTON, reason="sphinx-togglebutton is not installed") +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={ + "solution_collapsed": True, + "extensions": ["sphinx_exercise", "myst_nb", "sphinx_togglebutton"], + }, +) +def test_solution_collapsed_no_warning_with_togglebutton(app, warnings): + """No warning when sphinx-togglebutton is loaded.""" + app.build() + assert "solution_collapsed=True requires" not in warnings(app) + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={ + "solution_collapsed": True, + "suppress_warnings": ["exercise.solution_collapsed"], + }, +) +def test_solution_collapsed_warning_is_suppressible(app, warnings): + """The warning is typed, so projects supplying their own CSS can silence it. + + Without a type/subtype the warning would be unsuppressible and would break + any ``-W`` build for a project that provides its own ``.admonition.dropdown`` + rules instead of loading sphinx-togglebutton. + """ + app.build() + assert "solution_collapsed=True requires" not in warnings(app) + # the class is still applied - suppression only silences the warning + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert "dropdown" in classes, f"expected 'dropdown' in {classes}" + + +@pytest.mark.sphinx( + "latex", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_solution_collapsed_no_warning_for_latex(app, warnings): + """Non-HTML builders render solutions inline, so no warning is emitted.""" + app.build() + assert "solution_collapsed=True requires" not in warnings(app) + + +# --- interaction with exercise_style ----------------------------------------- + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={"exercise_style": "solution_follow_exercise"}, +) +def test_follow_exercise_style_collapses_by_default(app): + """The solution_follow_exercise style implies collapsed solutions. + + That style places the solution directly beneath its exercise, which is the + layout the collapsing is meant to address, so it opts in by default. + """ + app.build() + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert "dropdown" in classes, ( + f"exercise_style='solution_follow_exercise' should collapse solutions, " + f"got {classes}" + ) + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={ + "exercise_style": "solution_follow_exercise", + "solution_collapsed": False, + }, +) +def test_explicit_false_overrides_the_style(app): + """An explicit solution_collapsed=False switches the style's implied collapse off. + + This is the reason the config value is tri-state: with a plain False default + Sphinx could not tell "unset" from "explicitly False", so this opt-out would + be impossible to express. + """ + app.build() + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert ( + "dropdown" not in classes + ), f"solution_collapsed=False must override the style, got {classes}" + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={ + "exercise_style": "solution_follow_exercise", + "solution_collapsed": True, + }, +) +def test_explicit_true_agrees_with_the_style(app): + """solution_collapsed=True alongside the style collapses, without duplicating.""" + app.build() + classes = get_solution_classes(app, "solution/_linked_enum.html") + assert classes.count("dropdown") == 1, f"expected one 'dropdown', got {classes}" + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={"exercise_style": "solution_follow_exercise"}, +) +def test_style_implied_warning_mentions_the_opt_out(app, warnings): + """The implied-collapse warning tells authors how to switch it back off. + + An author who set exercise_style but never asked for collapsing needs the + opt-out, not just the "install sphinx-togglebutton" remedy. + """ + app.build() + captured = warnings(app) + assert "collapses solutions by default" in captured + assert "solution_collapsed = False" in captured + + +@pytest.mark.sphinx( + "html", testroot="mybook", confoverrides={"solution_collapsed": True} +) +def test_explicit_warning_does_not_mention_the_opt_out(app, warnings): + """An author who opted in explicitly does not need to be told to opt out.""" + app.build() + captured = warnings(app) + assert "solution_collapsed=True requires" in captured + assert "solution_collapsed = False" not in captured + + +@pytest.mark.sphinx( + "html", + testroot="mybook", + confoverrides={ + "exercise_style": "solution_follow_exercise", + "solution_collapsed": False, + }, +) +def test_no_warning_when_style_collapse_is_switched_off(app, warnings): + """Opting out of the implied collapse also silences the togglebutton warning.""" + app.build() + assert "sphinx_togglebutton" not in warnings(app)