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