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

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""" 

9 

10from __future__ import annotations 

11 

12import sys 

13import warnings 

14from pathlib import Path 

15from types import ModuleType 

16from typing import Any, cast, Dict 

17 

18import pytest 

19 

20import muutils.web.inline_html as ia 

21 

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>""" 

33 

34CSS_CONTENT: str = "body { color: red; }" 

35JS_CONTENT: str = "console.log('hello');" 

36 

37 

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" 

47 

48 html_path.write_text(SAMPLE_HTML) 

49 css_path.write_text(CSS_CONTENT) 

50 js_path.write_text(JS_CONTENT) 

51 

52 return { 

53 "dir": tmp_path, 

54 "html": html_path, 

55 "css": css_path, 

56 "js": js_path, 

57 } 

58 

59 

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 

64 

65 def prettify(self) -> str: # noqa: D401 

66 return f"PRETTIFIED\n{self._html}" 

67 

68 

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) 

73 

74 

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) 

95 

96 base_dir: Path = cast(Path, project["dir"]) 

97 html_src: str = project["html"].read_text() 

98 

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 ) 

106 

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 

112 

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 

116 

117 # If prettified via our stub, sentinel present 

118 if prettify and bs4_present: 

119 assert result.startswith("PRETTIFIED") 

120 

121 

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" 

133 

134 # Guard: ensure bs4 missing path exercised 

135 if "bs4" in sys.modules: 

136 monkeypatch.delitem(sys.modules, "bs4", raising=False) 

137 

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() 

145 

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 

153 

154 

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 

162 

163 

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"]) 

170 

171 

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"]) 

184 

185 

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"]) 

197 

198 

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]) 

216 

217 

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 

228 

229 

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) 

241 

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