From 142a48ad5ed8886b22fbd29924bc13bd254753d7 Mon Sep 17 00:00:00 2001 From: Djeex Date: Sun, 23 Aug 2026 10:24:02 +0200 Subject: [PATCH] Add pytest suite and Gitea Actions CI/CD pipeline 83 tests (94% coverage of src/py) covering the builder pipeline (gallery sync, HTML/CSS generation, image processing, full site build) and the Flask webui (routes, uploads, theme/font management). Dockerfile gains a `test` stage (pytest) between the wheel builder and the prod image, and pins the alpine base to a full patch tag so Renovate can classify updates. CI workflow builds, smoke-tests, runs the suite, scans with Trivy, and publishes/releases on merge to main, following the same pipeline already running on adguard-cidre. --- .gitea/workflows/ci.yml | 141 +++++++++++++++ .gitignore | 6 +- Dockerfile | 13 +- pytest.ini | 3 + renovate.json | 29 ++++ requirements-dev.txt | 3 + tests/conftest.py | 86 ++++++++++ tests/test_builder_utils.py | 93 ++++++++++ tests/test_css_generator.py | 68 ++++++++ tests/test_gallery_builder.py | 113 ++++++++++++ tests/test_html_generator.py | 67 ++++++++ tests/test_image_processor.py | 134 +++++++++++++++ tests/test_site_builder.py | 85 +++++++++ tests/test_upload.py | 82 +++++++++ tests/test_webui_routes.py | 312 ++++++++++++++++++++++++++++++++++ 15 files changed, 1232 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 pytest.ini create mode 100644 renovate.json create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_builder_utils.py create mode 100644 tests/test_css_generator.py create mode 100644 tests/test_gallery_builder.py create mode 100644 tests/test_html_generator.py create mode 100644 tests/test_image_processor.py create mode 100644 tests/test_site_builder.py create mode 100644 tests/test_upload.py create mode 100644 tests/test_webui_routes.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..aa5cc40 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,141 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Build prod image + run: docker build -t lumeex:ci . + + - name: Smoke test + run: | + docker run --rm --entrypoint python lumeex:ci -c " + import ast + ast.parse(open('build.py').read()) + ast.parse(open('gallery.py').read()) + print('syntax ok') + " + + - name: Build test image + run: docker build --target test -t lumeex:test . + + - name: Unit tests + run: docker run --rm lumeex:test pytest -v + + - name: Check deprecation warnings + run: docker run --rm --entrypoint python lumeex:ci -W error::DeprecationWarning -c "import src.py.webui.webui" + + - name: Trivy critical (blocking) + run: | + docker run --rm \ + -e DOCKER_HOST=tcp://dockerhost:2375 --add-host=dockerhost:host-gateway \ + -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy image --exit-code 1 --severity CRITICAL lumeex:ci + + - name: Trivy high (informative) + run: | + docker run --rm \ + -e DOCKER_HOST=tcp://dockerhost:2375 --add-host=dockerhost:host-gateway \ + -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy image --exit-code 0 --severity HIGH lumeex:ci + + - name: Publish tagged image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + CI_PUSH_TOKEN: ${{ secrets.CI_PUSH_TOKEN }} + GITEA_URL: https://git.djeex.fr + IMAGE: git.djeex.fr/djeex/lumeex + run: | + set -e + + # Only republish when a container-relevant file actually changed + BEFORE="${{ github.event.before }}" + if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + BEFORE="HEAD~1" + fi + CHANGED=$(git diff --name-only "$BEFORE" "${{ github.sha }}" -- \ + Dockerfile requirements.txt VERSION build.py gallery.py \ + src/ config/ docker/.sh/entrypoint.sh || true) + + if [ -z "$CHANGED" ]; then + echo "No container-relevant changes, skipping publish." + exit 0 + fi + + VERSION_BUMPED=false + if ! echo "$CHANGED" | grep -qx "VERSION"; then + CURRENT_VERSION=$(cat VERSION) + MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) + MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) + PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) + NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" + echo "$NEW_VERSION" > VERSION + + git config user.name "gitea-ci" + git config user.email "ci@git.djeex.fr" + git remote set-url origin "https://gitea-ci:${CI_PUSH_TOKEN}@git.djeex.fr/Djeex/lumeex.git" + git add VERSION + git commit -m "chore: bump version to $NEW_VERSION [skip ci]" + git push origin HEAD:main + git config --unset-all "http.https://git.djeex.fr/Djeex/lumeex.git/.extraheader" || true + VERSION_BUMPED=true + fi + + FULL_VERSION=$(cat VERSION) + MAJOR_MINOR=$(echo "$FULL_VERSION" | cut -d. -f1,2) + + echo "$REGISTRY_TOKEN" | docker login git.djeex.fr -u Djeex --password-stdin + + docker tag lumeex:ci "$IMAGE:latest" + docker tag lumeex:ci "$IMAGE:$MAJOR_MINOR" + docker tag lumeex:ci "$IMAGE:$FULL_VERSION" + docker push "$IMAGE:latest" + docker push "$IMAGE:$MAJOR_MINOR" + docker push "$IMAGE:$FULL_VERSION" + + # Categorize the changelog from the merged PR's labels, if any + PR_NUMBER=$(git log -1 --format=%B "${{ github.sha }}" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true) + CATEGORY="🔧 Maintenance" + PR_LINK="" + if [ -n "$PR_NUMBER" ]; then + PR_LINK="https://git.djeex.fr/Djeex/lumeex/pulls/$PR_NUMBER" + LABELS=$(curl -s -H "Authorization: token $CI_PUSH_TOKEN" \ + "$GITEA_URL/api/v1/repos/Djeex/lumeex/issues/$PR_NUMBER/labels" | jq -r '.[].name' || true) + if echo "$LABELS" | grep -qx "bug"; then + CATEGORY="⚠️ Hotfix" + elif echo "$LABELS" | grep -qx "major"; then + CATEGORY="💥 Breaking change" + elif echo "$LABELS" | grep -qx "minor"; then + CATEGORY="✨ Update" + fi + fi + + COMMIT_LINK="https://git.djeex.fr/Djeex/lumeex/commit/${{ github.sha }}" + BODY=$(printf '**%s**\n\nCommit: %s' "$CATEGORY" "$COMMIT_LINK") + if [ -n "$PR_LINK" ]; then + BODY=$(printf '%s\nPR: %s' "$BODY" "$PR_LINK") + fi + + jq -n \ + --arg tag "$FULL_VERSION" \ + --arg name "$FULL_VERSION" \ + --arg body "$BODY" \ + '{tag_name: $tag, name: $name, body: $body}' | \ + curl -s -X POST \ + -H "Authorization: token $CI_PUSH_TOKEN" \ + -H "Content-Type: application/json" \ + -d @- \ + "$GITEA_URL/api/v1/repos/Djeex/lumeex/releases" diff --git a/.gitignore b/.gitignore index a833ea8..b377941 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,9 @@ !.env !.sh !.gitignore +!.gitea +!.gitea/** output/ -__pycache__/ \ No newline at end of file +__pycache__/ +htmlcov/ +site_output.zip \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 589c6c8..e125dbd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13-alpine AS builder +FROM python:3.13.15-alpine AS builder WORKDIR /app @@ -7,7 +7,7 @@ RUN apk add --no-cache gcc musl-dev jpeg-dev zlib-dev COPY requirements.txt . RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt -FROM python:3.13-alpine +FROM python:3.13.15-alpine AS base WORKDIR /app @@ -20,4 +20,13 @@ COPY ./config /app/default COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh +FROM base AS test + +COPY requirements.txt requirements-dev.txt pytest.ini /app/ +RUN pip install --no-cache-dir -r requirements-dev.txt +COPY ./tests/ ./tests/ +COPY ./demo/ ./demo/ + +FROM base + ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f169cc0 --- /dev/null +++ b/renovate.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "timezone": "Europe/Paris", + "labels": ["bot"], + "packageRules": [ + { + "matchManagers": ["pip_requirements"], + "matchUpdateTypes": ["patch", "minor"], + "automerge": true + }, + { + "matchManagers": ["dockerfile"], + "matchUpdateTypes": ["patch"], + "automerge": true + }, + { + "matchUpdateTypes": ["major"], + "addLabels": ["major"] + }, + { + "matchUpdateTypes": ["minor"], + "addLabels": ["minor"] + } + ], + "vulnerabilityAlerts": { + "addLabels": ["bug"] + } +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f794fa9 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.4.2 +pytest-cov==7.0.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c77f9d4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,86 @@ +from pathlib import Path + +import pytest +from PIL import Image + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEMO_ROOT = REPO_ROOT / "demo" / "config" + + +@pytest.fixture +def repo_root(): + return REPO_ROOT + + +@pytest.fixture +def demo_root(): + return DEMO_ROOT + + +@pytest.fixture +def make_image(): + """Factory that writes a small real image to disk and returns its path.""" + + def _make(path: Path, size=(64, 48), color=(200, 100, 50), fmt="JPEG", **save_kwargs): + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", size, color).save(path, fmt, **save_kwargs) + return path + + return _make + + +@pytest.fixture +def app_env(tmp_path, monkeypatch, make_image): + """ + Isolate the Flask webui from the real repo filesystem. + + webui.py resolves most config paths from `Path(__file__).resolve().parents[3]` + (recomputed inline in most routes) or from module-level globals computed once + at import time (PHOTOS_DIR, SITE_YAML). We patch `__file__` so inline lookups + land in a throwaway tmp_path repo skeleton, patch the two import-time globals + directly, and chdir so the plain-relative paths used by gallery_builder + (GALLERY_YAML, GALLERY_DIR, HERO_DIR) resolve there too. + """ + import src.py.webui.webui as webui + + root = tmp_path + (root / "config" / "photos" / "gallery").mkdir(parents=True) + (root / "config" / "photos" / "hero").mkdir(parents=True) + + theme_dir = root / "config" / "themes" / "modern" + theme_dir.mkdir(parents=True) + (theme_dir / "theme.yaml").write_text( + "colors:\n browser_color: '#ffffff'\n" + "favicon:\n path: favicon.png\n" + "google_fonts: []\n", + encoding="utf-8", + ) + make_image(theme_dir / "favicon.png", size=(32, 32), fmt="PNG") + + (root / "config" / "gallery.yaml").write_text( + "hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8" + ) + (root / "config" / "site.yaml").write_text( + "info:\n title: Test\n canonical: https://example.com\n" + "social:\n thumbnail: ''\n", + encoding="utf-8", + ) + + fake_module_file = root / "src" / "py" / "webui" / "webui.py" + fake_module_file.parent.mkdir(parents=True, exist_ok=True) + + monkeypatch.chdir(root) + monkeypatch.setattr(webui, "__file__", str(fake_module_file)) + monkeypatch.setattr(webui, "PHOTOS_DIR", root / "config" / "photos") + monkeypatch.setattr(webui, "SITE_YAML", root / "config" / "site.yaml") + webui.app.config["PHOTOS_DIR"] = root / "config" / "photos" + webui.app.config["TESTING"] = True + + return root + + +@pytest.fixture +def client(app_env): + import src.py.webui.webui as webui + + return webui.app.test_client() diff --git a/tests/test_builder_utils.py b/tests/test_builder_utils.py new file mode 100644 index 0000000..043eb4a --- /dev/null +++ b/tests/test_builder_utils.py @@ -0,0 +1,93 @@ +from pathlib import Path + +from src.py.builder import utils + + +def test_load_yaml_missing_file_returns_empty_dict(tmp_path, caplog): + result = utils.load_yaml(tmp_path / "missing.yaml") + assert result == {} + + +def test_load_yaml_parses_existing_file(tmp_path): + path = tmp_path / "data.yaml" + path.write_text("foo: bar\nnum: 1\n", encoding="utf-8") + assert utils.load_yaml(path) == {"foo": "bar", "num": 1} + + +def test_load_theme_config_success(tmp_path): + themes_dir = tmp_path / "themes" + theme_dir = themes_dir / "modern" + theme_dir.mkdir(parents=True) + (theme_dir / "theme.yaml").write_text("colors:\n primary: '#000'\n", encoding="utf-8") + + theme_vars, returned_dir = utils.load_theme_config("modern", themes_dir) + + assert theme_vars == {"colors": {"primary": "#000"}} + assert returned_dir == theme_dir + + +def test_load_theme_config_missing_raises(tmp_path): + themes_dir = tmp_path / "themes" + try: + utils.load_theme_config("missing", themes_dir) + assert False, "expected FileNotFoundError" + except FileNotFoundError: + pass + + +def test_clear_dir_creates_missing_dir(tmp_path): + target = tmp_path / "out" + utils.clear_dir(target) + assert target.is_dir() + assert list(target.iterdir()) == [] + + +def test_clear_dir_removes_existing_content(tmp_path): + target = tmp_path / "out" + target.mkdir() + (target / "file.txt").write_text("x", encoding="utf-8") + sub = target / "sub" + sub.mkdir() + (sub / "nested.txt").write_text("y", encoding="utf-8") + + utils.clear_dir(target) + + assert target.is_dir() + assert list(target.iterdir()) == [] + + +def test_ensure_dir_creates_and_clears(tmp_path): + target = tmp_path / "out" + utils.ensure_dir(target) + assert target.is_dir() + + (target / "stale.txt").write_text("x", encoding="utf-8") + utils.ensure_dir(target) + assert list(target.iterdir()) == [] + + +def test_copy_assets_copies_existing_folders(tmp_path): + js_dir = tmp_path / "js" + style_dir = tmp_path / "style" + build_dir = tmp_path / "output" + js_dir.mkdir() + style_dir.mkdir() + (js_dir / "app.js").write_text("console.log(1)", encoding="utf-8") + (style_dir / "style.css").write_text("body{}", encoding="utf-8") + + utils.copy_assets(js_dir, style_dir, build_dir) + + assert (build_dir / "js" / "app.js").exists() + assert (build_dir / "style" / "style.css").exists() + + +def test_copy_assets_skips_missing_folder(tmp_path, caplog): + js_dir = tmp_path / "missing_js" + style_dir = tmp_path / "style" + style_dir.mkdir() + build_dir = tmp_path / "output" + + utils.copy_assets(js_dir, style_dir, build_dir) + + assert not (build_dir / "missing_js").exists() + assert (build_dir / "style").exists() diff --git a/tests/test_css_generator.py b/tests/test_css_generator.py new file mode 100644 index 0000000..c9d10c1 --- /dev/null +++ b/tests/test_css_generator.py @@ -0,0 +1,68 @@ +from src.py.builder import css_generator as cg + + +def test_generate_css_variables(tmp_path): + output_path = tmp_path / "style" / "colors.css" + cg.generate_css_variables({"primary_dark": "#005384", "accent": "#FFC700"}, output_path) + + content = output_path.read_text(encoding="utf-8") + assert ":root {" in content + assert "--color-primary-dark: #005384;" in content + assert "--color-accent: #FFC700;" in content + + +def test_generate_fonts_css_with_font_files_and_config(tmp_path): + fonts_dir = tmp_path / "theme" / "fonts" + fonts_dir.mkdir(parents=True) + (fonts_dir / "trixie.woff2").write_bytes(b"fake-woff2") + (fonts_dir / "trixie.woff").write_bytes(b"fake-woff") + (fonts_dir / "readme.txt").write_text("skip me", encoding="utf-8") + + output_path = tmp_path / "build" / "style" / "fonts.css" + fonts_cfg = { + "primary": {"name": "Lato", "fallback": "sans-serif"}, + "secondary": {"name": "Trixie", "fallback": "monospace"}, + } + + preload_links = cg.generate_fonts_css(fonts_dir, output_path, fonts_cfg=fonts_cfg) + + content = output_path.read_text(encoding="utf-8") + assert "@font-face {" in content + assert "font-family: 'trixie';" in content + assert "--font-primary: 'Lato', sans-serif;" in content + assert "--font-secondary: 'Trixie', monospace;" in content + + assert (tmp_path / "build" / "fonts" / "trixie.woff2").exists() + assert (tmp_path / "build" / "fonts" / "trixie.woff").exists() + assert len(preload_links) == 2 + assert any("trixie.woff2" in link for link in preload_links) + + +def test_generate_fonts_css_with_no_fonts(tmp_path): + fonts_dir = tmp_path / "empty_fonts" + fonts_dir.mkdir() + output_path = tmp_path / "build" / "fonts.css" + + preload_links = cg.generate_fonts_css(fonts_dir, output_path) + + assert preload_links == [] + assert output_path.exists() + + +def test_generate_google_fonts_link_empty(): + assert cg.generate_google_fonts_link([]) == "" + assert cg.generate_google_fonts_link(None) == "" + + +def test_generate_google_fonts_link_with_weights(): + fonts = [ + {"family": "Lato", "weights": ["200", "400", "700"]}, + {"family": "Open Sans"}, + ] + + link = cg.generate_google_fonts_link(fonts) + + assert "family=Lato:wght@200;400;700" in link + assert "family=Open+Sans" in link + assert link.startswith('') diff --git a/tests/test_gallery_builder.py b/tests/test_gallery_builder.py new file mode 100644 index 0000000..245a043 --- /dev/null +++ b/tests/test_gallery_builder.py @@ -0,0 +1,113 @@ +import yaml + +from src.py.builder import gallery_builder as gb + + +def _setup_repo(tmp_path, monkeypatch): + (tmp_path / "config" / "photos" / "gallery").mkdir(parents=True) + (tmp_path / "config" / "photos" / "hero").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + +def test_load_yaml_missing_file(tmp_path, monkeypatch): + _setup_repo(tmp_path, monkeypatch) + assert gb.load_yaml("config/gallery.yaml") == {} + + +def test_load_yaml_and_save_yaml_roundtrip(tmp_path, monkeypatch): + _setup_repo(tmp_path, monkeypatch) + data = {"gallery": {"images": [{"src": "gallery/a.jpg", "tags": ["x"]}]}} + gb.save_yaml(data, "config/gallery.yaml") + + loaded = gb.load_yaml("config/gallery.yaml") + assert loaded == data + + +def test_get_all_image_paths_filters_and_sorts(tmp_path, monkeypatch, make_image): + _setup_repo(tmp_path, monkeypatch) + gallery_dir = tmp_path / "config" / "photos" / "gallery" + make_image(gallery_dir / "b.jpg") + make_image(gallery_dir / "a.png", fmt="PNG") + (gallery_dir / "notes.txt").write_text("ignore me", encoding="utf-8") + + paths = gb.get_all_image_paths(gallery_dir) + + assert paths == ["gallery/a.png", "gallery/b.jpg"] + + +def test_update_gallery_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image, capsys): + _setup_repo(tmp_path, monkeypatch) + gallery_dir = tmp_path / "config" / "photos" / "gallery" + make_image(gallery_dir / "kept.jpg") + make_image(gallery_dir / "new.jpg") + + gb.save_yaml( + { + "gallery": { + "images": [ + {"src": "gallery/kept.jpg", "tags": ["portrait"]}, + {"src": "gallery/gone.jpg", "tags": ["stale"]}, + ] + } + }, + gb.GALLERY_YAML, + ) + + gb.update_gallery() + + result = gb.load_yaml(gb.GALLERY_YAML) + images = {img["src"]: img for img in result["gallery"]["images"]} + assert set(images) == {"gallery/kept.jpg", "gallery/new.jpg"} + assert images["gallery/kept.jpg"]["tags"] == ["portrait"] + assert images["gallery/new.jpg"]["tags"] == [] + + +def test_update_gallery_no_changes(tmp_path, monkeypatch, make_image, capsys): + _setup_repo(tmp_path, monkeypatch) + gallery_dir = tmp_path / "config" / "photos" / "gallery" + make_image(gallery_dir / "only.jpg") + gb.save_yaml( + {"gallery": {"images": [{"src": "gallery/only.jpg", "tags": []}]}}, + gb.GALLERY_YAML, + ) + + gb.update_gallery() + + out = capsys.readouterr().out + assert "No changes to gallery.yaml (gallery)" in out + + +def test_update_hero_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image): + _setup_repo(tmp_path, monkeypatch) + hero_dir = tmp_path / "config" / "photos" / "hero" + make_image(hero_dir / "kept.jpg") + make_image(hero_dir / "new.jpg") + + gb.save_yaml( + { + "hero": { + "images": [ + {"src": "hero/kept.jpg"}, + {"src": "hero/gone.jpg"}, + ] + } + }, + gb.GALLERY_YAML, + ) + + gb.update_hero() + + result = gb.load_yaml(gb.GALLERY_YAML) + srcs = {img["src"] for img in result["hero"]["images"]} + assert srcs == {"hero/kept.jpg", "hero/new.jpg"} + + +def test_update_gallery_on_empty_yaml_initializes_section(tmp_path, monkeypatch, make_image): + _setup_repo(tmp_path, monkeypatch) + gallery_dir = tmp_path / "config" / "photos" / "gallery" + make_image(gallery_dir / "only.jpg") + + gb.update_gallery() + + result = gb.load_yaml(gb.GALLERY_YAML) + assert result["gallery"]["images"][0]["src"] == "gallery/only.jpg" diff --git a/tests/test_html_generator.py b/tests/test_html_generator.py new file mode 100644 index 0000000..908f5d8 --- /dev/null +++ b/tests/test_html_generator.py @@ -0,0 +1,67 @@ +import json + +from src.py.builder import html_generator as hg + + +def test_render_template_replaces_placeholders(tmp_path): + template = tmp_path / "t.html" + template.write_text("

