Hugoifier

hugoifier / tests / test_enhance.py
Blame History Raw 424 lines
1
"""Tests for utils.enhance."""
2
import json
3
import os
4
import tempfile
5
import unittest
6
from unittest.mock import patch
7
8
from hugoifier.utils.enhance import (
9
_chunks,
10
_find_baseof,
11
_parse_ai_json,
12
_parse_frontmatter,
13
_read_body,
14
_read_site_context,
15
_seo_og_tags,
16
_update_frontmatter,
17
alt_text,
18
generate,
19
seo,
20
)
21
22
23
class TestReadSiteContext(unittest.TestCase):
24
def test_reads_title_from_hugo_toml(self):
25
with tempfile.TemporaryDirectory() as tmp:
26
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
27
f.write('title = "My Test Site"\n')
28
ctx = _read_site_context(tmp)
29
self.assertEqual(ctx["title"], "My Test Site")
30
31
def test_reads_title_from_config_toml(self):
32
with tempfile.TemporaryDirectory() as tmp:
33
with open(os.path.join(tmp, "config.toml"), "w") as f:
34
f.write('title = "Legacy Config"\n')
35
ctx = _read_site_context(tmp)
36
self.assertEqual(ctx["title"], "Legacy Config")
37
38
def test_hugo_toml_takes_precedence_over_config_toml(self):
39
with tempfile.TemporaryDirectory() as tmp:
40
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
41
f.write('title = "Hugo"\n')
42
with open(os.path.join(tmp, "config.toml"), "w") as f:
43
f.write('title = "Config"\n')
44
ctx = _read_site_context(tmp)
45
self.assertEqual(ctx["title"], "Hugo")
46
47
def test_reads_description(self):
48
with tempfile.TemporaryDirectory() as tmp:
49
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
50
f.write('title = "Site"\ndescription = "A fine site"\n')
51
ctx = _read_site_context(tmp)
52
self.assertEqual(ctx["description"], "A fine site")
53
54
def test_finds_content_sections(self):
55
with tempfile.TemporaryDirectory() as tmp:
56
os.makedirs(os.path.join(tmp, "content", "blog"))
57
os.makedirs(os.path.join(tmp, "content", "about"))
58
ctx = _read_site_context(tmp)
59
self.assertIn("blog", ctx["content_sections"])
60
self.assertIn("about", ctx["content_sections"])
61
62
def test_defaults_when_no_config(self):
63
with tempfile.TemporaryDirectory() as tmp:
64
ctx = _read_site_context(tmp)
65
self.assertEqual(ctx["title"], "My Hugo Site")
66
self.assertEqual(ctx["description"], "")
67
self.assertEqual(ctx["content_sections"], [])
68
69
def test_handles_missing_content_dir(self):
70
with tempfile.TemporaryDirectory() as tmp:
71
ctx = _read_site_context(tmp)
72
self.assertEqual(ctx["content_sections"], [])
73
self.assertEqual(ctx["sample_content"], "")
74
75
def test_reads_sample_content(self):
76
with tempfile.TemporaryDirectory() as tmp:
77
content = os.path.join(tmp, "content")
78
os.makedirs(content)
79
with open(os.path.join(content, "page.md"), "w") as f:
80
f.write("---\ntitle: Test\n---\n\nSome content.\n")
81
ctx = _read_site_context(tmp)
82
self.assertIn("Some content.", ctx["sample_content"])
83
84
85
class TestParseFrontmatter(unittest.TestCase):
86
def test_parses_yaml_frontmatter(self):
87
with tempfile.TemporaryDirectory() as tmp:
88
path = os.path.join(tmp, "page.md")
89
with open(path, "w") as f:
90
f.write("---\ntitle: Hello\ndate: 2024-01-01\ndraft: false\n---\n\nBody.\n")
91
result = _parse_frontmatter(path)
92
self.assertEqual(result["title"], "Hello")
93
self.assertFalse(result["draft"])
94
95
def test_returns_empty_dict_for_missing_frontmatter(self):
96
with tempfile.TemporaryDirectory() as tmp:
97
path = os.path.join(tmp, "bare.md")
98
with open(path, "w") as f:
99
f.write("Just some text.\n")
100
self.assertEqual(_parse_frontmatter(path), {})
101
102
def test_returns_empty_dict_for_missing_file(self):
103
self.assertEqual(_parse_frontmatter("/nonexistent/path.md"), {})
104
105
def test_returns_empty_dict_for_empty_frontmatter(self):
106
with tempfile.TemporaryDirectory() as tmp:
107
path = os.path.join(tmp, "empty_fm.md")
108
with open(path, "w") as f:
109
f.write("---\n\n---\nBody.\n")
110
self.assertEqual(_parse_frontmatter(path), {})
111
112
113
class TestUpdateFrontmatter(unittest.TestCase):
114
def test_adds_new_field(self):
115
with tempfile.TemporaryDirectory() as tmp:
116
path = os.path.join(tmp, "post.md")
117
with open(path, "w") as f:
118
f.write("---\ntitle: Hello\n---\nBody text here.\n")
119
_update_frontmatter(path, {"description": "A great post"})
120
fm = _parse_frontmatter(path)
121
self.assertEqual(fm["description"], "A great post")
122
self.assertEqual(fm["title"], "Hello")
123
124
def test_preserves_body(self):
125
with tempfile.TemporaryDirectory() as tmp:
126
path = os.path.join(tmp, "post.md")
127
with open(path, "w") as f:
128
f.write("---\ntitle: Hello\n---\nBody text here.\n")
129
_update_frontmatter(path, {"description": "desc"})
130
body = _read_body(path)
131
self.assertIn("Body text here.", body)
132
133
def test_updates_existing_field(self):
134
with tempfile.TemporaryDirectory() as tmp:
135
path = os.path.join(tmp, "post.md")
136
with open(path, "w") as f:
137
f.write("---\ntitle: Old Title\n---\nBody.\n")
138
_update_frontmatter(path, {"title": "New Title"})
139
fm = _parse_frontmatter(path)
140
self.assertEqual(fm["title"], "New Title")
141
142
def test_noop_without_frontmatter(self):
143
with tempfile.TemporaryDirectory() as tmp:
144
path = os.path.join(tmp, "bare.md")
145
with open(path, "w") as f:
146
f.write("No frontmatter here.\n")
147
_update_frontmatter(path, {"description": "test"})
148
with open(path, "r") as f:
149
self.assertEqual(f.read(), "No frontmatter here.\n")
150
151
152
class TestReadBody(unittest.TestCase):
153
def test_extracts_body_after_frontmatter(self):
154
with tempfile.TemporaryDirectory() as tmp:
155
path = os.path.join(tmp, "post.md")
156
with open(path, "w") as f:
157
f.write("---\ntitle: Test\n---\n\nThe body content.\n")
158
body = _read_body(path)
159
self.assertEqual(body, "The body content.")
160
161
def test_returns_full_content_without_frontmatter(self):
162
with tempfile.TemporaryDirectory() as tmp:
163
path = os.path.join(tmp, "bare.md")
164
with open(path, "w") as f:
165
f.write("Just text, no frontmatter.\n")
166
body = _read_body(path)
167
self.assertIn("Just text", body)
168
169
def test_returns_empty_for_missing_file(self):
170
self.assertEqual(_read_body("/nonexistent/path.md"), "")
171
172
173
class TestFindBaseof(unittest.TestCase):
174
def test_finds_baseof_in_layouts(self):
175
with tempfile.TemporaryDirectory() as tmp:
176
baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
177
os.makedirs(os.path.dirname(baseof))
178
with open(baseof, "w") as f:
179
f.write("<html></html>")
180
self.assertEqual(_find_baseof(tmp), baseof)
181
182
def test_finds_baseof_in_themes(self):
183
with tempfile.TemporaryDirectory() as tmp:
184
baseof = os.path.join(tmp, "themes", "mytheme", "layouts", "_default", "baseof.html")
185
os.makedirs(os.path.dirname(baseof))
186
with open(baseof, "w") as f:
187
f.write("<html></html>")
188
self.assertEqual(_find_baseof(tmp), baseof)
189
190
def test_prefers_layouts_over_themes(self):
191
with tempfile.TemporaryDirectory() as tmp:
192
layouts_baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
193
os.makedirs(os.path.dirname(layouts_baseof))
194
with open(layouts_baseof, "w") as f:
195
f.write("<html>layouts</html>")
196
theme_baseof = os.path.join(tmp, "themes", "t", "layouts", "_default", "baseof.html")
197
os.makedirs(os.path.dirname(theme_baseof))
198
with open(theme_baseof, "w") as f:
199
f.write("<html>theme</html>")
200
self.assertEqual(_find_baseof(tmp), layouts_baseof)
201
202
def test_returns_none_when_missing(self):
203
with tempfile.TemporaryDirectory() as tmp:
204
self.assertIsNone(_find_baseof(tmp))
205
206
207
class TestParseAiJson(unittest.TestCase):
208
def test_parses_valid_json(self):
209
result = _parse_ai_json('{"key": "value"}')
210
self.assertEqual(result, {"key": "value"})
211
212
def test_parses_fenced_json(self):
213
result = _parse_ai_json('```json\n{"key": "value"}\n```')
214
self.assertEqual(result, {"key": "value"})
215
216
def test_parses_json_embedded_in_prose(self):
217
result = _parse_ai_json('Here is the result:\n{"title": "Hello"}\nDone.')
218
self.assertEqual(result, {"title": "Hello"})
219
220
def test_returns_none_for_invalid(self):
221
self.assertIsNone(_parse_ai_json("not json at all"))
222
223
def test_returns_none_for_json_list(self):
224
self.assertIsNone(_parse_ai_json('[1, 2, 3]'))
225
226
def test_returns_none_for_empty_string(self):
227
self.assertIsNone(_parse_ai_json(""))
228
229
def test_parses_fenced_json_without_language(self):
230
result = _parse_ai_json('```\n{"a": 1}\n```')
231
self.assertEqual(result, {"a": 1})
232
233
234
class TestChunks(unittest.TestCase):
235
def test_splits_evenly(self):
236
result = list(_chunks([1, 2, 3, 4], 2))
237
self.assertEqual(result, [[1, 2], [3, 4]])
238
239
def test_handles_remainder(self):
240
result = list(_chunks([1, 2, 3, 4, 5], 2))
241
self.assertEqual(result, [[1, 2], [3, 4], [5]])
242
243
def test_empty_list(self):
244
result = list(_chunks([], 3))
245
self.assertEqual(result, [])
246
247
def test_chunk_larger_than_list(self):
248
result = list(_chunks([1, 2], 10))
249
self.assertEqual(result, [[1, 2]])
250
251
def test_chunk_size_one(self):
252
result = list(_chunks([1, 2, 3], 1))
253
self.assertEqual(result, [[1], [2], [3]])
254
255
256
class TestSeoOgTags(unittest.TestCase):
257
def test_injects_og_tags(self):
258
with tempfile.TemporaryDirectory() as tmp:
259
baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
260
os.makedirs(os.path.dirname(baseof))
261
with open(baseof, "w") as f:
262
f.write("<html><head><title>Test</title></head><body></body></html>")
263
result = _seo_og_tags(tmp)
264
self.assertIn("OG tags", result)
265
with open(baseof, "r") as f:
266
html = f.read()
267
self.assertIn("og:title", html)
268
self.assertIn("og:description", html)
269
self.assertIn("og:url", html)
270
271
def test_skips_if_already_present(self):
272
with tempfile.TemporaryDirectory() as tmp:
273
baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
274
os.makedirs(os.path.dirname(baseof))
275
with open(baseof, "w") as f:
276
f.write('<html><head><meta property="og:title" content="x" /></head></html>')
277
result = _seo_og_tags(tmp)
278
self.assertIn("already present", result)
279
280
def test_returns_message_when_no_baseof(self):
281
with tempfile.TemporaryDirectory() as tmp:
282
result = _seo_og_tags(tmp)
283
self.assertIn("No baseof.html found", result)
284
285
286
class TestGenerate(unittest.TestCase):
287
@patch("hugoifier.utils.enhance.call_ai")
288
def test_creates_content_files(self, mock_ai):
289
ai_response = json.dumps({
290
"blog/first-post.md": "---\ntitle: First Post\ndate: 2026-03-17\ndescription: A post\n---\n\nHello world.\n",
291
"blog/second-post.md": "---\ntitle: Second Post\ndate: 2026-03-17\ndescription: Another post\n---\n\nGoodbye world.\n",
292
})
293
mock_ai.return_value = ai_response
294
295
with tempfile.TemporaryDirectory() as tmp:
296
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
297
f.write('title = "Test"\n')
298
result = generate(tmp, prompt="Write blog posts")
299
self.assertIn("Generated 2", result)
300
self.assertTrue(os.path.exists(os.path.join(tmp, "content", "blog", "first-post.md")))
301
self.assertTrue(os.path.exists(os.path.join(tmp, "content", "blog", "second-post.md")))
302
with open(os.path.join(tmp, "content", "blog", "first-post.md"), "r") as f:
303
self.assertIn("Hello world.", f.read())
304
305
@patch("hugoifier.utils.enhance.call_ai")
306
def test_handles_invalid_ai_response(self, mock_ai):
307
mock_ai.return_value = "not valid json"
308
with tempfile.TemporaryDirectory() as tmp:
309
result = generate(tmp, prompt="Write posts")
310
self.assertIn("Could not generate content", result)
311
312
@patch("hugoifier.utils.enhance.call_ai")
313
def test_from_file_reads_example(self, mock_ai):
314
ai_response = json.dumps({
315
"page.md": "---\ntitle: New Page\ndate: 2026-03-17\ndescription: New\n---\n\nContent.\n",
316
})
317
mock_ai.return_value = ai_response
318
319
with tempfile.TemporaryDirectory() as tmp:
320
example = os.path.join(tmp, "example.md")
321
with open(example, "w") as f:
322
f.write("---\ntitle: Example\n---\n\nExample content.\n")
323
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
324
f.write('title = "Test"\n')
325
result = generate(tmp, from_file=example)
326
self.assertIn("Generated 1", result)
327
# Verify call_ai was called with the example content
328
prompt_arg = mock_ai.call_args[0][0]
329
self.assertIn("Example content.", prompt_arg)
330
331
332
class TestSeo(unittest.TestCase):
333
@patch("hugoifier.utils.enhance.call_ai")
334
def test_finds_missing_descriptions(self, mock_ai):
335
mock_ai.return_value = json.dumps({"My Post": "A short description."})
336
with tempfile.TemporaryDirectory() as tmp:
337
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
338
f.write('title = "Test"\n')
339
content = os.path.join(tmp, "content")
340
os.makedirs(content)
341
path = os.path.join(content, "post.md")
342
with open(path, "w") as f:
343
f.write("---\ntitle: My Post\n---\n\nSome body text.\n")
344
result = seo(tmp)
345
self.assertIn("Added meta descriptions to 1", result)
346
fm = _parse_frontmatter(path)
347
self.assertEqual(fm["description"], "A short description.")
348
349
@patch("hugoifier.utils.enhance.call_ai")
350
def test_skips_files_with_descriptions(self, mock_ai):
351
with tempfile.TemporaryDirectory() as tmp:
352
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
353
f.write('title = "Test"\n')
354
content = os.path.join(tmp, "content")
355
os.makedirs(content)
356
with open(os.path.join(content, "post.md"), "w") as f:
357
f.write("---\ntitle: My Post\ndescription: Already set\n---\n\nBody.\n")
358
result = seo(tmp)
359
self.assertIn("already have descriptions", result)
360
mock_ai.assert_not_called()
361
362
def test_handles_missing_content_dir(self):
363
with tempfile.TemporaryDirectory() as tmp:
364
result = seo(tmp)
365
self.assertIn("No content/", result)
366
367
368
class TestAltText(unittest.TestCase):
369
@patch("hugoifier.utils.enhance.call_ai")
370
def test_finds_images_without_alt(self, mock_ai):
371
mock_ai.return_value = json.dumps({"logo.png": "Company logo"})
372
with tempfile.TemporaryDirectory() as tmp:
373
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
374
f.write('title = "Test"\n')
375
layouts = os.path.join(tmp, "layouts")
376
os.makedirs(layouts)
377
tpl = os.path.join(layouts, "index.html")
378
with open(tpl, "w") as f:
379
f.write('<html><body><img src="logo.png"></body></html>')
380
result = alt_text(tmp)
381
self.assertIn("Added alt text to 1", result)
382
with open(tpl, "r") as f:
383
html = f.read()
384
self.assertIn('alt="Company logo"', html)
385
386
@patch("hugoifier.utils.enhance.call_ai")
387
def test_skips_images_with_alt(self, mock_ai):
388
with tempfile.TemporaryDirectory() as tmp:
389
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
390
f.write('title = "Test"\n')
391
layouts = os.path.join(tmp, "layouts")
392
os.makedirs(layouts)
393
tpl = os.path.join(layouts, "index.html")
394
with open(tpl, "w") as f:
395
f.write('<html><body><img src="logo.png" alt="A logo"></body></html>')
396
result = alt_text(tmp)
397
self.assertIn("All images already have alt text", result)
398
mock_ai.assert_not_called()
399
400
@patch("hugoifier.utils.enhance.call_ai")
401
def test_finds_images_with_empty_alt(self, mock_ai):
402
mock_ai.return_value = json.dumps({"photo.jpg": "A photo"})
403
with tempfile.TemporaryDirectory() as tmp:
404
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
405
f.write('title = "Test"\n')
406
layouts = os.path.join(tmp, "layouts")
407
os.makedirs(layouts)
408
tpl = os.path.join(layouts, "page.html")
409
with open(tpl, "w") as f:
410
f.write('<html><body><img src="photo.jpg" alt=""></body></html>')
411
result = alt_text(tmp)
412
self.assertIn("Added alt text to 1", result)
413
414
def test_no_templates_returns_message(self):
415
with tempfile.TemporaryDirectory() as tmp:
416
with open(os.path.join(tmp, "hugo.toml"), "w") as f:
417
f.write('title = "Test"\n')
418
result = alt_text(tmp)
419
self.assertIn("All images already have alt text", result)
420
421
422
if __name__ == "__main__":
423
unittest.main()
424

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button