Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edd39febd1 | ||
|
|
33766f0b0b | ||
|
|
f954ace53b | ||
|
|
6df2d561d9 | ||
|
|
18c894f780 | ||
|
|
ca4240c84a | ||
|
|
83d2dc7ee6 | ||
|
|
8f4d763048 | ||
|
|
d9b821c066 | ||
|
|
7130069adf | ||
|
|
142a48ad5e | ||
|
|
fc5b1c4234 | ||
|
|
2968e875a7 | ||
|
|
a7ce49b989 | ||
|
|
26e3ca684b | ||
|
|
a938234495 | ||
|
|
193b1704f8 | ||
|
|
9d56d729c1 |
@@ -0,0 +1,189 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
jobs:
|
||||
build-and-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -t lumeex:ci . 2>&1 | tee build.log
|
||||
if grep -q "Building wheel for" build.log; then
|
||||
echo "::warning::A dependency was built from source — check Python/Alpine compatibility"
|
||||
fi
|
||||
|
||||
- name: Smoke test (syntax check)
|
||||
run: |
|
||||
docker run --rm --entrypoint python lumeex:ci -c "
|
||||
import ast
|
||||
ok = True
|
||||
for f in ('build.py', 'gallery.py'):
|
||||
with open(f) as fh:
|
||||
source = fh.read()
|
||||
try:
|
||||
ast.parse(source)
|
||||
except SyntaxError as e:
|
||||
print(f'::error::Syntax error in {f}: {e}')
|
||||
ok = False
|
||||
if not ok:
|
||||
exit(1)
|
||||
print('OK: syntax is valid')
|
||||
"
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
docker build --target test -t lumeex:test .
|
||||
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" 2>&1 | tee deprecation.log || true
|
||||
if grep -qi "deprecat" deprecation.log; then
|
||||
echo "::warning::Deprecation warning detected, check logs"
|
||||
fi
|
||||
|
||||
- name: Scan with Trivy (critical - blocking)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e DOCKER_HOST=tcp://dockerhost:2375 \
|
||||
--add-host=dockerhost:host-gateway \
|
||||
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:ci
|
||||
|
||||
- name: Scan with Trivy (high - informative)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e DOCKER_HOST=tcp://dockerhost:2375 \
|
||||
--add-host=dockerhost:host-gateway \
|
||||
aquasec/trivy:0.74.0 image --exit-code 0 --severity HIGH lumeex:ci
|
||||
|
||||
- name: Publish tagged image
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
BEFORE="${{ github.event.before }}"
|
||||
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
|
||||
BASE_REF="$BEFORE"
|
||||
else
|
||||
BASE_REF="HEAD~1"
|
||||
fi
|
||||
CHANGED=$(git diff --name-only "$BASE_REF" "${{ github.sha }}")
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
if ! echo "$CHANGED" | grep -qE '^(Dockerfile|requirements\.txt|VERSION|build\.py|gallery\.py)$|^(src|config)/|^docker/\.sh/entrypoint\.sh$'; then
|
||||
echo "No container-relevant file changed, skipping publish."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if echo "$CHANGED" | grep -qE '^VERSION$'; then
|
||||
echo "VERSION was manually edited in this push, using it as-is."
|
||||
else
|
||||
echo "VERSION untouched but container files changed, auto-bumping the build number (Z)."
|
||||
OLD_VERSION=$(tr -d '[:space:]' < VERSION)
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$OLD_VERSION"
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
echo "$NEW_VERSION" > VERSION
|
||||
|
||||
git config user.name "lumeex-ci"
|
||||
git config user.email "[email protected]"
|
||||
git add VERSION
|
||||
git commit -m "Bump build version to $NEW_VERSION [skip ci]"
|
||||
|
||||
# Belt and suspenders: actions/checkout can leave its own ephemeral
|
||||
# credential injected as an extraheader, which would silently override
|
||||
# the URL-embedded token below. persist-credentials:false on checkout
|
||||
# should already prevent this, but strip it here too just in case.
|
||||
git config --unset-all http.https://git.djeex.fr/.extraheader || true
|
||||
|
||||
git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/lumeex.git" HEAD:main
|
||||
fi
|
||||
|
||||
VERSION=$(tr -d '[:space:]' < VERSION)
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||
MINOR_TAG="${MAJOR}.${MINOR}"
|
||||
|
||||
IMAGE=git.djeex.fr/djeex/lumeex
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.djeex.fr -u Djeex --password-stdin
|
||||
|
||||
# Retag the already-built, already-scanned image — never rebuild for publish,
|
||||
# so what ships is byte-for-byte what Trivy just scanned.
|
||||
docker tag lumeex:ci "$IMAGE:latest"
|
||||
docker tag lumeex:ci "$IMAGE:$MINOR_TAG"
|
||||
docker tag lumeex:ci "$IMAGE:$VERSION"
|
||||
docker push "$IMAGE:latest"
|
||||
docker push "$IMAGE:$MINOR_TAG"
|
||||
docker push "$IMAGE:$VERSION"
|
||||
|
||||
TRIGGER_MSG=$(git log -1 --format=%s "${{ github.sha }}")
|
||||
PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true)
|
||||
|
||||
CATEGORY="🔧 Maintenance"
|
||||
CHANGE_TITLE="$TRIGGER_MSG"
|
||||
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
PR_JSON=$(curl -s -H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/pulls/$PR_NUM")
|
||||
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title // empty' 2>/dev/null || true)
|
||||
LABELS=$(echo "$PR_JSON" | jq -r '.labels[]?.name' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$PR_TITLE" ]; then
|
||||
CHANGE_TITLE="$PR_TITLE"
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
REPO_URL="https://git.djeex.fr/Djeex/lumeex"
|
||||
COMMIT_LIST=$(git log --no-merges --format="- [%h](${REPO_URL}/commit/%H) %s" "$BASE_REF".."${{ github.sha }}")
|
||||
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})"
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
SOURCE_LINE="[#${PR_NUM}](${REPO_URL}/pulls/${PR_NUM}) · ${SOURCE_LINE}"
|
||||
fi
|
||||
|
||||
BODY=$(cat <<EOF
|
||||
## Changelog
|
||||
---
|
||||
|
||||
### ${CATEGORY}
|
||||
---
|
||||
${CHANGE_TITLE}
|
||||
|
||||
**Source:** ${SOURCE_LINE}
|
||||
**Image:** \`${IMAGE}:${VERSION}\`
|
||||
|
||||
**Commits:**
|
||||
${COMMIT_LIST}
|
||||
EOF
|
||||
)
|
||||
|
||||
JSON_PAYLOAD=$(jq -n \
|
||||
--arg tag "$VERSION" \
|
||||
--arg name "$VERSION" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, name: $name, target_commitish: "main", body: $body}')
|
||||
|
||||
curl -s -o /dev/null -w "Release API response: %{http_code}\n" -X POST \
|
||||
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_PAYLOAD" \
|
||||
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/releases"
|
||||
+5
-1
@@ -2,5 +2,9 @@
|
||||
!.env
|
||||
!.sh
|
||||
!.gitignore
|
||||
!.gitea
|
||||
!.gitea/**
|
||||
output/
|
||||
__pycache__/
|
||||
__pycache__/
|
||||
htmlcov/
|
||||
site_output.zip
|
||||
+11
-2
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13-alpine AS builder
|
||||
FROM python:3.14.7-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.14.7-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"]
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
Lumeex is a static site generator designed to create minimalist photo galleries that highlight your artworks over the author. It empowers users to organize and explore images using tags, with each page load presenting photos in a random order to encourage discovery of new content.
|
||||
|
||||
👉 __Check the online version : [Lumeex-web](https://lumeex.djeex.fr)__
|
||||
|
||||
The project includes two thoughtfully designed themes—one modern, one minimalistic—both crafted to keep the spotlight on your photos:
|
||||
|
||||
- **Modern** — [View Demo](https://modern.djeex.fr)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==8.4.2
|
||||
pytest-cov==7.1.0
|
||||
@@ -32,6 +32,14 @@ window.addEventListener("DOMContentLoaded", () => {
|
||||
img.classList.add("loaded");
|
||||
};
|
||||
|
||||
// Chrome HDR artefacting fix
|
||||
const clearWillChange = (e) => {
|
||||
if (e.propertyName !== "transform") return;
|
||||
img.style.willChange = "auto";
|
||||
img.removeEventListener("transitionend", clearWillChange);
|
||||
};
|
||||
img.addEventListener("transitionend", clearWillChange);
|
||||
|
||||
if (img.complete && img.naturalHeight !== 0) {
|
||||
onLoad(); // already loaded
|
||||
} else {
|
||||
|
||||
@@ -11,6 +11,7 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
|
||||
return
|
||||
|
||||
img = Image.open(input_path)
|
||||
icc_profile = img.info.get("icc_profile")
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
@@ -27,7 +28,10 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
|
||||
if fmt == "JPEG":
|
||||
output_path = output_path.with_suffix(".jpg")
|
||||
|
||||
img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100)
|
||||
save_kwargs = {"quality": 90 if fmt == "JPEG" else 100}
|
||||
if icc_profile:
|
||||
save_kwargs["icc_profile"] = icc_profile
|
||||
img.save(output_path, fmt, **save_kwargs)
|
||||
logging.info(f"[✓] Processed image: {input_path} → {output_path}")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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('<link href="https://fonts.googleapis.com/css2?')
|
||||
assert link.endswith('&display=swap" rel="stylesheet">')
|
||||
@@ -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"
|
||||
@@ -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("<h1>{{ title }}</h1><p>{{ missing }}</p>", encoding="utf-8")
|
||||
|
||||
result = hg.render_template(template, {"title": "Hello", "missing": None})
|
||||
|
||||
assert result == "<h1>Hello</h1><p></p>"
|
||||
|
||||
|
||||
def test_render_template_leaves_unknown_placeholders(tmp_path):
|
||||
template = tmp_path / "t.html"
|
||||
template.write_text("<h1>{{ title }}</h1>", encoding="utf-8")
|
||||
|
||||
result = hg.render_template(template, {})
|
||||
|
||||
assert result == "<h1>{{ title }}</h1>"
|
||||
|
||||
|
||||
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 '<span class="tag">#nature</span>' in html
|
||||
assert '<span class="tag">#sky</span>' 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 "<loc>https://example.com/</loc>" in content
|
||||
assert "<loc>https://example.com/legals/</loc>" in content
|
||||
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
@@ -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()
|
||||
@@ -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 "<h1>Lumeex</h1>" in index_html
|
||||
assert "https://lumeex.djeex.fr/" in index_html
|
||||
assert '<img class="fade-in-img lazyload"' in index_html
|
||||
|
||||
assert (output_dir / "legals" / "index.html").exists()
|
||||
assert (output_dir / "robots.txt").exists()
|
||||
assert (output_dir / "sitemap.xml").exists()
|
||||
assert (output_dir / "data" / "gallery.json").exists()
|
||||
assert (output_dir / "style" / "colors.css").exists()
|
||||
assert (output_dir / "style" / "fonts.css").exists()
|
||||
assert (output_dir / "style" / "theme.css").exists()
|
||||
assert (output_dir / "favicon.ico").exists()
|
||||
assert (output_dir / "img" / "favicon" / "favicon-192.png").exists()
|
||||
assert (output_dir / "img" / "social").is_dir()
|
||||
assert any((output_dir / "img" / "social").iterdir())
|
||||
|
||||
# convert_images=true in the demo config: originals become webp (or jpg fallback)
|
||||
processed = list((output_dir / "img" / "gallery").glob("*"))
|
||||
assert processed
|
||||
assert all(p.suffix in (".webp", ".jpg") for p in processed)
|
||||
|
||||
|
||||
def test_build_without_image_conversion_copies_originals(tmp_path, monkeypatch, demo_root):
|
||||
site_data = yaml.safe_load((demo_root / "site.yaml").read_text(encoding="utf-8"))
|
||||
site_data["build"]["convert_images"] = False
|
||||
site_data["build"]["resize_images"] = False
|
||||
site_file = tmp_path / "site.yaml"
|
||||
site_file.write_text(yaml.dump(site_data), encoding="utf-8")
|
||||
|
||||
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", site_file)
|
||||
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
|
||||
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
|
||||
|
||||
site_builder.build()
|
||||
|
||||
copied = list((output_dir / "img" / "gallery").glob("*.jpg"))
|
||||
assert copied, "original jpg files should be copied unmodified"
|
||||
|
||||
|
||||
def test_build_minimal_site_skips_optional_sections(tmp_path, monkeypatch, demo_root):
|
||||
gallery_file = tmp_path / "gallery.yaml"
|
||||
gallery_file.write_text("hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8")
|
||||
|
||||
site_file = tmp_path / "site.yaml"
|
||||
site_file.write_text(
|
||||
"info:\n title: Minimal\n"
|
||||
"menu:\n items: []\n"
|
||||
"build:\n theme: modern\n convert_images: true\n resize_images: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
|
||||
monkeypatch.setattr(site_builder, "GALLERY_FILE", gallery_file)
|
||||
monkeypatch.setattr(site_builder, "SITE_FILE", site_file)
|
||||
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
|
||||
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
|
||||
|
||||
site_builder.build()
|
||||
|
||||
assert (output_dir / "index.html").exists()
|
||||
assert not (output_dir / "legals").exists()
|
||||
assert not (output_dir / "robots.txt").exists()
|
||||
assert not (output_dir / "sitemap.xml").exists()
|
||||
assert not (output_dir / "data" / "gallery.json").exists()
|
||||
assert not (output_dir / "img" / "social").exists()
|
||||
@@ -0,0 +1,82 @@
|
||||
import io
|
||||
|
||||
from src.py.webui.upload import allowed_file, save_uploaded_file
|
||||
|
||||
|
||||
def test_allowed_file_accepts_known_extensions():
|
||||
for name in ["a.jpg", "a.JPEG", "a.png", "a.webp"]:
|
||||
assert allowed_file(name) is True
|
||||
|
||||
|
||||
def test_allowed_file_rejects_unknown_or_missing_extension():
|
||||
assert allowed_file("a.gif") is False
|
||||
assert allowed_file("noextension") is False
|
||||
|
||||
|
||||
def test_save_uploaded_file_sanitizes_filename(tmp_path):
|
||||
class FakeFile:
|
||||
filename = "../../evil.jpg"
|
||||
|
||||
def save(self, path):
|
||||
self.saved_path = path
|
||||
|
||||
folder = tmp_path / "gallery"
|
||||
fake_file = FakeFile()
|
||||
|
||||
result = save_uploaded_file(fake_file, folder)
|
||||
|
||||
assert result == "evil.jpg"
|
||||
assert fake_file.saved_path == folder / "evil.jpg"
|
||||
assert folder.exists()
|
||||
|
||||
|
||||
def test_upload_photo_invalid_section(client):
|
||||
resp = client.post(
|
||||
"/api/invalid/upload",
|
||||
data={"files": (io.BytesIO(b"data"), "a.jpg")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_upload_photo_no_files_key(client):
|
||||
resp = client.post("/api/gallery/upload", data={}, content_type="multipart/form-data")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_upload_photo_skips_disallowed_extensions(client):
|
||||
resp = client.post(
|
||||
"/api/gallery/upload",
|
||||
data={"files": (io.BytesIO(b"data"), "notes.txt")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "No valid files uploaded" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_upload_photo_saves_valid_files_and_updates_gallery(client, app_env):
|
||||
resp = client.post(
|
||||
"/api/gallery/upload",
|
||||
data={"files": (io.BytesIO(b"fake-jpg-bytes"), "photo.jpg")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["uploaded"] == ["photo.jpg"]
|
||||
assert (app_env / "config" / "photos" / "gallery" / "photo.jpg").exists()
|
||||
|
||||
gallery = client.get("/api/gallery").get_json()
|
||||
assert gallery[0]["src"] == "gallery/photo.jpg"
|
||||
|
||||
|
||||
def test_upload_photo_hero_section(client, app_env):
|
||||
resp = client.post(
|
||||
"/api/hero/upload",
|
||||
data={"files": (io.BytesIO(b"fake-jpg-bytes"), "photo.jpg")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.get_json()["status"] == "ok"
|
||||
assert (app_env / "config" / "photos" / "hero" / "photo.jpg").exists()
|
||||
|
||||
hero = client.get("/api/hero").get_json()
|
||||
assert hero[0]["src"] == "hero/photo.jpg"
|
||||
@@ -0,0 +1,312 @@
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def test_index_page(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_get_gallery_and_hero_start_empty(client):
|
||||
assert client.get("/api/gallery").get_json() == []
|
||||
assert client.get("/api/hero").get_json() == []
|
||||
|
||||
|
||||
def test_update_gallery_and_hero(client, app_env):
|
||||
images = [{"src": "gallery/a.jpg", "tags": ["x"]}]
|
||||
resp = client.post("/api/gallery/update", json=images)
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
assert client.get("/api/gallery").get_json() == images
|
||||
|
||||
hero_images = [{"src": "hero/a.jpg"}]
|
||||
resp = client.post("/api/hero/update", json=hero_images)
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
assert client.get("/api/hero").get_json() == hero_images
|
||||
|
||||
|
||||
def test_refresh_gallery_and_hero_pick_up_new_files(client, app_env, make_image):
|
||||
make_image(app_env / "config" / "photos" / "gallery" / "new.jpg")
|
||||
make_image(app_env / "config" / "photos" / "hero" / "new.jpg")
|
||||
|
||||
client.post("/api/gallery/refresh")
|
||||
client.post("/api/hero/refresh")
|
||||
|
||||
gallery = client.get("/api/gallery").get_json()
|
||||
hero = client.get("/api/hero").get_json()
|
||||
assert gallery[0]["src"] == "gallery/new.jpg"
|
||||
assert hero[0]["src"] == "hero/new.jpg"
|
||||
|
||||
|
||||
def test_delete_gallery_photo(client, app_env, make_image):
|
||||
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
|
||||
|
||||
resp = client.post("/api/gallery/delete", json={"src": "a.jpg"})
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
assert not (app_env / "config" / "photos" / "gallery" / "a.jpg").exists()
|
||||
|
||||
|
||||
def test_delete_gallery_photo_not_found(client):
|
||||
resp = client.post("/api/gallery/delete", json={"src": "missing.jpg"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_hero_photo(client, app_env, make_image):
|
||||
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
|
||||
|
||||
resp = client.post("/api/hero/delete", json={"src": "a.jpg"})
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
assert not (app_env / "config" / "photos" / "hero" / "a.jpg").exists()
|
||||
|
||||
|
||||
def test_delete_all_gallery_photos(client, app_env, make_image):
|
||||
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
|
||||
make_image(app_env / "config" / "photos" / "gallery" / "b.jpg")
|
||||
client.post("/api/gallery/update", json=[{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}])
|
||||
|
||||
resp = client.post("/api/gallery/delete_all")
|
||||
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["deleted"] == 2
|
||||
assert client.get("/api/gallery").get_json() == []
|
||||
assert list((app_env / "config" / "photos" / "gallery").iterdir()) == []
|
||||
|
||||
|
||||
def test_delete_all_hero_photos(client, app_env, make_image):
|
||||
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
|
||||
client.post("/api/hero/update", json=[{"src": "hero/a.jpg"}])
|
||||
|
||||
resp = client.post("/api/hero/delete_all")
|
||||
|
||||
body = resp.get_json()
|
||||
assert body["deleted"] == 1
|
||||
assert client.get("/api/hero").get_json() == []
|
||||
|
||||
|
||||
def test_serve_photo_by_section(client, app_env, make_image):
|
||||
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
|
||||
resp = client.get("/photos/gallery/a.jpg")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_serve_photo_at_root(client, app_env, make_image):
|
||||
make_image(app_env / "config" / "photos" / "thumbnail.png", fmt="PNG")
|
||||
resp = client.get("/photos/thumbnail.png")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_site_info_get_and_post(client, app_env):
|
||||
resp = client.get("/api/site-info")
|
||||
data = resp.get_json()
|
||||
assert data["info"]["title"] == "Test"
|
||||
|
||||
client.post("/api/site-info", json={"info": {"subtitle": "New subtitle"}, "social": {"instagram_url": "https://insta.example"}})
|
||||
|
||||
updated = client.get("/api/site-info").get_json()
|
||||
assert updated["info"]["title"] == "Test"
|
||||
assert updated["info"]["subtitle"] == "New subtitle"
|
||||
assert updated["social"]["instagram_url"] == "https://insta.example"
|
||||
|
||||
|
||||
def test_list_themes(client):
|
||||
assert client.get("/api/themes").get_json() == ["modern"]
|
||||
|
||||
|
||||
def test_thumbnail_upload_and_remove(client, app_env):
|
||||
data = {"file": (io.BytesIO(b"fake-png-bytes"), "thumb.png")}
|
||||
resp = client.post("/api/thumbnail/upload", data=data, content_type="multipart/form-data")
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "ok"
|
||||
assert (app_env / "config" / "photos" / "thumbnail.png").exists()
|
||||
|
||||
site_info = client.get("/api/site-info").get_json()
|
||||
assert site_info["social"]["thumbnail"] == "thumbnail.png"
|
||||
|
||||
resp = client.post("/api/thumbnail/remove")
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
assert not (app_env / "config" / "photos" / "thumbnail.png").exists()
|
||||
site_info = client.get("/api/site-info").get_json()
|
||||
assert site_info["social"]["thumbnail"] == ""
|
||||
|
||||
|
||||
def test_thumbnail_upload_missing_file(client):
|
||||
resp = client.post("/api/thumbnail/upload", data={}, content_type="multipart/form-data")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_theme_upload_and_remove(client, app_env):
|
||||
data = {
|
||||
"files": [
|
||||
(io.BytesIO(b"colors: {}"), "custom/theme.yaml"),
|
||||
(io.BytesIO(b"body{}"), "custom/theme.css"),
|
||||
]
|
||||
}
|
||||
resp = client.post("/api/theme/upload", data=data, content_type="multipart/form-data")
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["theme"] == "custom"
|
||||
assert (app_env / "config" / "themes" / "custom" / "theme.yaml").exists()
|
||||
assert (app_env / "config" / "themes" / "custom" / "theme.css").exists()
|
||||
|
||||
resp = client.post("/api/theme/remove", json={"theme": "custom"})
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
assert not (app_env / "config" / "themes" / "custom").exists()
|
||||
|
||||
|
||||
def test_theme_remove_protected_theme(client):
|
||||
resp = client.post("/api/theme/remove", json={"theme": "modern"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_theme_remove_missing_theme(client):
|
||||
resp = client.post("/api/theme/remove", json={"theme": "does-not-exist"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_theme_info_get_and_post(client, app_env):
|
||||
resp = client.get("/api/theme-info")
|
||||
body = resp.get_json()
|
||||
assert body["theme_name"] == "modern"
|
||||
assert body["theme_yaml"]["favicon"]["path"] == "favicon.png"
|
||||
|
||||
new_theme_yaml = dict(body["theme_yaml"])
|
||||
new_theme_yaml["colors"] = {"primary": "#123456"}
|
||||
resp = client.post(
|
||||
"/api/theme-info",
|
||||
json={"theme_name": "modern", "theme_yaml": new_theme_yaml},
|
||||
)
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
|
||||
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
|
||||
assert saved["colors"]["primary"] == "#123456"
|
||||
|
||||
|
||||
def test_update_theme_google_fonts(client, app_env):
|
||||
resp = client.post(
|
||||
"/api/theme-google-fonts",
|
||||
json={"theme_name": "modern", "google_fonts": [{"family": "Lato"}]},
|
||||
)
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
|
||||
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
|
||||
assert saved["google_fonts"] == [{"family": "Lato"}]
|
||||
|
||||
|
||||
def test_local_fonts_empty_then_populated(client, app_env):
|
||||
assert client.get("/api/local-fonts?theme=modern").get_json() == []
|
||||
|
||||
fonts_dir = app_env / "config" / "themes" / "modern" / "fonts"
|
||||
fonts_dir.mkdir(parents=True)
|
||||
(fonts_dir / "trixie.woff2").write_bytes(b"fake")
|
||||
|
||||
assert client.get("/api/local-fonts?theme=modern").get_json() == ["trixie.woff2"]
|
||||
|
||||
|
||||
def test_favicon_upload_invalid_extension(client):
|
||||
data = {"theme": "modern", "file": (io.BytesIO(b"not-an-image"), "favicon.txt")}
|
||||
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_favicon_upload_and_remove(client, app_env):
|
||||
data = {"theme": "modern", "file": (io.BytesIO(b"fake-png"), "favicon.png")}
|
||||
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
|
||||
assert resp.get_json()["status"] == "ok"
|
||||
assert (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
|
||||
|
||||
resp = client.post("/api/favicon/remove", json={"theme": "modern"})
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
assert not (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
|
||||
|
||||
|
||||
def test_serve_theme_asset(client, app_env):
|
||||
(app_env / "config" / "themes" / "modern" / "theme.css").write_text("body{}", encoding="utf-8")
|
||||
resp = client.get("/themes/modern/theme.css")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_font_upload_invalid_extension(client):
|
||||
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "font.ttf")}
|
||||
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_font_upload_and_remove(client, app_env):
|
||||
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "trixie.woff2")}
|
||||
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["filename"] == "trixie"
|
||||
|
||||
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "trixie.woff2"})
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_font_remove_missing(client):
|
||||
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "missing.woff2"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_build_missing_site_yaml(client, app_env):
|
||||
(app_env / "config" / "site.yaml").unlink()
|
||||
resp = client.post("/api/build")
|
||||
assert resp.status_code == 400
|
||||
assert "site.yaml not found" in resp.get_json()["message"]
|
||||
|
||||
|
||||
def test_trigger_build_missing_required_field(client, app_env):
|
||||
resp = client.post("/api/build")
|
||||
assert resp.status_code == 400
|
||||
assert "Site info are not set" in resp.get_json()["message"]
|
||||
|
||||
|
||||
def test_trigger_build_success_invokes_subprocess(client, app_env, monkeypatch):
|
||||
import src.py.webui.webui as webui
|
||||
|
||||
(app_env / "config" / "site.yaml").write_text(
|
||||
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
webui.subprocess, "run", lambda *a, **k: calls.append((a, k))
|
||||
)
|
||||
|
||||
resp = client.post("/api/build")
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
assert calls
|
||||
|
||||
|
||||
def test_trigger_build_subprocess_failure_returns_500(client, app_env, monkeypatch):
|
||||
import src.py.webui.webui as webui
|
||||
|
||||
(app_env / "config" / "site.yaml").write_text(
|
||||
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _raise(*a, **k):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(webui.subprocess, "run", _raise)
|
||||
|
||||
resp = client.post("/api/build")
|
||||
assert resp.status_code == 500
|
||||
assert "boom" in resp.get_json()["message"]
|
||||
|
||||
|
||||
def test_download_output_zip(client, app_env):
|
||||
output_dir = app_env / "output"
|
||||
output_dir.mkdir()
|
||||
(output_dir / "index.html").write_text("<html></html>", 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()
|
||||
Reference in New Issue
Block a user