{{ title }}

{{ missing }}

", encoding="utf-8") + + result = hg.render_template(template, {"title": "Hello", "missing": None}) + + assert result == "

Hello

" + + +def test_render_template_leaves_unknown_placeholders(tmp_path): + template = tmp_path / "t.html" + template.write_text("

{{ title }}

", encoding="utf-8") + + result = hg.render_template(template, {}) + + assert result == "

{{ title }}

" + + +def test_render_gallery_images_with_and_without_tags(): + images = [ + {"src": "gallery/a.jpg", "tags": ["nature", "sky"], "alt": "A photo"}, + {"src": "gallery/b.jpg"}, + ] + + html = hg.render_gallery_images(images) + + assert 'data-tags="nature sky"' in html + assert '#nature' in html + assert '#sky' in html + assert 'data-src="/img/gallery/a.jpg"' in html + assert 'alt="A photo"' in html + assert 'data-tags=""' in html + assert 'data-src="/img/gallery/b.jpg"' in html + assert 'alt=""' in html + + +def test_generate_gallery_json_from_images(tmp_path): + images = [{"src": "hero/a.jpg"}, {"src": "hero/b.jpg"}] + + hg.generate_gallery_json_from_images(images, tmp_path) + + output_path = tmp_path / "data" / "gallery.json" + assert json.loads(output_path.read_text(encoding="utf-8")) == ["hero/a.jpg", "hero/b.jpg"] + + +def test_generate_robots_txt(tmp_path): + hg.generate_robots_txt("https://example.com/", ["/", "legals"], tmp_path) + + content = (tmp_path / "robots.txt").read_text(encoding="utf-8") + assert "Disallow: /" in content + assert "Allow: /" in content + assert "Allow: /legals" in content + assert "Sitemap: https://example.com/sitemap.xml" in content + + +def test_generate_sitemap_xml(tmp_path): + hg.generate_sitemap_xml("https://example.com", ["/", "/legals/"], tmp_path) + + content = (tmp_path / "sitemap.xml").read_text(encoding="utf-8") + assert "https://example.com/" in content + assert "https://example.com/legals/" in content + assert content.startswith('') diff --git a/tests/test_image_processor.py b/tests/test_image_processor.py new file mode 100644 index 0000000..0990f05 --- /dev/null +++ b/tests/test_image_processor.py @@ -0,0 +1,134 @@ +from pathlib import Path + +from PIL import Image + +from src.py.builder import image_processor as ip + + +def test_convert_and_resize_image_missing_input_logs_and_skips(tmp_path): + output_path = tmp_path / "out.webp" + ip.convert_and_resize_image(tmp_path / "missing.jpg", output_path) + assert not output_path.exists() + + +def test_convert_and_resize_image_resizes_down(tmp_path, make_image): + input_path = make_image(tmp_path / "in.jpg", size=(2000, 1000)) + output_path = tmp_path / "out.webp" + + ip.convert_and_resize_image(input_path, output_path, resize=True, max_width=1000) + + actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg") + assert actual_output.exists() + with Image.open(actual_output) as img: + assert img.width == 1000 + assert img.height == 500 + + +def test_convert_and_resize_image_no_resize_keeps_dimensions(tmp_path, make_image): + input_path = make_image(tmp_path / "in.jpg", size=(500, 300)) + output_path = tmp_path / "out.webp" + + ip.convert_and_resize_image(input_path, output_path, resize=False) + + actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg") + with Image.open(actual_output) as img: + assert img.size == (500, 300) + + +def test_convert_and_resize_image_preserves_icc_profile(tmp_path): + input_path = tmp_path / "in.jpg" + icc_bytes = b"fake-icc-profile-bytes" + Image.new("RGB", (100, 80), (10, 20, 30)).save(input_path, "JPEG", icc_profile=icc_bytes) + output_path = tmp_path / "out.webp" + + ip.convert_and_resize_image(input_path, output_path, resize=False) + + actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg") + with Image.open(actual_output) as img: + assert img.info.get("icc_profile") == icc_bytes + + +def test_process_images_updates_src_to_processed_extension(tmp_path, make_image): + img_dir = tmp_path / "photos" + build_dir = tmp_path / "output" + make_image(img_dir / "gallery" / "a.jpg", size=(200, 200)) + images = [{"src": "gallery/a.jpg", "tags": []}] + + ip.process_images(images, resize_images=True, img_dir=img_dir, build_dir=build_dir) + + assert images[0]["src"] in ("gallery/a.webp", "gallery/a.jpg") + produced = build_dir / "img" / images[0]["src"] + assert produced.exists() + + +def test_copy_original_images_copies_existing_and_skips_missing(tmp_path, make_image, caplog): + img_dir = tmp_path / "photos" + build_dir = tmp_path / "output" + make_image(img_dir / "gallery" / "present.jpg") + images = [{"src": "gallery/present.jpg"}, {"src": "gallery/absent.jpg"}] + + ip.copy_original_images(images, img_dir, build_dir) + + assert (build_dir / "img" / "gallery" / "present.jpg").exists() + assert not (build_dir / "img" / "gallery" / "absent.jpg").exists() + + +def test_get_favicon_path_missing_config_returns_none(tmp_path): + assert ip.get_favicon_path({}, tmp_path) is None + + +def test_get_favicon_path_relative_and_absolute(tmp_path, make_image): + theme_dir = tmp_path / "theme" + icon_path = make_image(theme_dir / "favicon.png", fmt="PNG") + + result = ip.get_favicon_path({"favicon": {"path": "favicon.png"}}, theme_dir) + assert result == icon_path + + absolute_result = ip.get_favicon_path({"favicon": {"path": str(icon_path)}}, theme_dir) + assert absolute_result == icon_path + + +def test_get_favicon_path_file_not_found(tmp_path): + result = ip.get_favicon_path({"favicon": {"path": "missing.png"}}, tmp_path) + assert result is None + + +def test_generate_favicons_from_logo_creates_all_sizes(tmp_path, make_image): + theme_dir = tmp_path / "theme" + make_image(theme_dir / "favicon.png", size=(256, 256), fmt="PNG") + output_dir = tmp_path / "output" / "favicon" + + ip.generate_favicons_from_logo({"favicon": {"path": "favicon.png"}}, theme_dir, output_dir) + + expected = [ + "favicon-32.png", "favicon-96.png", "favicon-128.png", + "favicon-192.png", "favicon-196.png", "favicon-152.png", "favicon-180.png", + ] + for name in expected: + assert (output_dir / name).exists() + with Image.open(output_dir / "favicon-32.png") as img: + assert img.size == (32, 32) + + +def test_generate_favicons_from_logo_no_favicon_skips(tmp_path): + output_dir = tmp_path / "output" / "favicon" + ip.generate_favicons_from_logo({}, tmp_path, output_dir) + assert not output_dir.exists() + + +def test_generate_favicon_ico_creates_file(tmp_path, make_image): + theme_dir = tmp_path / "theme" + make_image(theme_dir / "favicon.png", size=(64, 64), fmt="PNG") + output_path = tmp_path / "output" / "favicon.ico" + + ip.generate_favicon_ico({"favicon": {"path": "favicon.png"}}, theme_dir, output_path) + + assert output_path.exists() + with Image.open(output_path) as img: + assert img.format == "ICO" + + +def test_generate_favicon_ico_no_favicon_skips(tmp_path): + output_path = tmp_path / "output" / "favicon.ico" + ip.generate_favicon_ico({}, tmp_path, output_path) + assert not output_path.exists() diff --git a/tests/test_site_builder.py b/tests/test_site_builder.py new file mode 100644 index 0000000..3086308 --- /dev/null +++ b/tests/test_site_builder.py @@ -0,0 +1,85 @@ +import yaml + +from src.py.builder import site_builder + + +def test_build_end_to_end_with_demo_content(tmp_path, monkeypatch, demo_root): + output_dir = tmp_path / "output" + monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir) + monkeypatch.setattr(site_builder, "GALLERY_FILE", demo_root / "gallery.yaml") + monkeypatch.setattr(site_builder, "SITE_FILE", demo_root / "site.yaml") + monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos") + monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes") + + site_builder.build() + + index_html = (output_dir / "index.html").read_text(encoding="utf-8") + assert "

Lumeex

" in index_html + assert "https://lumeex.djeex.fr/" in index_html + assert '", encoding="utf-8") + + resp = client.post("/download-output-zip") + + assert resp.status_code == 200 + zip_bytes = io.BytesIO(resp.data) + with zipfile.ZipFile(zip_bytes) as zf: + assert "index.html" in zf.namelist() + assert not (app_env / "site_output.zip").exists() -- 2.54.0