diff --git a/tests/unit/fixtures/html/dashboard_legacy.html b/tests/unit/fixtures/html/dashboard_legacy.html
new file mode 100644
index 0000000..f5d2fdd
--- /dev/null
+++ b/tests/unit/fixtures/html/dashboard_legacy.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ Dashboard
+
+
+ Dashboard
+
+
diff --git a/tests/unit/fixtures/html/dashboard_modern.html b/tests/unit/fixtures/html/dashboard_modern.html
new file mode 100644
index 0000000..b4df925
--- /dev/null
+++ b/tests/unit/fixtures/html/dashboard_modern.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+ Dashboard
+
+
+ Dashboard
+
+
diff --git a/tests/unit/fixtures/html/dashboard_no_version.html b/tests/unit/fixtures/html/dashboard_no_version.html
new file mode 100644
index 0000000..8d62285
--- /dev/null
+++ b/tests/unit/fixtures/html/dashboard_no_version.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ Dashboard
+
+
+ Dashboard content without any version metadata.
+
+
diff --git a/tests/unit/fixtures/html/folder_view_modern.html b/tests/unit/fixtures/html/folder_view_modern.html
new file mode 100644
index 0000000..2aa6d33
--- /dev/null
+++ b/tests/unit/fixtures/html/folder_view_modern.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ Folder
+
+
+
+
+
diff --git a/tests/unit/fixtures/html/login_page_modern.html b/tests/unit/fixtures/html/login_page_modern.html
new file mode 100644
index 0000000..ff72234
--- /dev/null
+++ b/tests/unit/fixtures/html/login_page_modern.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+ Log in
+
+
+
+
+
diff --git a/tests/unit/fixtures/html/sesskey_config_modern.html b/tests/unit/fixtures/html/sesskey_config_modern.html
new file mode 100644
index 0000000..9f29176
--- /dev/null
+++ b/tests/unit/fixtures/html/sesskey_config_modern.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Dashboard
+
+
+ Dashboard content
+
+
+
diff --git a/tests/unit/test_html_fixtures.py b/tests/unit/test_html_fixtures.py
new file mode 100644
index 0000000..b5428dd
--- /dev/null
+++ b/tests/unit/test_html_fixtures.py
@@ -0,0 +1,146 @@
+"""Offline regression tests for the brittle HTML parsers in ``py_moodle.compat``.
+
+These are characterization tests: the parsers already exist, so each test pins
+the current extraction behavior against a hand-authored HTML fixture file that
+mirrors representative Moodle markup. They run fully network-free under
+``make test-unit`` so a future Moodle-markup change (or an accidental selector
+regression) is caught early.
+
+The fixtures live in ``tests/unit/fixtures/html/`` and are loaded from disk via
+``pathlib`` relative to this file. Every embedded token/sesskey/version string
+is an obviously-fake placeholder, never a real secret.
+"""
+
+from pathlib import Path
+
+import pytest
+from bs4 import BeautifulSoup
+
+from py_moodle.compat import (
+ DEFAULT_COMPATIBILITY,
+ LEGACY_COMPATIBILITY,
+ LegacyCompatibilityStrategy,
+ ModernCompatibilityStrategy,
+ extract_version_from_dashboard,
+ get_strategy_for_version,
+)
+
+FIXTURES_DIR = Path(__file__).parent / "fixtures" / "html"
+
+
+def _load(name: str) -> str:
+ """Read the text of an HTML fixture from the fixtures directory.
+
+ Args:
+ name: Fixture file name relative to ``fixtures/html/``.
+
+ Returns:
+ str: Decoded UTF-8 contents of the fixture.
+ """
+ return (FIXTURES_DIR / name).read_text(encoding="utf-8")
+
+
+def _soup(name: str) -> BeautifulSoup:
+ """Parse an HTML fixture into a ``BeautifulSoup`` tree.
+
+ Args:
+ name: Fixture file name relative to ``fixtures/html/``.
+
+ Returns:
+ BeautifulSoup: Parsed document tree, using the same ``lxml`` parser the
+ production compatibility code relies on.
+ """
+ return BeautifulSoup(_load(name), "lxml")
+
+
+def test_extract_login_token_from_modern_login_page():
+ """The modern login fixture yields its fake ``logintoken`` value."""
+ html = _load("login_page_modern.html")
+
+ assert (
+ DEFAULT_COMPATIBILITY.extract_login_token(html)
+ == "fake-logintoken-9f8a7b6c5d4e3f2a"
+ )
+
+
+def test_extract_login_token_absent_returns_empty_string():
+ """A page without a login form yields an empty login token."""
+ html = _load("dashboard_modern.html")
+
+ assert DEFAULT_COMPATIBILITY.extract_login_token(html) == ""
+
+
+def test_extract_sesskey_from_config_page():
+ """The ``M.cfg`` config fixture yields its fake sesskey value."""
+ html = _load("sesskey_config_modern.html")
+
+ assert DEFAULT_COMPATIBILITY.extract_sesskey(html) == "fakeSesskey1a2b3c"
+
+
+def test_extract_sesskey_absent_returns_none():
+ """A login page carrying no sesskey yields ``None``."""
+ html = _load("login_page_modern.html")
+
+ assert DEFAULT_COMPATIBILITY.extract_sesskey(html) is None
+
+
+@pytest.mark.parametrize("strategy", [DEFAULT_COMPATIBILITY, LEGACY_COMPATIBILITY])
+def test_extract_folder_filenames_is_stable_across_strategies(strategy):
+ """Both strategies extract the same sorted filenames from the folder view.
+
+ This exercises the legacy/modern selector split: the fixture uses a
+ class-scoped ``pluginfile.php`` listing that both strategy selector orders
+ must resolve identically.
+ """
+ soup = _soup("folder_view_modern.html")
+
+ assert strategy.extract_folder_filenames(soup) == [
+ "notes.txt",
+ "slides.pdf",
+ "syllabus.docx",
+ ]
+
+
+def test_extract_version_from_modern_dashboard():
+ """The modern dashboard fixture parses to a 5.x ``MoodleVersion``."""
+ version = extract_version_from_dashboard(_load("dashboard_modern.html"))
+
+ assert version.major == 5
+ assert version.minor == 1
+ assert version.source == "html-meta"
+ assert version.raw.startswith("Moodle 5.1")
+
+
+def test_extract_version_from_legacy_dashboard():
+ """The legacy dashboard fixture parses to a 3.x ``MoodleVersion``."""
+ version = extract_version_from_dashboard(_load("dashboard_legacy.html"))
+
+ assert version.major == 3
+ assert version.minor == 11
+ assert version.patch == 16
+ assert version.source == "html-meta"
+
+
+def test_extract_version_absent_returns_unknown():
+ """A dashboard without a version marker degrades gracefully to unknown."""
+ version = extract_version_from_dashboard(_load("dashboard_no_version.html"))
+
+ assert version.major is None
+ assert version.raw == "unknown"
+
+
+@pytest.mark.parametrize(
+ ("fixture", "expected_major", "expected_strategy"),
+ [
+ ("dashboard_legacy.html", 3, LegacyCompatibilityStrategy),
+ ("dashboard_modern.html", 5, ModernCompatibilityStrategy),
+ ],
+)
+def test_strategy_selection_matches_dashboard_version(
+ fixture, expected_major, expected_strategy
+):
+ """Version parsed from a dashboard picks the matching legacy/modern strategy."""
+ version = extract_version_from_dashboard(_load(fixture))
+
+ assert version.major == expected_major
+ assert isinstance(get_strategy_for_version(version), expected_strategy)