Skip to main content

tests/python/test_static_site_tests.py

1"""
2Tests for `chives.static_site_tests`.
3"""
5from pathlib import Path
6import shutil
7import subprocess
8from typing import Any, TypeVar
10import pytest
11from pytest import Pytester
13from chives import dates
16M = TypeVar("M")
18GIT_ROOT = Path(
19 subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()
23@pytest.fixture
24def site_root(tmp_path: Path) -> Path:
25 """
26 Return a temp directory to use as a site root.
27 """
28 return tmp_path
31def create_pyfile(
32 pytester: Pytester,
33 site_root: Path | None = None,
34 metadata: Any = None,
35 *,
36 paths_in_metadata: set[Path] | None = None,
37 tags_in_metadata: set[str] | None = None,
38 date_formats: list[str] | None = None,
39 known_similar_tags: set[tuple[str, str]] | None = None,
40) -> None:
41 """
42 Create a new instance of `pytest.Pytester` which is ready to run
43 a test suite based on StaticSiteTestSuite.
44 """
45 default_date_formats = [
46 "%Y-%m-%dT%H:%M:%SZ",
47 "%Y-%m-%d",
48 ]
50 pytester.makepyfile(
51 f"""
52 from collections.abc import Iterator
53 from pathlib import Path, PosixPath
54 from typing import Any
56 import pytest
58 from chives.static_site_tests import StaticSiteTestSuite
61 class TestSuite(StaticSiteTestSuite[Any]):
62 @classmethod
63 def get_site_root(self) -> Path:
64 return Path({str(site_root or pytester.path)!r})
66 @pytest.fixture
67 def metadata(self, site_root: Path) -> Any:
68 return {repr(metadata)}
70 def list_paths_in_metadata(self, metadata: Any) -> set[Path]:
71 return {repr(paths_in_metadata or set())}
73 def list_tags_in_metadata(self, metadata: Any) -> Iterator[str]:
74 yield from {repr(tags_in_metadata or set())}
76 date_formats = {repr(date_formats or default_date_formats)}
78 known_similar_tags = {repr(known_similar_tags or set())}
79 """
80 )
83def test_paths_saved_locally_match_metadata(
84 pytester: Pytester, site_root: Path
85) -> None:
86 """
87 The tests check that the set of paths saved locally match the metadata.
88 """
89 # Create a series of paths in tmp_path.
90 for filename in [
91 "index.html",
92 "metadata.js",
93 "media/cat.jpg",
94 "media/dog.png",
95 "media/emu.gif",
96 "viewer/index.html",
97 ".DS_Store",
98 ]:
99 p = site_root / filename
100 p.parent.mkdir(exist_ok=True)
101 p.write_text("test")
103 metadata = [Path("media/cat.jpg"), Path("media/dog.png"), Path("media/emu.gif")]
105 create_pyfile(pytester, site_root, metadata, paths_in_metadata=set(metadata))
107 keyword = (
108 "test_every_file_in_metadata_is_saved_locally or "
109 "test_every_local_file_is_in_metadata"
110 )
111 pytester.runpytest("-k", keyword).assert_outcomes(passed=2)
113 # Add a new file locally, and check the test starts failing.
114 (site_root / "media/fish.tiff").write_text("test")
115 pytester.runpytest("-k", keyword).assert_outcomes(passed=1, failed=1)
116 (site_root / "media/fish.tiff").unlink()
118 # Delete one of the local files, and check the test starts failing.
119 (site_root / "media/cat.jpg").unlink()
120 pytester.runpytest("-k", keyword).assert_outcomes(passed=1, failed=1)
123def test_checks_for_git_changes(pytester: Pytester, site_root: Path) -> None:
124 """
125 The tests check that there are no uncommitted Git changes.
126 """
127 create_pyfile(pytester, site_root)
129 keyword = "test_no_uncommitted_git_changes"
131 # Initially this should fail, because there isn't a Git repo in
132 # the folder.
133 pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
135 # Create a Git repo, add a file, and commit it.
136 (site_root / "README.md").write_text("hello world")
137 subprocess.check_call(["git", "init"], cwd=site_root)
138 subprocess.check_call(["git", "add", "README.md"], cwd=site_root)
139 subprocess.check_call(["git", "commit", "-m", "initial commit"], cwd=site_root)
141 # Check there are no uncommitted Git changes
142 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
144 # Make a new change, and check it's spotted
145 (site_root / "README.md").write_text("a different hello world")
146 pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
149def test_checks_for_url_safe_paths(pytester: Pytester, site_root: Path) -> None:
150 """
151 The tests check for URL-safe paths.
152 """
153 create_pyfile(pytester, site_root)
155 keyword = "test_every_path_is_url_safe"
157 # This should pass trivially when the site is empty.
158 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
160 # Now write some files with URL-safe names, and check it's still okay.
161 for filename in [
162 "index.html",
163 "metadata.js",
164 ".DS_Store",
165 ]:
166 (site_root / filename).write_text("test")
168 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
170 # Write another file with a URL-unsafe name, and check it's caught
171 # by the test.
172 (site_root / "a#b#c").write_text("test")
173 pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
176def test_checks_for_av1_videos(pytester: Pytester, site_root: Path) -> None:
177 """
178 The tests check for AV1-encoded videos.
179 """
180 create_pyfile(pytester, site_root)
182 keyword = "test_no_videos_are_av1"
184 # This should pass trivially when the site is empty.
185 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
187 # Copy in an H.264-encoded video, and check it's not flagged.
188 shutil.copyfile(
189 GIT_ROOT / "tests/fixtures/media/Sintel_360_10s_1MB_H264.mp4",
190 site_root / "Sintel_360_10s_1MB_H264.mp4",
191 )
192 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
194 # Copy in an AV1-encoded video, and check it's caught by the test
195 shutil.copyfile(
196 GIT_ROOT / "tests/fixtures/media/Sintel_360_10s_1MB_AV1.mp4",
197 site_root / "Sintel_360_10s_1MB_AV1.mp4",
198 )
199 pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
202def test_checks_for_srt_subtitles(pytester: Pytester, site_root: Path) -> None:
203 """
204 The tests check for SRT-encoded subtitles, and fail if they're detected.
205 """
206 create_pyfile(pytester, site_root)
208 keyword = "test_no_subtitles_are_srt"
210 # This should pass trivially when the site is empty.
211 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
213 # Add a VTT-encoded subtitle, and check the test still passes.
214 (site_root / "my-great-show").mkdir()
215 (site_root / "my-great-show/e01 - Episode the First.en.vtt").write_text("")
216 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
218 # Add an SRT-encoded subtitle, and check the test fails
219 (site_root / "my-great-show/e02 - Episode the Second.en.srt").write_text("")
220 pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
223class TestAllTimestampsAreConsistent:
224 """
225 Tests for the `test_all_timestamps_are_consistent` method.
226 """
228 @pytest.mark.parametrize(
229 "metadata",
230 [
231 {"date_saved": "2025-12-06"},
232 {"date_saved": dates.now()},
233 ],
234 )
235 def test_allows_correct_date_formats(
236 self, pytester: Pytester, metadata: Any
237 ) -> None:
238 """
239 The tests pass if all the dates are in the correct format.
240 """
241 create_pyfile(pytester, metadata=metadata)
243 keyword = "test_all_timestamps_are_consistent"
245 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
247 @pytest.mark.parametrize("metadata", [{"date_saved": "AAAA-BB-CC"}])
248 def test_rejects_incorrect_date_formats(
249 self, pytester: Pytester, site_root: Path, metadata: Any
250 ) -> None:
251 """
252 The tests fail if the metadata has inconsistent date formats.
253 """
254 create_pyfile(pytester, metadata=metadata)
256 keyword = "test_all_timestamps_are_consistent"
258 pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
260 def test_can_override_date_formats(self, pytester: Pytester) -> None:
261 """
262 A previously-blocked date format is allowed if you add it to
263 the `date_formats` list.
264 """
265 metadata = {"date_saved": "2025"}
266 keyword = "test_all_timestamps_are_consistent"
268 # It fails with the default settings
269 create_pyfile(pytester, metadata=metadata)
270 pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
272 # It passes if we add the format to `date_formats`
273 create_pyfile(pytester, metadata=metadata, date_formats=["%Y"])
274 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
277@pytest.mark.parametrize(
278 "tags_in_metadata, known_similar_tags, expected_outcome",
279 [
280 pytest.param({"red", "green", "blue"}, {}, {"passed": 1}, id="distinct_tags"),
281 pytest.param({"red robot", "rod robot"}, {}, {"failed": 1}, id="similar_tags"),
282 pytest.param(
283 {"red robot", "rod robot"},
284 {("red robot", "rod robot")},
285 {"passed": 1},
286 id="similar_tags_marked_known",
287 ),
288 ],
290def test_checks_for_similar_tags(
291 pytester: Pytester,
292 tags_in_metadata: set[str],
293 known_similar_tags: set[tuple[str, str]],
294 expected_outcome: dict[str, int],
295) -> None:
296 """
297 The tests check for similar and misspelt tags.
298 """
299 keyword = "test_no_similar_tags"
301 create_pyfile(
302 pytester,
303 tags_in_metadata=tags_in_metadata,
304 known_similar_tags=known_similar_tags,
305 )
306 pytester.runpytest("-k", keyword).assert_outcomes(**expected_outcome)
309class TestChivesJSisUpToDate:
310 """
311 Tests for the `test_chives_js_is_up_to_date` method.
312 """
314 def test_no_js_is_passing(self, pytester: Pytester) -> None:
315 """
316 If the site doesn't have any JavaScript files, it's trivially okay.
317 """
318 create_pyfile(pytester)
320 keyword = "test_chives_js_is_up_to_date"
322 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
324 def test_correct_js_is_passing(self, pytester: Pytester, site_root: Path) -> None:
325 """
326 If the site has up-to-date JavaScript files, it's okay.
327 """
328 shutil.copyfile(
329 GIT_ROOT / "javascript/chives-image.js", site_root / "chives-image.js"
330 )
332 (site_root / "static").mkdir()
333 shutil.copyfile(
334 GIT_ROOT / "javascript/chives-image.js",
335 site_root / "static/chives-image.js",
336 )
338 create_pyfile(pytester, site_root=site_root)
340 keyword = "test_chives_js_is_up_to_date"
342 pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
344 @pytest.mark.parametrize(
345 "filename, header",
346 [
347 (
348 # Wrong filename
349 "chives-example.js",
350 "// javascript/chives-wrongname.js\n// chives version: 48",
351 ),
352 (
353 # Missing javascript/ prefixes
354 "chives-example.js",
355 "// chives-example.js\n// chives version: 48",
356 ),
357 (
358 # Missing version line
359 "chives-example.js",
360 "// javascript/chives-example.js\n",
361 ),
362 (
363 # Old version line
364 "chives-example.js",
365 "// javascript/chives-example.js\n// chives version: 1",
366 ),
367 ],
368 )
369 def test_incorrect_js_is_error(
370 self, pytester: Pytester, site_root: Path, filename: str, header: str
371 ) -> None:
372 """
373 If the site has an old chives-js file or a file with an incorrect
374 header, it fails this test.
375 """
376 # Create a chives-*.js file in the site root with a bad header.
377 (site_root / filename).write_text(
378 header + "\n\n" + "function greet() { console.log('hello world'); }"
379 )
381 create_pyfile(pytester, site_root=site_root)
383 keyword = "test_chives_js_is_up_to_date"
385 pytester.runpytest("-k", keyword).assert_outcomes(failed=1)