Coverage for tests/unit/web/test_inline_html.py: 94%
100 statements
« prev ^ index » next coverage.py v7.6.1, created at 2025-05-30 22:10 -0600
« prev ^ index » next coverage.py v7.6.1, created at 2025-05-30 22:10 -0600
1"""
2Covers:
3• Every flag combination (`include_filename_comments`, `prettify`).
4• Both “bs4 present” (stub) and “bs4 missing” branches.
5• `inline_html_file` end-to-end round-trip.
6• All documented failure paths + extra edge-cases (missing file, zero matches, duplicates, bad indentation).
7• Indentation and comment-block integrity.
8"""
10from __future__ import annotations
12import sys
13import warnings
14from pathlib import Path
15from types import ModuleType
16from typing import Any, cast, Dict
18import pytest
20import muutils.web.inline_html as ia
22# --------------------------------------------------------------------------- #
23# Constants #
24# --------------------------------------------------------------------------- #
25SAMPLE_HTML: str = """<html>
26<head>
27 <link rel="stylesheet" href="style.css">
28</head>
29<body>
30 <script src="app.js"></script>
31</body>
32</html>"""
34CSS_CONTENT: str = "body { color: red; }"
35JS_CONTENT: str = "console.log('hello');"
38# --------------------------------------------------------------------------- #
39# Fixtures #
40# --------------------------------------------------------------------------- #
41@pytest.fixture()
42def project(tmp_path: Path) -> Dict[str, Any]:
43 """Set up a temporary project directory with HTML, CSS, JS (all referenced)."""
44 html_path: Path = tmp_path / "index.html"
45 css_path: Path = tmp_path / "style.css"
46 js_path: Path = tmp_path / "app.js"
48 html_path.write_text(SAMPLE_HTML)
49 css_path.write_text(CSS_CONTENT)
50 js_path.write_text(JS_CONTENT)
52 return {
53 "dir": tmp_path,
54 "html": html_path,
55 "css": css_path,
56 "js": js_path,
57 }
60# Helper – monkey-patch a *working* BeautifulSoup with predictable output
61class _StubSoup: # noqa: D401
62 def __init__(self, html: str, _parser: str) -> None:
63 self._html: str = html
65 def prettify(self) -> str: # noqa: D401
66 return f"PRETTIFIED\n{self._html}"
69def _install_bs4_stub(monkeypatch: pytest.MonkeyPatch) -> None:
70 dummy_bs: ModuleType = ModuleType("bs4") # type: ignore[assignment]
71 dummy_bs.BeautifulSoup = _StubSoup # type: ignore[attr-defined]
72 monkeypatch.setitem(sys.modules, "bs4", dummy_bs)
75# --------------------------------------------------------------------------- #
76# Parametrised happy-path test (all flag combos + bs4 present / missing) #
77# --------------------------------------------------------------------------- #
78@pytest.mark.parametrize(
79 "include_comments", [True, False], ids=lambda b: f"comments={b}"
80)
81@pytest.mark.parametrize("bs4_present", [True, False], ids=lambda b: f"bs4={b}")
82@pytest.mark.parametrize("prettify", [True, False], ids=lambda b: f"prettify={b}")
83def test_inline_html_assets_matrix(
84 project: Dict[str, Any],
85 include_comments: bool,
86 prettify: bool,
87 bs4_present: bool,
88 monkeypatch: pytest.MonkeyPatch,
89) -> None:
90 """Exhaustive cartesian-product of switch settings."""
91 if prettify and bs4_present:
92 _install_bs4_stub(monkeypatch)
93 elif "bs4" in sys.modules:
94 monkeypatch.delitem(sys.modules, "bs4", raising=False)
96 base_dir: Path = cast(Path, project["dir"])
97 html_src: str = project["html"].read_text()
99 result: str = ia.inline_html_assets(
100 html_src,
101 assets=[("script", Path("app.js")), ("style", Path("style.css"))],
102 base_path=base_dir,
103 include_filename_comments=include_comments,
104 prettify=prettify,
105 )
107 # --- Inlined content checks ---
108 assert CSS_CONTENT in result
109 assert JS_CONTENT in result
110 assert '<script src="app.js"></script>' not in result
111 assert '<link rel="stylesheet" href="style.css">' not in result
113 # Comment blocks appear exactly when requested
114 assert ("<!-- begin 'style.css' -->" in result) is include_comments
115 assert ("<!-- end 'style.css' -->" in result) is include_comments
117 # If prettified via our stub, sentinel present
118 if prettify and bs4_present:
119 assert result.startswith("PRETTIFIED")
122# --------------------------------------------------------------------------- #
123# inline_html_file end-to-end (both comment modes) #
124# --------------------------------------------------------------------------- #
125@pytest.mark.parametrize("include_comments", [True, False])
126def test_inline_html_file_roundtrip(
127 project: Dict[str, Any],
128 include_comments: bool,
129 monkeypatch: pytest.MonkeyPatch,
130) -> None:
131 """All files in directory are auto-detected and inlined."""
132 out_path: Path = project["dir"] / f"out_{include_comments}.html"
134 # Guard: ensure bs4 missing path exercised
135 if "bs4" in sys.modules:
136 monkeypatch.delitem(sys.modules, "bs4", raising=False)
138 ia.inline_html_file(
139 html_path=project["html"],
140 output_path=out_path,
141 include_filename_comments=include_comments,
142 prettify=False,
143 )
144 out_html: str = out_path.read_text()
146 # No raw tags left; content present
147 assert CSS_CONTENT in out_html
148 assert JS_CONTENT in out_html
149 assert '<script src="app.js"></script>' not in out_html
150 assert '<link rel="stylesheet" href="style.css">' not in out_html
151 # Correct comment behaviour
152 assert ("<!-- begin 'style.css' -->" in out_html) is include_comments
155# --------------------------------------------------------------------------- #
156# Error & edge-case tests #
157# --------------------------------------------------------------------------- #
158def test_unsupported_asset_type(project: Dict[str, Any]) -> None:
159 html_src: str = project["html"].read_text()
160 with pytest.raises(ValueError, match="Unsupported tag type"):
161 ia.inline_html_assets(html_src, [("video", Path("demo.mp4"))], project["dir"]) # type: ignore
164def test_asset_file_missing(project: Dict[str, Any]) -> None:
165 html_src: str = project["html"].read_text()
166 # Delete the physical JS file
167 project["js"].unlink()
168 with pytest.raises(FileNotFoundError):
169 ia.inline_html_assets(html_src, [("script", Path("app.js"))], project["dir"])
172@pytest.mark.parametrize("occurrences", [0, 2], ids=lambda n: f"occurs={n}")
173def test_pattern_not_exactly_once(project: Dict[str, Any], occurrences: int) -> None:
174 html_src: str = project["html"].read_text()
175 if occurrences == 0:
176 html_src = html_src.replace('<script src="app.js"></script>', "")
177 else: # duplicate
178 html_src = html_src.replace(
179 '<script src="app.js"></script>',
180 '<script src="app.js"></script>\n <script src="app.js"></script>',
181 )
182 with pytest.raises(AssertionError, match="exactly once"):
183 ia.inline_html_assets(html_src, [("script", Path("app.js"))], project["dir"])
186def test_tag_not_alone_on_line(project: Dict[str, Any]) -> None:
187 html_src: str = (
188 project["html"]
189 .read_text()
190 .replace(
191 ' <link rel="stylesheet" href="style.css">',
192 ' <!--pre--><link rel="stylesheet" href="style.css">',
193 )
194 )
195 with pytest.raises(AssertionError, match="alone in its line"):
196 ia.inline_html_assets(html_src, [("style", Path("style.css"))], project["dir"])
199@pytest.mark.skip("can't figure out how to prevent bs4 import")
200def test_prettify_without_bs4_emits_warning(
201 project: Dict[str, Any], monkeypatch: pytest.MonkeyPatch
202) -> None:
203 """Not having BeautifulSoup installed is *warning*, not fatal."""
204 # Ensure bs4 absent
205 monkeypatch.delitem(sys.modules, "bs4", raising=False)
206 html_src: str = project["html"].read_text()
207 with warnings.catch_warnings(record=True) as rec:
208 warnings.simplefilter("always")
209 ia.inline_html_assets(
210 html_src,
211 [("script", Path("app.js"))],
212 project["dir"],
213 prettify=True,
214 )
215 assert any(["BeautifulSoup is not installed" in str(w.message) for w in rec])
218def test_mixed_asset_order(project: Dict[str, Any]) -> None:
219 """Order of asset tuples can be arbitrary."""
220 html_src: str = project["html"].read_text()
221 result: str = ia.inline_html_assets(
222 html_src,
223 # reversed order
224 [("style", Path("style.css")), ("script", Path("app.js"))],
225 project["dir"],
226 )
227 assert CSS_CONTENT in result and JS_CONTENT in result
230def test_multiple_assets_same_type(project: Dict[str, Any]) -> None:
231 """Supports >1 asset of given type provided patterns are unique."""
232 # Add second css file
233 css2: Path = project["dir"] / "extra.css"
234 css2.write_text("h1{font-size:2em;}")
235 html_mod: str = SAMPLE_HTML.replace(
236 "</head>",
237 ' <link rel="stylesheet" href="extra.css">\n</head>',
238 )
239 # Update on disk
240 project["html"].write_text(html_mod)
242 result: str = ia.inline_html_assets(
243 html_mod,
244 [
245 ("style", Path("style.css")),
246 ("style", Path("extra.css")),
247 ("script", Path("app.js")),
248 ],
249 project["dir"],
250 )
251 assert "h1{font-size:2em;}" in result